feat: implemented v1 audio-to-midi conversion (polyphonic only, algorithm-based)
This commit is contained in:
@@ -153,6 +153,17 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dialog-two-column-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog-half-width-group {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dialog-hint-card {
|
||||
margin-top: 12px;
|
||||
background-color: #252525;
|
||||
@@ -221,6 +232,27 @@
|
||||
accent-color: #5a9fd4;
|
||||
}
|
||||
|
||||
.dialog-compact-checkbox-row {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dialog-compact-checkbox-row span {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.dialog-compact-input {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.dialog-inline-sep {
|
||||
color: #b0b0b0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock('../../core/config/ConfigManager', () => ({
|
||||
|
||||
import DialogProvider from './DialogProvider';
|
||||
import {
|
||||
showAudioToMidiOptions,
|
||||
showChoice,
|
||||
showChordDetectionOptions,
|
||||
showMidiChordDetectionOptions,
|
||||
@@ -170,6 +171,123 @@ describe('DialogProvider MIDI chord detection dialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DialogProvider audio-to-MIDI dialog', () => {
|
||||
it('opens with the expected defaults and resolves cancel to null', async () => {
|
||||
let resolved: unknown = 'pending';
|
||||
|
||||
render(
|
||||
<DialogProvider>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
resolved = await showAudioToMidiOptions(
|
||||
'Tune audio-to-MIDI conversion settings before processing.',
|
||||
[
|
||||
{ label: 'Lead Synth', value: 'track-1' },
|
||||
{ label: 'Bass', value: 'track-2' },
|
||||
],
|
||||
false,
|
||||
{
|
||||
monophonic: true,
|
||||
useCurrentFloorDb: true,
|
||||
manualFloorDb: -25,
|
||||
pitchRangeStart: 12,
|
||||
pitchRangeEnd: 107,
|
||||
quantizeNoteStart: '1/16',
|
||||
quantizeNoteLength: '1/16',
|
||||
convertLoopRangeOnly: true,
|
||||
groupAdjacentPitchesToHighest: true,
|
||||
targetTrackId: 'track-1',
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
Open audio-to-midi
|
||||
</button>
|
||||
</DialogProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open audio-to-midi' }));
|
||||
|
||||
expect(screen.getByText('Convert to MIDI')).toBeInTheDocument();
|
||||
expect(screen.getByText('Experimental Feature')).toBeInTheDocument();
|
||||
expect(screen.getByText(/best results, switch to spectrogram view/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Monophonic only (v1)')).toBeChecked();
|
||||
expect(screen.getByLabelText('Monophonic only (v1)')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Use current Floor dB')).toBeChecked();
|
||||
expect(screen.getByLabelText('Manual Floor dB')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Convert loop-range audio clip only')).toBeChecked();
|
||||
expect(screen.getByLabelText('Convert loop-range audio clip only')).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
finishDialogCloseAnimation();
|
||||
|
||||
await waitFor(() => expect(resolved).toBeNull());
|
||||
});
|
||||
|
||||
it('returns adjusted conversion options when confirmed', async () => {
|
||||
let resolved: unknown = null;
|
||||
|
||||
render(
|
||||
<DialogProvider>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
resolved = await showAudioToMidiOptions(
|
||||
'Tune audio-to-MIDI conversion settings before processing.',
|
||||
[
|
||||
{ label: 'Lead Synth', value: 'track-1' },
|
||||
{ label: 'Bass', value: 'track-2' },
|
||||
],
|
||||
true,
|
||||
{
|
||||
monophonic: true,
|
||||
useCurrentFloorDb: true,
|
||||
manualFloorDb: -25,
|
||||
pitchRangeStart: 12,
|
||||
pitchRangeEnd: 107,
|
||||
quantizeNoteStart: '1/16',
|
||||
quantizeNoteLength: '1/16',
|
||||
convertLoopRangeOnly: true,
|
||||
groupAdjacentPitchesToHighest: true,
|
||||
targetTrackId: 'track-1',
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
Open audio-to-midi
|
||||
</button>
|
||||
</DialogProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open audio-to-midi' }));
|
||||
fireEvent.click(screen.getByLabelText('Use current Floor dB'));
|
||||
fireEvent.change(screen.getByLabelText('Manual Floor dB'), { target: { value: '-16' } });
|
||||
fireEvent.change(screen.getByLabelText('Pitch range start'), { target: { value: '21' } });
|
||||
fireEvent.change(screen.getByLabelText('Pitch range end'), { target: { value: '38' } });
|
||||
fireEvent.change(screen.getByLabelText('Quantize note start'), { target: { value: '1/8' } });
|
||||
fireEvent.change(screen.getByLabelText('Quantize note length'), { target: { value: '1/8' } });
|
||||
fireEvent.click(screen.getByLabelText('Convert loop-range audio clip only'));
|
||||
fireEvent.click(screen.getByLabelText('Group adjacent pitches to the strongest bin'));
|
||||
fireEvent.change(screen.getByLabelText('Target MIDI track'), { target: { value: 'track-2' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'OK' }));
|
||||
finishDialogCloseAnimation();
|
||||
|
||||
await waitFor(() => expect(resolved).toEqual({
|
||||
monophonic: true,
|
||||
useCurrentFloorDb: false,
|
||||
manualFloorDb: -16,
|
||||
pitchRangeStart: 21,
|
||||
pitchRangeEnd: 38,
|
||||
quantizeNoteStart: '1/8',
|
||||
quantizeNoteLength: '1/8',
|
||||
convertLoopRangeOnly: false,
|
||||
groupAdjacentPitchesToHighest: false,
|
||||
targetTrackId: 'track-2',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DialogProvider tempo detection dialog', () => {
|
||||
it('opens the tempo detection modal with the expected defaults and resolves cancel to null', async () => {
|
||||
let resolved: unknown = 'pending';
|
||||
|
||||
@@ -2,9 +2,11 @@ import React, { useState, useCallback, useRef } from 'react';
|
||||
import './DialogProvider.css';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
import { registerDialogFns } from '../../util/dialogUtil';
|
||||
import type {
|
||||
AudioToMidiOptionsResult,
|
||||
ChoiceOption,
|
||||
ChordDetectionOptionsResult,
|
||||
ConfirmOptions,
|
||||
@@ -16,7 +18,7 @@ import type {
|
||||
} from '../../util/dialogUtil';
|
||||
|
||||
interface DialogInfo {
|
||||
type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice' | 'chord-detection' | 'midi-chord-detection' | 'tempo-detection' | 'tempo-apply';
|
||||
type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice' | 'chord-detection' | 'midi-chord-detection' | 'tempo-detection' | 'tempo-apply' | 'audio-to-midi';
|
||||
message: string;
|
||||
options?: ConfirmOptions | PromptOptions;
|
||||
defaultValue?: string;
|
||||
@@ -25,6 +27,9 @@ interface DialogInfo {
|
||||
defaultChordDetectionOptions?: ChordDetectionOptionsResult;
|
||||
defaultMidiChordDetectionOptions?: MidiChordDetectionOptionsResult;
|
||||
defaultTempoDetectionOptions?: TempoDetectionOptionsResult;
|
||||
defaultAudioToMidiOptions?: AudioToMidiOptionsResult;
|
||||
audioToMidiTargetTracks?: ChoiceOption[];
|
||||
audioToMidiLoopModeEnabled?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS: ChordDetectionOptionsResult = {
|
||||
@@ -45,6 +50,19 @@ const DEFAULT_TEMPO_DETECTION_OPTIONS: TempoDetectionOptionsResult = {
|
||||
maxTempo: 180,
|
||||
};
|
||||
|
||||
const DEFAULT_AUDIO_TO_MIDI_OPTIONS: AudioToMidiOptionsResult = {
|
||||
monophonic: true,
|
||||
useCurrentFloorDb: true,
|
||||
manualFloorDb: -25,
|
||||
pitchRangeStart: 12,
|
||||
pitchRangeEnd: 107,
|
||||
quantizeNoteStart: '1/16',
|
||||
quantizeNoteLength: '1/16',
|
||||
convertLoopRangeOnly: true,
|
||||
groupAdjacentPitchesToHighest: true,
|
||||
targetTrackId: '',
|
||||
};
|
||||
|
||||
const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { t } = useI18n();
|
||||
const [dialog, setDialog] = useState<DialogInfo | null>(null);
|
||||
@@ -55,6 +73,8 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
const [chordDetectionOptions, setChordDetectionOptions] = useState<ChordDetectionOptionsResult>(DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS);
|
||||
const [midiChordDetectionOptions, setMidiChordDetectionOptions] = useState<MidiChordDetectionOptionsResult>(DEFAULT_MIDI_CHORD_DETECTION_OPTIONS);
|
||||
const [tempoDetectionOptions, setTempoDetectionOptions] = useState<TempoDetectionOptionsResult>(DEFAULT_TEMPO_DETECTION_OPTIONS);
|
||||
const [audioToMidiOptions, setAudioToMidiOptions] = useState<AudioToMidiOptionsResult>(DEFAULT_AUDIO_TO_MIDI_OPTIONS);
|
||||
const [audioToMidiTargetTracks, setAudioToMidiTargetTracks] = useState<ChoiceOption[]>([]);
|
||||
const [autoAlignRegionToBeat, setAutoAlignRegionToBeat] = useState(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const resolveRef = useRef<((value: any) => void) | null>(null);
|
||||
@@ -142,6 +162,30 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openAudioToMidiOptions = useCallback((
|
||||
message: string,
|
||||
targetTracks: ChoiceOption[],
|
||||
loopModeEnabled: boolean,
|
||||
defaultValue?: AudioToMidiOptionsResult,
|
||||
): Promise<AudioToMidiOptionsResult | null> => {
|
||||
return new Promise<AudioToMidiOptionsResult | null>((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
const fallbackTrackId = targetTracks[0]?.value ?? '';
|
||||
setAudioToMidiTargetTracks(targetTracks);
|
||||
setAudioToMidiOptions({
|
||||
...(defaultValue ?? DEFAULT_AUDIO_TO_MIDI_OPTIONS),
|
||||
targetTrackId: defaultValue?.targetTrackId || fallbackTrackId,
|
||||
});
|
||||
setDialog({
|
||||
type: 'audio-to-midi',
|
||||
message,
|
||||
defaultAudioToMidiOptions: defaultValue,
|
||||
audioToMidiTargetTracks: targetTracks,
|
||||
audioToMidiLoopModeEnabled: loopModeEnabled,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const close = useCallback((value: unknown) => {
|
||||
pendingValueRef.current = value;
|
||||
setIsClosing(true);
|
||||
@@ -158,6 +202,8 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
setChordDetectionOptions(DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS);
|
||||
setMidiChordDetectionOptions(DEFAULT_MIDI_CHORD_DETECTION_OPTIONS);
|
||||
setTempoDetectionOptions(DEFAULT_TEMPO_DETECTION_OPTIONS);
|
||||
setAudioToMidiOptions(DEFAULT_AUDIO_TO_MIDI_OPTIONS);
|
||||
setAudioToMidiTargetTracks([]);
|
||||
setAutoAlignRegionToBeat(false);
|
||||
if (resolveRef.current) {
|
||||
resolveRef.current(pendingValueRef.current);
|
||||
@@ -180,6 +226,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
openMidiChordDetectionOptions,
|
||||
openTempoDetectionOptions,
|
||||
openTempoApply,
|
||||
openAudioToMidiOptions,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,6 +242,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
const isMidiChordDetection = dialog.type === 'midi-chord-detection';
|
||||
const isTempoDetection = dialog.type === 'tempo-detection';
|
||||
const isTempoApply = dialog.type === 'tempo-apply';
|
||||
const isAudioToMidi = dialog.type === 'audio-to-midi';
|
||||
const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined;
|
||||
const isKGOneEnabled = (ConfigManager.instance().get('general.kgone.enabled') as boolean | undefined) ?? false;
|
||||
|
||||
@@ -203,10 +251,12 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
: isTimeSig
|
||||
? t('dialog.title.timeSignature')
|
||||
: isTempoDetection
|
||||
? t('dialog.title.tempoDetection')
|
||||
: isTempoApply
|
||||
? t('dialog.title.applyTempo')
|
||||
: (isChordDetection || isMidiChordDetection)
|
||||
? t('dialog.title.tempoDetection')
|
||||
: isTempoApply
|
||||
? t('dialog.title.applyTempo')
|
||||
: isAudioToMidi
|
||||
? t('dialog.title.audioToMidi')
|
||||
: (isChordDetection || isMidiChordDetection)
|
||||
? t('dialog.title.chordDetection')
|
||||
: isPrompt
|
||||
? t('dialog.title.input')
|
||||
@@ -218,11 +268,11 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget && mouseDownOnOverlay.current) {
|
||||
close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice || isChordDetection || isTempoDetection || isTempoApply) ? null : false);
|
||||
close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice || isChordDetection || isMidiChordDetection || isTempoDetection || isTempoApply || isAudioToMidi) ? null : false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice || isChordDetection || isTempoDetection || isTempoApply) ? null : false);
|
||||
const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice || isChordDetection || isMidiChordDetection || isTempoDetection || isTempoApply || isAudioToMidi) ? null : false);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (isAlert) { close(undefined); return; }
|
||||
@@ -243,6 +293,10 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
close(tempoDetectionOptions);
|
||||
return;
|
||||
}
|
||||
if (isAudioToMidi) {
|
||||
close(audioToMidiOptions);
|
||||
return;
|
||||
}
|
||||
if (isTempoApply) {
|
||||
close({
|
||||
action: dialog.choices?.[dialog.choices.length - 1]?.value ?? '',
|
||||
@@ -274,14 +328,30 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
setTempoDetectionOptions(current => ({ ...current, [key]: value }));
|
||||
};
|
||||
|
||||
const updateAudioToMidiOption = <K extends keyof AudioToMidiOptionsResult>(
|
||||
key: K,
|
||||
value: AudioToMidiOptionsResult[K],
|
||||
) => {
|
||||
setAudioToMidiOptions(current => ({ ...current, [key]: value }));
|
||||
};
|
||||
|
||||
const detectionHintText = isChordDetection
|
||||
? t('dialog.chordHint.audio')
|
||||
: isMidiChordDetection
|
||||
? t('dialog.chordHint.midi')
|
||||
: isTempoDetection
|
||||
? t('dialog.chordHint.tempo')
|
||||
: isAudioToMidi
|
||||
? t('dialog.audioToMidiHint')
|
||||
: null;
|
||||
|
||||
const audioToMidiPitchOptions = Array.from({ length: 96 }, (_, index) => {
|
||||
const pitch = 12 + index;
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const noteName = `${noteNames[pitch % 12]}${Math.floor(pitch / 12) - 1}`;
|
||||
return { value: String(pitch), label: noteName };
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
@@ -298,7 +368,9 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<p className="dialog-message">{dialog.message}</p>
|
||||
{(!isAudioToMidi || dialog.message.trim().length > 0) && (
|
||||
<p className="dialog-message">{dialog.message}</p>
|
||||
)}
|
||||
{detectionHintText && (
|
||||
<div className="dialog-hint-card">
|
||||
<div className="dialog-hint-card-title">{t('dialog.experimentalFeature')}</div>
|
||||
@@ -347,6 +419,160 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isAudioToMidi && (
|
||||
<div className="dialog-chord-detection-form">
|
||||
<div className="dialog-two-column-row">
|
||||
<label className="dialog-checkbox-row dialog-compact-checkbox-row" htmlFor="dialog-audio-to-midi-monophonic">
|
||||
<input
|
||||
id="dialog-audio-to-midi-monophonic"
|
||||
type="checkbox"
|
||||
checked={audioToMidiOptions.monophonic}
|
||||
disabled={true}
|
||||
readOnly={true}
|
||||
autoFocus
|
||||
/>
|
||||
<span>{t('dialog.label.monophonicOnly')}</span>
|
||||
</label>
|
||||
<label className="dialog-checkbox-row dialog-compact-checkbox-row" htmlFor="dialog-audio-to-midi-current-floor">
|
||||
<input
|
||||
id="dialog-audio-to-midi-current-floor"
|
||||
type="checkbox"
|
||||
checked={audioToMidiOptions.useCurrentFloorDb}
|
||||
onChange={(e) => updateAudioToMidiOption('useCurrentFloorDb', e.target.checked)}
|
||||
/>
|
||||
<span>{t('dialog.label.useCurrentFloorDb')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="dialog-slider-group">
|
||||
<div className="dialog-slider-header">
|
||||
<label className="dialog-slider-label" htmlFor="dialog-audio-to-midi-floor">{t('dialog.label.manualFloorDb')}</label>
|
||||
<span className="dialog-slider-value">{audioToMidiOptions.manualFloorDb} dB</span>
|
||||
</div>
|
||||
<input
|
||||
id="dialog-audio-to-midi-floor"
|
||||
className="dialog-slider"
|
||||
type="range"
|
||||
aria-label={t('dialog.label.manualFloorDb')}
|
||||
min={-50}
|
||||
max={-5}
|
||||
step={1}
|
||||
value={audioToMidiOptions.manualFloorDb}
|
||||
onChange={(e) => updateAudioToMidiOption('manualFloorDb', Number(e.target.value))}
|
||||
disabled={audioToMidiOptions.useCurrentFloorDb}
|
||||
/>
|
||||
</div>
|
||||
<div className="dialog-slider-group">
|
||||
<div className="dialog-slider-header">
|
||||
<label className="dialog-slider-label" htmlFor="dialog-audio-to-midi-pitch-start">{t('dialog.label.pitchRange')}</label>
|
||||
</div>
|
||||
<div className="dialog-timesig-row">
|
||||
<select
|
||||
id="dialog-audio-to-midi-pitch-start"
|
||||
className="dialog-input"
|
||||
aria-label={`${t('dialog.label.pitchRange')} start`}
|
||||
value={String(audioToMidiOptions.pitchRangeStart)}
|
||||
onChange={(e) => {
|
||||
const nextStart = Number(e.target.value);
|
||||
updateAudioToMidiOption('pitchRangeStart', nextStart);
|
||||
if (nextStart > audioToMidiOptions.pitchRangeEnd) {
|
||||
updateAudioToMidiOption('pitchRangeEnd', nextStart);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{audioToMidiPitchOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="dialog-inline-sep">{t('dialog.to')}</span>
|
||||
<select
|
||||
className="dialog-input"
|
||||
aria-label={`${t('dialog.label.pitchRange')} end`}
|
||||
value={String(audioToMidiOptions.pitchRangeEnd)}
|
||||
onChange={(e) => {
|
||||
const nextEnd = Number(e.target.value);
|
||||
updateAudioToMidiOption('pitchRangeEnd', nextEnd);
|
||||
if (nextEnd < audioToMidiOptions.pitchRangeStart) {
|
||||
updateAudioToMidiOption('pitchRangeStart', nextEnd);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{audioToMidiPitchOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dialog-two-column-row">
|
||||
<div className="dialog-slider-group dialog-half-width-group">
|
||||
<div className="dialog-slider-header">
|
||||
<label className="dialog-slider-label" htmlFor="dialog-audio-to-midi-quant-start">{t('dialog.label.quantizeNoteStart')}</label>
|
||||
</div>
|
||||
<select
|
||||
id="dialog-audio-to-midi-quant-start"
|
||||
className="dialog-input dialog-compact-input"
|
||||
aria-label={t('dialog.label.quantizeNoteStart')}
|
||||
value={audioToMidiOptions.quantizeNoteStart}
|
||||
onChange={(e) => updateAudioToMidiOption('quantizeNoteStart', e.target.value)}
|
||||
>
|
||||
{KGPianoRollState.QUANT_POS_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{t(option.labelKey)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="dialog-slider-group dialog-half-width-group">
|
||||
<div className="dialog-slider-header">
|
||||
<label className="dialog-slider-label" htmlFor="dialog-audio-to-midi-quant-length">{t('dialog.label.quantizeNoteLength')}</label>
|
||||
</div>
|
||||
<select
|
||||
id="dialog-audio-to-midi-quant-length"
|
||||
className="dialog-input dialog-compact-input"
|
||||
aria-label={t('dialog.label.quantizeNoteLength')}
|
||||
value={audioToMidiOptions.quantizeNoteLength}
|
||||
onChange={(e) => updateAudioToMidiOption('quantizeNoteLength', e.target.value)}
|
||||
>
|
||||
{KGPianoRollState.QUANT_LEN_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{t(option.labelKey)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label className="dialog-checkbox-row" htmlFor="dialog-audio-to-midi-loop-only">
|
||||
<input
|
||||
id="dialog-audio-to-midi-loop-only"
|
||||
type="checkbox"
|
||||
checked={audioToMidiOptions.convertLoopRangeOnly}
|
||||
disabled={!dialog.audioToMidiLoopModeEnabled}
|
||||
onChange={(e) => updateAudioToMidiOption('convertLoopRangeOnly', e.target.checked)}
|
||||
/>
|
||||
<span>{t('dialog.label.convertLoopRangeOnly')}</span>
|
||||
</label>
|
||||
<label className="dialog-checkbox-row" htmlFor="dialog-audio-to-midi-group-adjacent">
|
||||
<input
|
||||
id="dialog-audio-to-midi-group-adjacent"
|
||||
type="checkbox"
|
||||
checked={audioToMidiOptions.groupAdjacentPitchesToHighest}
|
||||
onChange={(e) => updateAudioToMidiOption('groupAdjacentPitchesToHighest', e.target.checked)}
|
||||
/>
|
||||
<span>{t('dialog.label.groupAdjacentPitchesToHighest')}</span>
|
||||
</label>
|
||||
<div className="dialog-slider-group">
|
||||
<div className="dialog-slider-header">
|
||||
<label className="dialog-slider-label" htmlFor="dialog-audio-to-midi-target-track">{t('dialog.label.targetTrack')}</label>
|
||||
</div>
|
||||
<select
|
||||
id="dialog-audio-to-midi-target-track"
|
||||
className="dialog-input"
|
||||
aria-label={t('dialog.label.targetTrack')}
|
||||
value={audioToMidiOptions.targetTrackId}
|
||||
onChange={(e) => updateAudioToMidiOption('targetTrackId', e.target.value)}
|
||||
>
|
||||
{audioToMidiTargetTracks.map(choice => (
|
||||
<option key={choice.value} value={choice.value}>{choice.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isChordDetection && (
|
||||
<div className="dialog-chord-detection-form">
|
||||
<div className="dialog-hint-card">
|
||||
@@ -545,14 +771,14 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
<button
|
||||
className="dialog-btn dialog-btn-primary"
|
||||
onClick={handleConfirm}
|
||||
autoFocus={!isPrompt && !isTimeSig && !isChordDetection && !isMidiChordDetection && !isTempoDetection && !isTempoApply}
|
||||
autoFocus={!isPrompt && !isTimeSig && !isChordDetection && !isMidiChordDetection && !isTempoDetection && !isTempoApply && !isAudioToMidi}
|
||||
>
|
||||
{isAlert
|
||||
? t('dialog.ok')
|
||||
: ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel
|
||||
?? (isPrompt || isTimeSig
|
||||
? t('dialog.ok')
|
||||
: (isChordDetection || isMidiChordDetection || isTempoDetection)
|
||||
: (isChordDetection || isMidiChordDetection || isTempoDetection || isAudioToMidi)
|
||||
? t('dialog.ok')
|
||||
: t('settings.yes')))}
|
||||
</button>
|
||||
|
||||
@@ -7,5 +7,22 @@ export { default as OpenProjectModal } from './OpenProjectModal';
|
||||
export { default as DialogProvider } from './DialogProvider';
|
||||
export { default as TrackCreateDialog } from './TrackCreateDialog';
|
||||
export { default as FloatingPopup } from './FloatingPopup';
|
||||
export { showAlert, showChordDetectionOptions, showConfirm, showPrompt, showTempoApply, showTempoDetectionOptions, showTimeSigPrompt } from '../../util/dialogUtil';
|
||||
export type { ChordDetectionOptionsResult, ConfirmOptions, PromptOptions, TempoApplyResult, TempoDetectionOptionsResult, TimeSigResult } from '../../util/dialogUtil';
|
||||
export {
|
||||
showAlert,
|
||||
showAudioToMidiOptions,
|
||||
showChordDetectionOptions,
|
||||
showConfirm,
|
||||
showPrompt,
|
||||
showTempoApply,
|
||||
showTempoDetectionOptions,
|
||||
showTimeSigPrompt,
|
||||
} from '../../util/dialogUtil';
|
||||
export type {
|
||||
AudioToMidiOptionsResult,
|
||||
ChordDetectionOptionsResult,
|
||||
ConfirmOptions,
|
||||
PromptOptions,
|
||||
TempoApplyResult,
|
||||
TempoDetectionOptionsResult,
|
||||
TimeSigResult,
|
||||
} from '../../util/dialogUtil';
|
||||
|
||||
@@ -10,6 +10,7 @@ import PianoRollHeader from './PianoRollHeader';
|
||||
import PianoRollToolbar from './PianoRollToolbar';
|
||||
import NoteAttributeBar from './NoteAttributeBar';
|
||||
import PianoRollContent from './PianoRollContent';
|
||||
import { LoadingOverlay } from '../common';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||
import { KGMidiTrack, type InstrumentType } from '../../core/track/KGMidiTrack';
|
||||
@@ -21,11 +22,18 @@ import {
|
||||
type PianoRollSnapValue,
|
||||
} from '../../core/state/KGPianoRollState';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { beatsToBar } from '../../util/midiUtil';
|
||||
import { ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../core/commands';
|
||||
import { beatsToBar, type RawMidiNote } from '../../util/midiUtil';
|
||||
import { ImportMidiClipCommand, ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../core/commands';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
|
||||
import { showAlert, showChordDetectionOptions, showMidiChordDetectionOptions, showTempoApply, showTempoDetectionOptions } from '../../util/dialogUtil';
|
||||
import {
|
||||
showAlert,
|
||||
showAudioToMidiOptions,
|
||||
showChordDetectionOptions,
|
||||
showMidiChordDetectionOptions,
|
||||
showTempoApply,
|
||||
showTempoDetectionOptions,
|
||||
} from '../../util/dialogUtil';
|
||||
import { matchesKeyboardShortcut } from '../../util/osUtil';
|
||||
import { resolveChordGuideItems } from '../../util/chordGuideDataUtil';
|
||||
import {
|
||||
@@ -59,6 +67,7 @@ import {
|
||||
type MidiChordDetectionOptions,
|
||||
} from '../../util/midiChordDetection';
|
||||
import type { AudioChordDetectionWorkerMessage } from '../../workers/audioChordDetectionWorker';
|
||||
import type { AudioToMidiWorkerResult } from '../../workers/audioToMidiWorker';
|
||||
import type { PianoRollAutomationType } from './pianoRollAutomation';
|
||||
import type { SheetMeasureMetric } from './sheetNotationTypes';
|
||||
import { getSheetPlayheadPixel, getSheetQuantizationOptions, parseSheetQuantization } from './sheetNotation';
|
||||
@@ -70,6 +79,11 @@ import {
|
||||
} from './pianoRollViewport';
|
||||
import { getNextChordGuideSelection, resolveChordGuideContext, type ChordGuideFunction } from './chordGuideUtil';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
import {
|
||||
buildAudioToMidiAnalysisSpan,
|
||||
convertDetectedAudioNotesToRawMidiNotes,
|
||||
} from '../../util/audioToMidi';
|
||||
import { TrackType } from '../../core/track/KGTrack';
|
||||
|
||||
interface PianoRollProps {
|
||||
onClose: () => void;
|
||||
@@ -84,6 +98,13 @@ interface PianoRollProps {
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
type AudioToMidiWorkerHandle = {
|
||||
onmessage: ((event: MessageEvent<AudioToMidiWorkerResult>) => void) | null;
|
||||
onerror: ((this: AbstractWorker, ev: ErrorEvent) => unknown) | null;
|
||||
postMessage: Worker['postMessage'];
|
||||
terminate: () => void;
|
||||
};
|
||||
|
||||
const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
onClose,
|
||||
regionId,
|
||||
@@ -101,7 +122,32 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
const isAudioWaveform = currentMode === 'audio-waveform';
|
||||
const isAudioOnly = isAudioWaveform || isSpectrogram;
|
||||
const isHybrid = currentMode === 'hybrid';
|
||||
const { maxBars, tracks, updateTrack, updateRegionProperties, timeSignature, showChatBox, showKGOnePanel, showEventListPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, selectedRegionIds, automationRedrawVersion, refreshProjectState, setBpm } = useProjectStore();
|
||||
const {
|
||||
maxBars,
|
||||
tracks,
|
||||
updateTrack,
|
||||
updateRegionProperties,
|
||||
timeSignature,
|
||||
showChatBox,
|
||||
showKGOnePanel,
|
||||
showEventListPanel,
|
||||
showInstrumentSelection,
|
||||
keySignature,
|
||||
selectedMode,
|
||||
setSelectedMode,
|
||||
playheadPosition,
|
||||
isPlaying,
|
||||
autoScrollEnabled,
|
||||
bpm,
|
||||
pianoRollScrollRequest,
|
||||
selectedNoteIds,
|
||||
selectedRegionIds,
|
||||
automationRedrawVersion,
|
||||
refreshProjectState,
|
||||
setBpm,
|
||||
isLooping,
|
||||
loopingRange,
|
||||
} = useProjectStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
// Tool state for piano roll
|
||||
@@ -115,6 +161,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
const [isDetectingChords, setIsDetectingChords] = useState(false);
|
||||
const [detectChordProgressPercent, setDetectChordProgressPercent] = useState(0);
|
||||
const [isDetectingTempo, setIsDetectingTempo] = useState(false);
|
||||
const [isConvertingToMidi, setIsConvertingToMidi] = useState(false);
|
||||
|
||||
// Piano roll zoom (1x–8x); updates --region-grid-beat-width CSS variable
|
||||
const [pianoRollZoom, setPianoRollZoom] = useState<number>(() => KGPianoRollState.instance().getPianoRollZoom());
|
||||
@@ -164,6 +211,14 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
|
||||
return tracks.find(track => track.getId().toString() === activeRegion.getTrackId()) ?? null;
|
||||
}, [activeRegion, tracks]);
|
||||
const availableMidiTracks = useMemo(
|
||||
() => tracks.filter(track => track.getType() === TrackType.MIDI),
|
||||
[tracks],
|
||||
);
|
||||
const sourceAudioTrack = useMemo(
|
||||
() => audioRegion ? tracks.find(track => track.getId().toString() === audioRegion.getTrackId()) ?? null : null,
|
||||
[audioRegion, tracks],
|
||||
);
|
||||
const activeInstrument = useMemo<InstrumentType>(() => (
|
||||
parentMidiTrack instanceof KGMidiTrack ? parentMidiTrack.getInstrument() : 'acoustic_grand_piano'
|
||||
), [parentMidiTrack]);
|
||||
@@ -514,6 +569,165 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
}
|
||||
}, [activeEditableRegionId, selectedRegionIds, tracks, updateRegionProperties]);
|
||||
|
||||
const getMonoPcmForAudioRegion = useCallback(async () => {
|
||||
if (!audioRegion || !projectName || !trackId) {
|
||||
throw new Error('Audio-to-MIDI conversion requires an active audio region with track and project context.');
|
||||
}
|
||||
|
||||
let audioBuffer = KGAudioInterface.instance().getAudioBuffer(trackId, audioRegion.getAudioFileId());
|
||||
if (!audioBuffer) {
|
||||
const rawBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioRegion.getAudioFileId());
|
||||
const audioContext = Tone.getContext().rawContext as AudioContext;
|
||||
audioBuffer = await audioContext.decodeAudioData(rawBuffer);
|
||||
}
|
||||
|
||||
const monoPcm = new Float32Array(audioBuffer.length);
|
||||
for (let channelIndex = 0; channelIndex < audioBuffer.numberOfChannels; channelIndex++) {
|
||||
const channelData = audioBuffer.getChannelData(channelIndex);
|
||||
for (let sampleIndex = 0; sampleIndex < audioBuffer.length; sampleIndex++) {
|
||||
monoPcm[sampleIndex] += channelData[sampleIndex] / audioBuffer.numberOfChannels;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pcm: monoPcm,
|
||||
sampleRate: audioBuffer.sampleRate,
|
||||
};
|
||||
}, [audioRegion, projectName, trackId]);
|
||||
|
||||
const handleConvertToMidi = useCallback(async () => {
|
||||
if (!audioRegion) {
|
||||
await showAlert('Open an audio region before converting it to MIDI.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectName || !trackId) {
|
||||
await showAlert('Open an audio region in the piano roll before converting it to MIDI.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (availableMidiTracks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTrackChoices = availableMidiTracks.map(track => ({
|
||||
label: track.getName(),
|
||||
value: track.getId().toString(),
|
||||
}));
|
||||
const dialogResult = await showAudioToMidiOptions(
|
||||
'',
|
||||
targetTrackChoices,
|
||||
isLooping,
|
||||
{
|
||||
monophonic: true,
|
||||
useCurrentFloorDb: true,
|
||||
manualFloorDb: spectrogramThresholdDb,
|
||||
pitchRangeStart: 12,
|
||||
pitchRangeEnd: 107,
|
||||
quantizeNoteStart: '1/16',
|
||||
quantizeNoteLength: '1/16',
|
||||
convertLoopRangeOnly: true,
|
||||
groupAdjacentPitchesToHighest: true,
|
||||
targetTrackId: targetTrackChoices[0]?.value ?? '',
|
||||
},
|
||||
);
|
||||
if (!dialogResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const analysisSpan = buildAudioToMidiAnalysisSpan(project, audioRegion, {
|
||||
loopModeEnabled: isLooping,
|
||||
convertLoopRangeOnly: dialogResult.convertLoopRangeOnly,
|
||||
loopingRange,
|
||||
});
|
||||
if (!analysisSpan) {
|
||||
await showAlert('The loop range does not overlap with the selected audio region.');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTrack = availableMidiTracks.find(track => track.getId().toString() === dialogResult.targetTrackId);
|
||||
if (!targetTrack) {
|
||||
await showAlert('Please choose a valid MIDI target track.');
|
||||
return;
|
||||
}
|
||||
|
||||
const floorDb = dialogResult.useCurrentFloorDb
|
||||
? spectrogramThresholdDb
|
||||
: dialogResult.manualFloorDb;
|
||||
|
||||
let worker: AudioToMidiWorkerHandle | null = null;
|
||||
setIsConvertingToMidi(true);
|
||||
|
||||
try {
|
||||
const { pcm, sampleRate } = await getMonoPcmForAudioRegion();
|
||||
const detectedNotes = await new Promise<RawMidiNote[]>((resolve, reject) => {
|
||||
worker = new Worker(
|
||||
new URL('../../workers/audioToMidiWorker.ts', import.meta.url),
|
||||
{ type: 'module' },
|
||||
) as AudioToMidiWorkerHandle;
|
||||
|
||||
worker.onmessage = (event: MessageEvent<AudioToMidiWorkerResult>) => {
|
||||
if (event.data.type !== 'result') {
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(convertDetectedAudioNotesToRawMidiNotes(
|
||||
project,
|
||||
analysisSpan,
|
||||
event.data.notes,
|
||||
{
|
||||
quantizeNoteStart: dialogResult.quantizeNoteStart as PianoRollQuantizePositionValue,
|
||||
quantizeNoteLength: dialogResult.quantizeNoteLength as PianoRollQuantizeLengthValue,
|
||||
},
|
||||
));
|
||||
};
|
||||
worker.onerror = () => reject(new Error('Audio-to-MIDI worker failed.'));
|
||||
worker.postMessage({
|
||||
pcm,
|
||||
sampleRate,
|
||||
startSeconds: analysisSpan.startSeconds,
|
||||
endSeconds: analysisSpan.endSeconds,
|
||||
floorDb,
|
||||
pitchRangeStart: dialogResult.pitchRangeStart,
|
||||
pitchRangeEnd: dialogResult.pitchRangeEnd,
|
||||
groupAdjacentPitchesToHighest: dialogResult.groupAdjacentPitchesToHighest,
|
||||
}, [pcm.buffer]);
|
||||
});
|
||||
|
||||
const regionLengthBeats = analysisSpan.regionEndBeat - analysisSpan.regionStartBeat;
|
||||
const importCommand = new ImportMidiClipCommand(
|
||||
targetTrack.getId().toString(),
|
||||
targetTrack.getTrackIndex(),
|
||||
analysisSpan.regionStartBeat,
|
||||
regionLengthBeats,
|
||||
detectedNotes,
|
||||
`Converted from ${sourceAudioTrack?.getName() ?? audioRegion.getName()}`,
|
||||
);
|
||||
KGCore.instance().executeCommand(importCommand);
|
||||
refreshProjectState();
|
||||
} catch (error) {
|
||||
console.error('Error converting audio to MIDI:', error);
|
||||
await showAlert('Failed to convert this audio region to MIDI.');
|
||||
} finally {
|
||||
const cleanupWorker = worker as AudioToMidiWorkerHandle | null;
|
||||
cleanupWorker?.terminate();
|
||||
setIsConvertingToMidi(false);
|
||||
}
|
||||
}, [
|
||||
audioRegion,
|
||||
availableMidiTracks,
|
||||
getMonoPcmForAudioRegion,
|
||||
isLooping,
|
||||
loopingRange,
|
||||
projectName,
|
||||
refreshProjectState,
|
||||
sourceAudioTrack,
|
||||
spectrogramThresholdDb,
|
||||
t,
|
||||
trackId,
|
||||
]);
|
||||
|
||||
const handleDetectChords = useCallback(async () => {
|
||||
if (!audioRegion && !activeRegion) {
|
||||
await showAlert('Open a MIDI or audio region before detecting chords.');
|
||||
@@ -1619,6 +1833,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
detectingChords={isDetectingChords}
|
||||
onDetectTempo={audioRegion ? handleDetectTempo : undefined}
|
||||
detectingTempo={isDetectingTempo}
|
||||
onConvertToMidi={audioRegion ? handleConvertToMidi : undefined}
|
||||
convertToMidiDisabled={availableMidiTracks.length === 0}
|
||||
selectedRegionColor={selectedRegionColor}
|
||||
onRegionColorSelect={activeEditableRegionId ? (color) => { void handleRegionColorSelect(color); } : undefined}
|
||||
/>
|
||||
@@ -1669,6 +1885,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
>
|
||||
<FaGripLines />
|
||||
</div>
|
||||
<LoadingOverlay visible={isConvertingToMidi} message={t('kgone.shared.btn.processing')} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -325,6 +325,46 @@ describe('PianoRollToolbar', () => {
|
||||
expect(screen.queryByText('Detect tempo...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the convert to MIDI action as the last audio option and triggers it', () => {
|
||||
const onConvertToMidi = vi.fn();
|
||||
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="spectrogram"
|
||||
showAutomationControls={false}
|
||||
onConvertToMidi={onConvertToMidi}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('More options'));
|
||||
const options = screen.getAllByText(/Detect chords...|Convert to MIDI.../);
|
||||
expect(options[options.length - 1]).toHaveTextContent('Convert to MIDI...');
|
||||
fireEvent.click(screen.getByText('Convert to MIDI...'));
|
||||
|
||||
expect(onConvertToMidi).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables the convert to MIDI action when no MIDI tracks are available', () => {
|
||||
const onConvertToMidi = vi.fn();
|
||||
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="spectrogram"
|
||||
showAutomationControls={false}
|
||||
onConvertToMidi={onConvertToMidi}
|
||||
convertToMidiDisabled={true}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('More options'));
|
||||
const convertItem = screen.getByText('Convert to MIDI...');
|
||||
expect(convertItem).toHaveAttribute('aria-disabled', 'true');
|
||||
fireEvent.click(convertItem);
|
||||
expect(onConvertToMidi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows only the sheet controls when sheet mode is enabled', () => {
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
|
||||
@@ -50,6 +50,8 @@ interface PianoRollToolbarProps {
|
||||
detectingChords?: boolean;
|
||||
onDetectTempo?: () => void | Promise<void>;
|
||||
detectingTempo?: boolean;
|
||||
onConvertToMidi?: () => void | Promise<void>;
|
||||
convertToMidiDisabled?: boolean;
|
||||
selectedRegionColor?: string;
|
||||
onRegionColorSelect?: (color: string | null) => void;
|
||||
}
|
||||
@@ -94,6 +96,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
detectingChords = false,
|
||||
onDetectTempo,
|
||||
detectingTempo = false,
|
||||
onConvertToMidi,
|
||||
convertToMidiDisabled = false,
|
||||
selectedRegionColor,
|
||||
onRegionColorSelect,
|
||||
}) => {
|
||||
@@ -102,7 +106,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
const showAudioOnlyControls = mode === 'audio-waveform' && !sheetMusicViewEnabled;
|
||||
const showSpectrogramOnlyControls = mode === 'spectrogram' && !sheetMusicViewEnabled;
|
||||
const showSpecControls = !sheetMusicViewEnabled && (mode === 'spectrogram' || mode === 'hybrid');
|
||||
const showSpecMenu = !sheetMusicViewEnabled && (!!onDetectChords || !!onDetectTempo);
|
||||
const showSpecMenu = !sheetMusicViewEnabled && (!!onDetectChords || !!onDetectTempo || !!onConvertToMidi);
|
||||
const automationOptions = React.useMemo(() => getTranslatedAutomationOptions(t), [t]);
|
||||
const snapOptions = React.useMemo(
|
||||
() => KGPianoRollState.SNAP_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value })),
|
||||
@@ -447,6 +451,22 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
{detectingTempo ? t('pianoRoll.detectingTempo') : t('pianoRoll.detectTempo')}
|
||||
</div>
|
||||
)}
|
||||
{onConvertToMidi && (
|
||||
<div
|
||||
className={`quant-option${convertToMidiDisabled ? ' disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (convertToMidiDisabled) {
|
||||
return;
|
||||
}
|
||||
setShowRegionColorPalette(false);
|
||||
setShowMoreMenu(false);
|
||||
void onConvertToMidi();
|
||||
}}
|
||||
aria-disabled={convertToMidiDisabled}
|
||||
>
|
||||
{t('pianoRoll.convertToMidi')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user