feat: implemented v1 audio-to-midi conversion (polyphonic only, algorithm-based)

This commit is contained in:
Xiaohan-Tian
2026-07-06 22:56:22 -07:00
parent f4e22e50a4
commit 5aa74e61a8
18 changed files with 1518 additions and 17 deletions
+32
View File
@@ -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';
+236 -10
View File
@@ -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>
+19 -2
View File
@@ -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';
+221 -4
View File
@@ -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 (1x8x); 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
+21 -1
View File
@@ -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>