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
Binary file not shown.
+32
View File
@@ -153,6 +153,17 @@
gap: 16px; 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 { .dialog-hint-card {
margin-top: 12px; margin-top: 12px;
background-color: #252525; background-color: #252525;
@@ -221,6 +232,27 @@
accent-color: #5a9fd4; 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 { .dialog-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -19,6 +19,7 @@ vi.mock('../../core/config/ConfigManager', () => ({
import DialogProvider from './DialogProvider'; import DialogProvider from './DialogProvider';
import { import {
showAudioToMidiOptions,
showChoice, showChoice,
showChordDetectionOptions, showChordDetectionOptions,
showMidiChordDetectionOptions, 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', () => { describe('DialogProvider tempo detection dialog', () => {
it('opens the tempo detection modal with the expected defaults and resolves cancel to null', async () => { it('opens the tempo detection modal with the expected defaults and resolves cancel to null', async () => {
let resolved: unknown = 'pending'; let resolved: unknown = 'pending';
+236 -10
View File
@@ -2,9 +2,11 @@ import React, { useState, useCallback, useRef } from 'react';
import './DialogProvider.css'; import './DialogProvider.css';
import { FaTimes } from 'react-icons/fa'; import { FaTimes } from 'react-icons/fa';
import { ConfigManager } from '../../core/config/ConfigManager'; import { ConfigManager } from '../../core/config/ConfigManager';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { useI18n } from '../../i18n/useI18n'; import { useI18n } from '../../i18n/useI18n';
import { registerDialogFns } from '../../util/dialogUtil'; import { registerDialogFns } from '../../util/dialogUtil';
import type { import type {
AudioToMidiOptionsResult,
ChoiceOption, ChoiceOption,
ChordDetectionOptionsResult, ChordDetectionOptionsResult,
ConfirmOptions, ConfirmOptions,
@@ -16,7 +18,7 @@ import type {
} from '../../util/dialogUtil'; } from '../../util/dialogUtil';
interface DialogInfo { 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; message: string;
options?: ConfirmOptions | PromptOptions; options?: ConfirmOptions | PromptOptions;
defaultValue?: string; defaultValue?: string;
@@ -25,6 +27,9 @@ interface DialogInfo {
defaultChordDetectionOptions?: ChordDetectionOptionsResult; defaultChordDetectionOptions?: ChordDetectionOptionsResult;
defaultMidiChordDetectionOptions?: MidiChordDetectionOptionsResult; defaultMidiChordDetectionOptions?: MidiChordDetectionOptionsResult;
defaultTempoDetectionOptions?: TempoDetectionOptionsResult; defaultTempoDetectionOptions?: TempoDetectionOptionsResult;
defaultAudioToMidiOptions?: AudioToMidiOptionsResult;
audioToMidiTargetTracks?: ChoiceOption[];
audioToMidiLoopModeEnabled?: boolean;
} }
const DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS: ChordDetectionOptionsResult = { const DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS: ChordDetectionOptionsResult = {
@@ -45,6 +50,19 @@ const DEFAULT_TEMPO_DETECTION_OPTIONS: TempoDetectionOptionsResult = {
maxTempo: 180, 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 DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { t } = useI18n(); const { t } = useI18n();
const [dialog, setDialog] = useState<DialogInfo | null>(null); 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 [chordDetectionOptions, setChordDetectionOptions] = useState<ChordDetectionOptionsResult>(DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS);
const [midiChordDetectionOptions, setMidiChordDetectionOptions] = useState<MidiChordDetectionOptionsResult>(DEFAULT_MIDI_CHORD_DETECTION_OPTIONS); const [midiChordDetectionOptions, setMidiChordDetectionOptions] = useState<MidiChordDetectionOptionsResult>(DEFAULT_MIDI_CHORD_DETECTION_OPTIONS);
const [tempoDetectionOptions, setTempoDetectionOptions] = useState<TempoDetectionOptionsResult>(DEFAULT_TEMPO_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); const [autoAlignRegionToBeat, setAutoAlignRegionToBeat] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolveRef = useRef<((value: any) => void) | null>(null); 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) => { const close = useCallback((value: unknown) => {
pendingValueRef.current = value; pendingValueRef.current = value;
setIsClosing(true); setIsClosing(true);
@@ -158,6 +202,8 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
setChordDetectionOptions(DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS); setChordDetectionOptions(DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS);
setMidiChordDetectionOptions(DEFAULT_MIDI_CHORD_DETECTION_OPTIONS); setMidiChordDetectionOptions(DEFAULT_MIDI_CHORD_DETECTION_OPTIONS);
setTempoDetectionOptions(DEFAULT_TEMPO_DETECTION_OPTIONS); setTempoDetectionOptions(DEFAULT_TEMPO_DETECTION_OPTIONS);
setAudioToMidiOptions(DEFAULT_AUDIO_TO_MIDI_OPTIONS);
setAudioToMidiTargetTracks([]);
setAutoAlignRegionToBeat(false); setAutoAlignRegionToBeat(false);
if (resolveRef.current) { if (resolveRef.current) {
resolveRef.current(pendingValueRef.current); resolveRef.current(pendingValueRef.current);
@@ -180,6 +226,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
openMidiChordDetectionOptions, openMidiChordDetectionOptions,
openTempoDetectionOptions, openTempoDetectionOptions,
openTempoApply, openTempoApply,
openAudioToMidiOptions,
); );
} }
@@ -195,6 +242,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const isMidiChordDetection = dialog.type === 'midi-chord-detection'; const isMidiChordDetection = dialog.type === 'midi-chord-detection';
const isTempoDetection = dialog.type === 'tempo-detection'; const isTempoDetection = dialog.type === 'tempo-detection';
const isTempoApply = dialog.type === 'tempo-apply'; const isTempoApply = dialog.type === 'tempo-apply';
const isAudioToMidi = dialog.type === 'audio-to-midi';
const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined; const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined;
const isKGOneEnabled = (ConfigManager.instance().get('general.kgone.enabled') as boolean | undefined) ?? false; 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 : isTimeSig
? t('dialog.title.timeSignature') ? t('dialog.title.timeSignature')
: isTempoDetection : isTempoDetection
? t('dialog.title.tempoDetection') ? t('dialog.title.tempoDetection')
: isTempoApply : isTempoApply
? t('dialog.title.applyTempo') ? t('dialog.title.applyTempo')
: (isChordDetection || isMidiChordDetection) : isAudioToMidi
? t('dialog.title.audioToMidi')
: (isChordDetection || isMidiChordDetection)
? t('dialog.title.chordDetection') ? t('dialog.title.chordDetection')
: isPrompt : isPrompt
? t('dialog.title.input') ? t('dialog.title.input')
@@ -218,11 +268,11 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const handleOverlayClick = (e: React.MouseEvent) => { const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && mouseDownOnOverlay.current) { 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 = () => { const handleConfirm = () => {
if (isAlert) { close(undefined); return; } if (isAlert) { close(undefined); return; }
@@ -243,6 +293,10 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
close(tempoDetectionOptions); close(tempoDetectionOptions);
return; return;
} }
if (isAudioToMidi) {
close(audioToMidiOptions);
return;
}
if (isTempoApply) { if (isTempoApply) {
close({ close({
action: dialog.choices?.[dialog.choices.length - 1]?.value ?? '', 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 })); setTempoDetectionOptions(current => ({ ...current, [key]: value }));
}; };
const updateAudioToMidiOption = <K extends keyof AudioToMidiOptionsResult>(
key: K,
value: AudioToMidiOptionsResult[K],
) => {
setAudioToMidiOptions(current => ({ ...current, [key]: value }));
};
const detectionHintText = isChordDetection const detectionHintText = isChordDetection
? t('dialog.chordHint.audio') ? t('dialog.chordHint.audio')
: isMidiChordDetection : isMidiChordDetection
? t('dialog.chordHint.midi') ? t('dialog.chordHint.midi')
: isTempoDetection : isTempoDetection
? t('dialog.chordHint.tempo') ? t('dialog.chordHint.tempo')
: isAudioToMidi
? t('dialog.audioToMidiHint')
: null; : 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 ( return (
<> <>
{children} {children}
@@ -298,7 +368,9 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
</button> </button>
</div> </div>
<div className="dialog-body"> <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 && ( {detectionHintText && (
<div className="dialog-hint-card"> <div className="dialog-hint-card">
<div className="dialog-hint-card-title">{t('dialog.experimentalFeature')}</div> <div className="dialog-hint-card-title">{t('dialog.experimentalFeature')}</div>
@@ -347,6 +419,160 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
/> />
</div> </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 && ( {isChordDetection && (
<div className="dialog-chord-detection-form"> <div className="dialog-chord-detection-form">
<div className="dialog-hint-card"> <div className="dialog-hint-card">
@@ -545,14 +771,14 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
<button <button
className="dialog-btn dialog-btn-primary" className="dialog-btn dialog-btn-primary"
onClick={handleConfirm} onClick={handleConfirm}
autoFocus={!isPrompt && !isTimeSig && !isChordDetection && !isMidiChordDetection && !isTempoDetection && !isTempoApply} autoFocus={!isPrompt && !isTimeSig && !isChordDetection && !isMidiChordDetection && !isTempoDetection && !isTempoApply && !isAudioToMidi}
> >
{isAlert {isAlert
? t('dialog.ok') ? t('dialog.ok')
: ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel
?? (isPrompt || isTimeSig ?? (isPrompt || isTimeSig
? t('dialog.ok') ? t('dialog.ok')
: (isChordDetection || isMidiChordDetection || isTempoDetection) : (isChordDetection || isMidiChordDetection || isTempoDetection || isAudioToMidi)
? t('dialog.ok') ? t('dialog.ok')
: t('settings.yes')))} : t('settings.yes')))}
</button> </button>
+19 -2
View File
@@ -7,5 +7,22 @@ export { default as OpenProjectModal } from './OpenProjectModal';
export { default as DialogProvider } from './DialogProvider'; export { default as DialogProvider } from './DialogProvider';
export { default as TrackCreateDialog } from './TrackCreateDialog'; export { default as TrackCreateDialog } from './TrackCreateDialog';
export { default as FloatingPopup } from './FloatingPopup'; export { default as FloatingPopup } from './FloatingPopup';
export { showAlert, showChordDetectionOptions, showConfirm, showPrompt, showTempoApply, showTempoDetectionOptions, showTimeSigPrompt } from '../../util/dialogUtil'; export {
export type { ChordDetectionOptionsResult, ConfirmOptions, PromptOptions, TempoApplyResult, TempoDetectionOptionsResult, TimeSigResult } from '../../util/dialogUtil'; 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 PianoRollToolbar from './PianoRollToolbar';
import NoteAttributeBar from './NoteAttributeBar'; import NoteAttributeBar from './NoteAttributeBar';
import PianoRollContent from './PianoRollContent'; import PianoRollContent from './PianoRollContent';
import { LoadingOverlay } from '../common';
import { KGCore } from '../../core/KGCore'; import { KGCore } from '../../core/KGCore';
import { KGMidiNote } from '../../core/midi/KGMidiNote'; import { KGMidiNote } from '../../core/midi/KGMidiNote';
import { KGMidiTrack, type InstrumentType } from '../../core/track/KGMidiTrack'; import { KGMidiTrack, type InstrumentType } from '../../core/track/KGMidiTrack';
@@ -21,11 +22,18 @@ import {
type PianoRollSnapValue, type PianoRollSnapValue,
} from '../../core/state/KGPianoRollState'; } from '../../core/state/KGPianoRollState';
import { ConfigManager } from '../../core/config/ConfigManager'; import { ConfigManager } from '../../core/config/ConfigManager';
import { beatsToBar } from '../../util/midiUtil'; import { beatsToBar, type RawMidiNote } from '../../util/midiUtil';
import { ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../core/commands'; import { ImportMidiClipCommand, ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../core/commands';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage'; 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 { matchesKeyboardShortcut } from '../../util/osUtil';
import { resolveChordGuideItems } from '../../util/chordGuideDataUtil'; import { resolveChordGuideItems } from '../../util/chordGuideDataUtil';
import { import {
@@ -59,6 +67,7 @@ import {
type MidiChordDetectionOptions, type MidiChordDetectionOptions,
} from '../../util/midiChordDetection'; } from '../../util/midiChordDetection';
import type { AudioChordDetectionWorkerMessage } from '../../workers/audioChordDetectionWorker'; import type { AudioChordDetectionWorkerMessage } from '../../workers/audioChordDetectionWorker';
import type { AudioToMidiWorkerResult } from '../../workers/audioToMidiWorker';
import type { PianoRollAutomationType } from './pianoRollAutomation'; import type { PianoRollAutomationType } from './pianoRollAutomation';
import type { SheetMeasureMetric } from './sheetNotationTypes'; import type { SheetMeasureMetric } from './sheetNotationTypes';
import { getSheetPlayheadPixel, getSheetQuantizationOptions, parseSheetQuantization } from './sheetNotation'; import { getSheetPlayheadPixel, getSheetQuantizationOptions, parseSheetQuantization } from './sheetNotation';
@@ -70,6 +79,11 @@ import {
} from './pianoRollViewport'; } from './pianoRollViewport';
import { getNextChordGuideSelection, resolveChordGuideContext, type ChordGuideFunction } from './chordGuideUtil'; import { getNextChordGuideSelection, resolveChordGuideContext, type ChordGuideFunction } from './chordGuideUtil';
import { useI18n } from '../../i18n/useI18n'; import { useI18n } from '../../i18n/useI18n';
import {
buildAudioToMidiAnalysisSpan,
convertDetectedAudioNotesToRawMidiNotes,
} from '../../util/audioToMidi';
import { TrackType } from '../../core/track/KGTrack';
interface PianoRollProps { interface PianoRollProps {
onClose: () => void; onClose: () => void;
@@ -84,6 +98,13 @@ interface PianoRollProps {
projectName?: string; 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> = ({ const PianoRoll: React.FC<PianoRollProps> = ({
onClose, onClose,
regionId, regionId,
@@ -101,7 +122,32 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const isAudioWaveform = currentMode === 'audio-waveform'; const isAudioWaveform = currentMode === 'audio-waveform';
const isAudioOnly = isAudioWaveform || isSpectrogram; const isAudioOnly = isAudioWaveform || isSpectrogram;
const isHybrid = currentMode === 'hybrid'; 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(); const { t } = useI18n();
// Tool state for piano roll // Tool state for piano roll
@@ -115,6 +161,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const [isDetectingChords, setIsDetectingChords] = useState(false); const [isDetectingChords, setIsDetectingChords] = useState(false);
const [detectChordProgressPercent, setDetectChordProgressPercent] = useState(0); const [detectChordProgressPercent, setDetectChordProgressPercent] = useState(0);
const [isDetectingTempo, setIsDetectingTempo] = useState(false); const [isDetectingTempo, setIsDetectingTempo] = useState(false);
const [isConvertingToMidi, setIsConvertingToMidi] = useState(false);
// Piano roll zoom (1x8x); updates --region-grid-beat-width CSS variable // Piano roll zoom (1x8x); updates --region-grid-beat-width CSS variable
const [pianoRollZoom, setPianoRollZoom] = useState<number>(() => KGPianoRollState.instance().getPianoRollZoom()); 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; return tracks.find(track => track.getId().toString() === activeRegion.getTrackId()) ?? null;
}, [activeRegion, tracks]); }, [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>(() => ( const activeInstrument = useMemo<InstrumentType>(() => (
parentMidiTrack instanceof KGMidiTrack ? parentMidiTrack.getInstrument() : 'acoustic_grand_piano' parentMidiTrack instanceof KGMidiTrack ? parentMidiTrack.getInstrument() : 'acoustic_grand_piano'
), [parentMidiTrack]); ), [parentMidiTrack]);
@@ -514,6 +569,165 @@ const PianoRoll: React.FC<PianoRollProps> = ({
} }
}, [activeEditableRegionId, selectedRegionIds, tracks, updateRegionProperties]); }, [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 () => { const handleDetectChords = useCallback(async () => {
if (!audioRegion && !activeRegion) { if (!audioRegion && !activeRegion) {
await showAlert('Open a MIDI or audio region before detecting chords.'); await showAlert('Open a MIDI or audio region before detecting chords.');
@@ -1619,6 +1833,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
detectingChords={isDetectingChords} detectingChords={isDetectingChords}
onDetectTempo={audioRegion ? handleDetectTempo : undefined} onDetectTempo={audioRegion ? handleDetectTempo : undefined}
detectingTempo={isDetectingTempo} detectingTempo={isDetectingTempo}
onConvertToMidi={audioRegion ? handleConvertToMidi : undefined}
convertToMidiDisabled={availableMidiTracks.length === 0}
selectedRegionColor={selectedRegionColor} selectedRegionColor={selectedRegionColor}
onRegionColorSelect={activeEditableRegionId ? (color) => { void handleRegionColorSelect(color); } : undefined} onRegionColorSelect={activeEditableRegionId ? (color) => { void handleRegionColorSelect(color); } : undefined}
/> />
@@ -1669,6 +1885,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
> >
<FaGripLines /> <FaGripLines />
</div> </div>
<LoadingOverlay visible={isConvertingToMidi} message={t('kgone.shared.btn.processing')} />
</div> </div>
); );
}; };
@@ -325,6 +325,46 @@ describe('PianoRollToolbar', () => {
expect(screen.queryByText('Detect tempo...')).not.toBeInTheDocument(); 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', () => { it('shows only the sheet controls when sheet mode is enabled', () => {
renderWithLocale( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
+21 -1
View File
@@ -50,6 +50,8 @@ interface PianoRollToolbarProps {
detectingChords?: boolean; detectingChords?: boolean;
onDetectTempo?: () => void | Promise<void>; onDetectTempo?: () => void | Promise<void>;
detectingTempo?: boolean; detectingTempo?: boolean;
onConvertToMidi?: () => void | Promise<void>;
convertToMidiDisabled?: boolean;
selectedRegionColor?: string; selectedRegionColor?: string;
onRegionColorSelect?: (color: string | null) => void; onRegionColorSelect?: (color: string | null) => void;
} }
@@ -94,6 +96,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
detectingChords = false, detectingChords = false,
onDetectTempo, onDetectTempo,
detectingTempo = false, detectingTempo = false,
onConvertToMidi,
convertToMidiDisabled = false,
selectedRegionColor, selectedRegionColor,
onRegionColorSelect, onRegionColorSelect,
}) => { }) => {
@@ -102,7 +106,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
const showAudioOnlyControls = mode === 'audio-waveform' && !sheetMusicViewEnabled; const showAudioOnlyControls = mode === 'audio-waveform' && !sheetMusicViewEnabled;
const showSpectrogramOnlyControls = mode === 'spectrogram' && !sheetMusicViewEnabled; const showSpectrogramOnlyControls = mode === 'spectrogram' && !sheetMusicViewEnabled;
const showSpecControls = !sheetMusicViewEnabled && (mode === 'spectrogram' || mode === 'hybrid'); 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 automationOptions = React.useMemo(() => getTranslatedAutomationOptions(t), [t]);
const snapOptions = React.useMemo( const snapOptions = React.useMemo(
() => KGPianoRollState.SNAP_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value })), () => 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')} {detectingTempo ? t('pianoRoll.detectingTempo') : t('pianoRoll.detectTempo')}
</div> </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>
)} )}
</div> </div>
+14
View File
@@ -193,6 +193,7 @@ export const enUsMessages: TranslationMessages = {
'dialog.title.tempoDetection': 'Tempo Detection', 'dialog.title.tempoDetection': 'Tempo Detection',
'dialog.title.applyTempo': 'Apply Tempo', 'dialog.title.applyTempo': 'Apply Tempo',
'dialog.title.chordDetection': 'Chord Detection', 'dialog.title.chordDetection': 'Chord Detection',
'dialog.title.audioToMidi': 'Convert to MIDI',
'dialog.title.input': 'Input', 'dialog.title.input': 'Input',
'dialog.title.confirm': 'Confirm', 'dialog.title.confirm': 'Confirm',
'dialog.close': 'Close dialog', 'dialog.close': 'Close dialog',
@@ -203,6 +204,7 @@ export const enUsMessages: TranslationMessages = {
'dialog.chordHint.audio': 'Chord analysis is still experimental. Harmonic content, arrangement density, and transient-heavy material can affect accuracy. For more reliable results, start with the default settings, then refine sensitivity and stability until the detected harmony best matches the musical phrasing.', 'dialog.chordHint.audio': 'Chord analysis is still experimental. Harmonic content, arrangement density, and transient-heavy material can affect accuracy. For more reliable results, start with the default settings, then refine sensitivity and stability until the detected harmony best matches the musical phrasing.',
'dialog.chordHint.midi': 'Chord analysis is still experimental. Voicing density, overlaps, and ornamental notes can influence the result. For more reliable chord labels, begin with the default settings, then adjust note suppression and harmonic focus to match the musical role of the passage.', 'dialog.chordHint.midi': 'Chord analysis is still experimental. Voicing density, overlaps, and ornamental notes can influence the result. For more reliable chord labels, begin with the default settings, then adjust note suppression and harmonic focus to match the musical role of the passage.',
'dialog.chordHint.tempo': 'Tempo analysis is still experimental. Rubato phrasing, sparse transients, and layered percussion can reduce accuracy. Start with the default BPM range, then narrow the analysis window to the most plausible tempo span for the material if the first pass is not musically convincing.', 'dialog.chordHint.tempo': 'Tempo analysis is still experimental. Rubato phrasing, sparse transients, and layered percussion can reduce accuracy. Start with the default BPM range, then narrow the analysis window to the most plausible tempo span for the material if the first pass is not musically convincing.',
'dialog.audioToMidiHint': 'Audio-to-MIDI conversion is still experimental. For best results, switch to spectrogram view, adjust Floor until only the true note ridges remain visible and most ghost pitches disappear, then enable Use current Floor dB before running the conversion.',
'dialog.sourceHint.kgone': 'For the most dependable chord labels, analyze a stem with vocals and percussion reduced or removed. If K.G.One Music Studio server integration is available, run Separator with the "Vocal, Drums, Bass, Guitar, Piano, and Others" model and use the Piano or Others stem for analysis.', 'dialog.sourceHint.kgone': 'For the most dependable chord labels, analyze a stem with vocals and percussion reduced or removed. If K.G.One Music Studio server integration is available, run Separator with the "Vocal, Drums, Bass, Guitar, Piano, and Others" model and use the Piano or Others stem for analysis.',
'dialog.sourceHint.local': 'For the most dependable chord labels, analyze a stem with vocals and percussion reduced or removed. If you are using the local separator, choose the "Vocal, Drums, Bass, and Others" model and use the Others stem for analysis.', 'dialog.sourceHint.local': 'For the most dependable chord labels, analyze a stem with vocals and percussion reduced or removed. If you are using the local separator, choose the "Vocal, Drums, Bass, and Others" model and use the Others stem for analysis.',
'dialog.label.sensitivity': 'Sensitivity', 'dialog.label.sensitivity': 'Sensitivity',
@@ -219,7 +221,18 @@ export const enUsMessages: TranslationMessages = {
'dialog.label.minimumBpm': 'Minimum BPM', 'dialog.label.minimumBpm': 'Minimum BPM',
'dialog.label.maximumBpm': 'Maximum BPM', 'dialog.label.maximumBpm': 'Maximum BPM',
'dialog.label.autoAlignRegionToBeat': 'Auto-align region to beat', 'dialog.label.autoAlignRegionToBeat': 'Auto-align region to beat',
'dialog.label.monophonicOnly': 'Monophonic only (v1)',
'dialog.label.useCurrentFloorDb': 'Use current Floor dB',
'dialog.label.manualFloorDb': 'Manual Floor dB',
'dialog.label.pitchRange': 'Pitch range',
'dialog.label.quantizeNoteStart': 'Quantize note start',
'dialog.label.quantizeNoteLength': 'Quantize note length',
'dialog.label.convertLoopRangeOnly': 'Convert loop-range audio clip only',
'dialog.label.groupAdjacentPitchesToHighest': 'Group adjacent pitches to the strongest bin',
'dialog.label.targetTrack': 'Target MIDI track',
'dialog.to': 'to',
'dialog.message.chordDetection': 'Tune audio chord detection settings before processing.', 'dialog.message.chordDetection': 'Tune audio chord detection settings before processing.',
'dialog.message.audioToMidi': 'Tune audio-to-MIDI conversion settings before processing.',
'dialog.message.tempoDetection': 'Tune audio tempo detection settings before processing.', 'dialog.message.tempoDetection': 'Tune audio tempo detection settings before processing.',
'dialog.message.tempoApply': 'Detected tempo: {bpm} BPM. Choose how to apply it.\n\nUpdate Current Tempo changes the active tempo at this clip location. Insert Tempo Change adds a new tempo region at the nearest bar before the clip starts.', 'dialog.message.tempoApply': 'Detected tempo: {bpm} BPM. Choose how to apply it.\n\nUpdate Current Tempo changes the active tempo at this clip location. Insert Tempo Change adds a new tempo region at the nearest bar before the clip starts.',
'dialog.action.updateCurrentTempo': 'Update Current Tempo', 'dialog.action.updateCurrentTempo': 'Update Current Tempo',
@@ -354,6 +367,7 @@ export const enUsMessages: TranslationMessages = {
'pianoRoll.detectingChords': 'Detecting chords...', 'pianoRoll.detectingChords': 'Detecting chords...',
'pianoRoll.detectTempo': 'Detect tempo...', 'pianoRoll.detectTempo': 'Detect tempo...',
'pianoRoll.detectingTempo': 'Detecting tempo...', 'pianoRoll.detectingTempo': 'Detecting tempo...',
'pianoRoll.convertToMidi': 'Convert to MIDI...',
'percussion.short.35': 'Ac.Bass', 'percussion.short.35': 'Ac.Bass',
'percussion.short.36': 'BassDrum', 'percussion.short.36': 'BassDrum',
'percussion.short.37': 'SideStick', 'percussion.short.37': 'SideStick',
+14
View File
@@ -190,6 +190,7 @@ export const frFrMessages: TranslationMessages = {
'dialog.title.tempoDetection': 'Détection du tempo', 'dialog.title.tempoDetection': 'Détection du tempo',
'dialog.title.applyTempo': 'Appliquer le tempo', 'dialog.title.applyTempo': 'Appliquer le tempo',
'dialog.title.chordDetection': 'Détection d\'accords', 'dialog.title.chordDetection': 'Détection d\'accords',
'dialog.title.audioToMidi': 'Convertir en MIDI',
'dialog.title.input': 'Saisie', 'dialog.title.input': 'Saisie',
'dialog.title.confirm': 'Confirmation', 'dialog.title.confirm': 'Confirmation',
'dialog.close': 'Fermer la boîte de dialogue', 'dialog.close': 'Fermer la boîte de dialogue',
@@ -200,6 +201,7 @@ export const frFrMessages: TranslationMessages = {
'dialog.chordHint.audio': 'L\'analyse d\'accords reste expérimentale. Le contenu harmonique, la densité de l\'arrangement et les matériaux riches en transitoires peuvent affecter la précision. Pour de meilleurs résultats, commencez avec les réglages par défaut puis affinez la sensibilité et la stabilité jusqu\'à ce que l\'harmonie détectée corresponde à la phrase musicale.', 'dialog.chordHint.audio': 'L\'analyse d\'accords reste expérimentale. Le contenu harmonique, la densité de l\'arrangement et les matériaux riches en transitoires peuvent affecter la précision. Pour de meilleurs résultats, commencez avec les réglages par défaut puis affinez la sensibilité et la stabilité jusqu\'à ce que l\'harmonie détectée corresponde à la phrase musicale.',
'dialog.chordHint.midi': 'L\'analyse d\'accords reste expérimentale. La densité des voicings, les chevauchements et les notes d\'ornement peuvent influencer le résultat. Pour des étiquettes d\'accord plus fiables, partez des réglages par défaut puis ajustez la suppression des notes courtes et le focus harmonique selon le rôle musical du passage.', 'dialog.chordHint.midi': 'L\'analyse d\'accords reste expérimentale. La densité des voicings, les chevauchements et les notes d\'ornement peuvent influencer le résultat. Pour des étiquettes d\'accord plus fiables, partez des réglages par défaut puis ajustez la suppression des notes courtes et le focus harmonique selon le rôle musical du passage.',
'dialog.chordHint.tempo': 'L\'analyse du tempo reste expérimentale. Le rubato, les transitoires clairsemées et les couches de percussions peuvent réduire la précision. Commencez avec la plage de BPM par défaut, puis resserrez la fenêtre d\'analyse vers la zone de tempo la plus plausible si le premier résultat n\'est pas convaincant musicalement.', 'dialog.chordHint.tempo': 'L\'analyse du tempo reste expérimentale. Le rubato, les transitoires clairsemées et les couches de percussions peuvent réduire la précision. Commencez avec la plage de BPM par défaut, puis resserrez la fenêtre d\'analyse vers la zone de tempo la plus plausible si le premier résultat n\'est pas convaincant musicalement.',
'dialog.audioToMidiHint': 'La conversion audio-vers-MIDI reste expérimentale. Pour de meilleurs résultats, passez en vue spectrogramme, ajustez Floor jusqu\'à ne laisser visibles que les vraies notes et à faire disparaître autant que possible les hauteurs fantômes, puis activez Utiliser le Floor dB actuel avant la conversion.',
'dialog.sourceHint.kgone': 'Pour des étiquettes d\'accord plus fiables, analysez de préférence un stem où les voix et les percussions ont été atténuées ou retirées. Si l\'intégration du serveur K.G.One Music Studio est disponible, lancez Separator avec le modèle "Vocal, Drums, Bass, Guitar, Piano, and Others" et utilisez de préférence le stem Piano ou Others.', 'dialog.sourceHint.kgone': 'Pour des étiquettes d\'accord plus fiables, analysez de préférence un stem où les voix et les percussions ont été atténuées ou retirées. Si l\'intégration du serveur K.G.One Music Studio est disponible, lancez Separator avec le modèle "Vocal, Drums, Bass, Guitar, Piano, and Others" et utilisez de préférence le stem Piano ou Others.',
'dialog.sourceHint.local': 'Pour des étiquettes d\'accord plus fiables, analysez de préférence un stem où les voix et les percussions ont été atténuées ou retirées. Si vous utilisez le séparateur local, choisissez le modèle "Vocal, Drums, Bass, and Others" et analysez le stem Others.', 'dialog.sourceHint.local': 'Pour des étiquettes d\'accord plus fiables, analysez de préférence un stem où les voix et les percussions ont été atténuées ou retirées. Si vous utilisez le séparateur local, choisissez le modèle "Vocal, Drums, Bass, and Others" et analysez le stem Others.',
'dialog.label.sensitivity': 'Sensibilité', 'dialog.label.sensitivity': 'Sensibilité',
@@ -216,7 +218,18 @@ export const frFrMessages: TranslationMessages = {
'dialog.label.minimumBpm': 'BPM minimum', 'dialog.label.minimumBpm': 'BPM minimum',
'dialog.label.maximumBpm': 'BPM maximum', 'dialog.label.maximumBpm': 'BPM maximum',
'dialog.label.autoAlignRegionToBeat': 'Aligner automatiquement la région sur le temps', 'dialog.label.autoAlignRegionToBeat': 'Aligner automatiquement la région sur le temps',
'dialog.label.monophonicOnly': 'Monophonique uniquement (v1)',
'dialog.label.useCurrentFloorDb': 'Utiliser le Floor dB actuel',
'dialog.label.manualFloorDb': 'Floor dB manuel',
'dialog.label.pitchRange': 'Plage de hauteurs',
'dialog.label.quantizeNoteStart': 'Quantifier le début des notes',
'dialog.label.quantizeNoteLength': 'Quantifier la durée des notes',
'dialog.label.convertLoopRangeOnly': 'Convertir uniquement l\'audio dans la plage de boucle',
'dialog.label.groupAdjacentPitchesToHighest': 'Regrouper les hauteurs adjacentes vers le bin le plus fort',
'dialog.label.targetTrack': 'Piste MIDI cible',
'dialog.to': 'à',
'dialog.message.chordDetection': 'Ajustez les réglages de détection d\'accords audio avant le traitement.', 'dialog.message.chordDetection': 'Ajustez les réglages de détection d\'accords audio avant le traitement.',
'dialog.message.audioToMidi': 'Ajustez les réglages de conversion audio-vers-MIDI avant le traitement.',
'dialog.message.tempoDetection': 'Ajustez les réglages de détection du tempo audio avant le traitement.', 'dialog.message.tempoDetection': 'Ajustez les réglages de détection du tempo audio avant le traitement.',
'dialog.message.tempoApply': 'Tempo détecté : {bpm} BPM. Choisissez comment l\'appliquer.\n\nMettre à jour le tempo courant modifie le tempo actif à l\'emplacement de ce clip. Insérer un changement de tempo ajoute une nouvelle région de tempo à la mesure la plus proche avant le début du clip.', 'dialog.message.tempoApply': 'Tempo détecté : {bpm} BPM. Choisissez comment l\'appliquer.\n\nMettre à jour le tempo courant modifie le tempo actif à l\'emplacement de ce clip. Insérer un changement de tempo ajoute une nouvelle région de tempo à la mesure la plus proche avant le début du clip.',
'dialog.action.updateCurrentTempo': 'Mettre à jour le tempo courant', 'dialog.action.updateCurrentTempo': 'Mettre à jour le tempo courant',
@@ -332,6 +345,7 @@ export const frFrMessages: TranslationMessages = {
'pianoRoll.detectingChords': 'Détection des accords...', 'pianoRoll.detectingChords': 'Détection des accords...',
'pianoRoll.detectTempo': 'Détecter le tempo...', 'pianoRoll.detectTempo': 'Détecter le tempo...',
'pianoRoll.detectingTempo': 'Détection du tempo...', 'pianoRoll.detectingTempo': 'Détection du tempo...',
'pianoRoll.convertToMidi': 'Convertir en MIDI...',
'toolbar.export.kgstudio': 'Exporter au format KGStudio', 'toolbar.export.kgstudio': 'Exporter au format KGStudio',
'toolbar.export.midi': 'Exporter au format MIDI', 'toolbar.export.midi': 'Exporter au format MIDI',
'toolbar.export.wav': 'Exporter en WAV', 'toolbar.export.wav': 'Exporter en WAV',
+14
View File
@@ -191,6 +191,7 @@ export const zhCnMessages: TranslationMessages = {
'dialog.title.tempoDetection': '速度检测', 'dialog.title.tempoDetection': '速度检测',
'dialog.title.applyTempo': '应用速度', 'dialog.title.applyTempo': '应用速度',
'dialog.title.chordDetection': '和弦检测', 'dialog.title.chordDetection': '和弦检测',
'dialog.title.audioToMidi': '转换为 MIDI',
'dialog.title.input': '输入', 'dialog.title.input': '输入',
'dialog.title.confirm': '确认', 'dialog.title.confirm': '确认',
'dialog.close': '关闭对话框', 'dialog.close': '关闭对话框',
@@ -201,6 +202,7 @@ export const zhCnMessages: TranslationMessages = {
'dialog.chordHint.audio': '和弦分析仍属实验性功能。和声内容、编配密度以及瞬态较强的素材都可能影响准确率。建议先使用默认设置,再逐步微调灵敏度和稳定性,让检测结果更贴近实际乐句。', 'dialog.chordHint.audio': '和弦分析仍属实验性功能。和声内容、编配密度以及瞬态较强的素材都可能影响准确率。建议先使用默认设置,再逐步微调灵敏度和稳定性,让检测结果更贴近实际乐句。',
'dialog.chordHint.midi': '和弦分析仍属实验性功能。和弦堆叠密度、重叠以及装饰音都会影响结果。建议先使用默认设置,再调整短音抑制和和声关注方式,以匹配该段音乐的和声作用。', 'dialog.chordHint.midi': '和弦分析仍属实验性功能。和弦堆叠密度、重叠以及装饰音都会影响结果。建议先使用默认设置,再调整短音抑制和和声关注方式,以匹配该段音乐的和声作用。',
'dialog.chordHint.tempo': '速度分析仍属实验性功能。自由速度、瞬态稀疏以及多层打击乐都可能降低准确率。建议先使用默认 BPM 范围,如果第一次结果不理想,再把分析范围收窄到更可能的速度区间。', 'dialog.chordHint.tempo': '速度分析仍属实验性功能。自由速度、瞬态稀疏以及多层打击乐都可能降低准确率。建议先使用默认 BPM 范围,如果第一次结果不理想,再把分析范围收窄到更可能的速度区间。',
'dialog.audioToMidiHint': 'Audio-to-MIDI 转换仍属实验性功能。建议先切换到频谱视图,调整 Floor,直到只剩下真正的音符轮廓而大部分鬼影音高消失,再启用使用当前 Floor dB 后执行转换。',
'dialog.sourceHint.kgone': '为了获得更可靠的和弦标签,建议分析已经削弱或移除了人声和打击乐的 stem。如果已启用 K.G.One Music Studio 服务器集成,请使用带有 “Vocal, Drums, Bass, Guitar, Piano, and Others” 的 Separator 模型,并优先分析 Piano 或 Others stem。', 'dialog.sourceHint.kgone': '为了获得更可靠的和弦标签,建议分析已经削弱或移除了人声和打击乐的 stem。如果已启用 K.G.One Music Studio 服务器集成,请使用带有 “Vocal, Drums, Bass, Guitar, Piano, and Others” 的 Separator 模型,并优先分析 Piano 或 Others stem。',
'dialog.sourceHint.local': '为了获得更可靠的和弦标签,建议分析已经削弱或移除了人声和打击乐的 stem。如果您使用本地分轨器,请选择 “Vocal, Drums, Bass, and Others” 模型,并使用 Others stem 进行分析。', 'dialog.sourceHint.local': '为了获得更可靠的和弦标签,建议分析已经削弱或移除了人声和打击乐的 stem。如果您使用本地分轨器,请选择 “Vocal, Drums, Bass, and Others” 模型,并使用 Others stem 进行分析。',
'dialog.label.sensitivity': '灵敏度', 'dialog.label.sensitivity': '灵敏度',
@@ -217,7 +219,18 @@ export const zhCnMessages: TranslationMessages = {
'dialog.label.minimumBpm': '最小 BPM', 'dialog.label.minimumBpm': '最小 BPM',
'dialog.label.maximumBpm': '最大 BPM', 'dialog.label.maximumBpm': '最大 BPM',
'dialog.label.autoAlignRegionToBeat': '自动将片段对齐到拍点', 'dialog.label.autoAlignRegionToBeat': '自动将片段对齐到拍点',
'dialog.label.monophonicOnly': '仅限单音 (v1)',
'dialog.label.useCurrentFloorDb': '使用当前 Floor dB',
'dialog.label.manualFloorDb': '手动 Floor dB',
'dialog.label.pitchRange': '音高范围',
'dialog.label.quantizeNoteStart': '量化音符起点',
'dialog.label.quantizeNoteLength': '量化音符长度',
'dialog.label.convertLoopRangeOnly': '仅转换 loop 范围内的音频片段',
'dialog.label.groupAdjacentPitchesToHighest': '将相邻音高归并到最强的音高格',
'dialog.label.targetTrack': '目标 MIDI 轨',
'dialog.to': '到',
'dialog.message.chordDetection': '在处理前调整音频和弦检测设置。', 'dialog.message.chordDetection': '在处理前调整音频和弦检测设置。',
'dialog.message.audioToMidi': '在处理前调整 Audio-to-MIDI 转换设置。',
'dialog.message.tempoDetection': '在处理前调整音频速度检测设置。', 'dialog.message.tempoDetection': '在处理前调整音频速度检测设置。',
'dialog.message.tempoApply': '检测到速度:{bpm} BPM。请选择应用方式。\n\n"更新当前速度"将修改此片段位置的有效速度。"插入速度变更"将在片段起始点前最近的小节处添加新的速度区域。', 'dialog.message.tempoApply': '检测到速度:{bpm} BPM。请选择应用方式。\n\n"更新当前速度"将修改此片段位置的有效速度。"插入速度变更"将在片段起始点前最近的小节处添加新的速度区域。',
'dialog.action.updateCurrentTempo': '更新当前速度', 'dialog.action.updateCurrentTempo': '更新当前速度',
@@ -352,6 +365,7 @@ export const zhCnMessages: TranslationMessages = {
'pianoRoll.detectingChords': '正在检测和弦...', 'pianoRoll.detectingChords': '正在检测和弦...',
'pianoRoll.detectTempo': '检测速度...', 'pianoRoll.detectTempo': '检测速度...',
'pianoRoll.detectingTempo': '正在检测速度...', 'pianoRoll.detectingTempo': '正在检测速度...',
'pianoRoll.convertToMidi': '转换为 MIDI...',
'percussion.short.35': '原底鼓', 'percussion.short.35': '原底鼓',
'percussion.short.36': '底鼓', 'percussion.short.36': '底鼓',
'percussion.short.37': '边击', 'percussion.short.37': '边击',
+14
View File
@@ -191,6 +191,7 @@ export const zhHkMessages: TranslationMessages = {
'dialog.title.tempoDetection': '速度檢測', 'dialog.title.tempoDetection': '速度檢測',
'dialog.title.applyTempo': '應用速度', 'dialog.title.applyTempo': '應用速度',
'dialog.title.chordDetection': '和弦檢測', 'dialog.title.chordDetection': '和弦檢測',
'dialog.title.audioToMidi': '轉換為 MIDI',
'dialog.title.input': '輸入', 'dialog.title.input': '輸入',
'dialog.title.confirm': '確認', 'dialog.title.confirm': '確認',
'dialog.close': '關閉對話框', 'dialog.close': '關閉對話框',
@@ -201,6 +202,7 @@ export const zhHkMessages: TranslationMessages = {
'dialog.chordHint.audio': '和弦分析仍屬實驗性功能。和聲內容、編配密度以及瞬態較強的素材都可能影響準確率。建議先使用預設設定,再逐步微調靈敏度和穩定性,讓檢測結果更貼近實際樂句。', 'dialog.chordHint.audio': '和弦分析仍屬實驗性功能。和聲內容、編配密度以及瞬態較強的素材都可能影響準確率。建議先使用預設設定,再逐步微調靈敏度和穩定性,讓檢測結果更貼近實際樂句。',
'dialog.chordHint.midi': '和弦分析仍屬實驗性功能。和弦堆疊密度、重疊以及裝飾音都會影響結果。建議先使用預設設定,再調整短音抑制和和聲關注方式,以配合該段音樂的和聲作用。', 'dialog.chordHint.midi': '和弦分析仍屬實驗性功能。和弦堆疊密度、重疊以及裝飾音都會影響結果。建議先使用預設設定,再調整短音抑制和和聲關注方式,以配合該段音樂的和聲作用。',
'dialog.chordHint.tempo': '速度分析仍屬實驗性功能。自由速度、瞬態稀疏以及多層打擊樂都可能降低準確率。建議先使用預設 BPM 範圍,如果第一次結果不理想,再把分析範圍收窄到更可能的速度區間。', 'dialog.chordHint.tempo': '速度分析仍屬實驗性功能。自由速度、瞬態稀疏以及多層打擊樂都可能降低準確率。建議先使用預設 BPM 範圍,如果第一次結果不理想,再把分析範圍收窄到更可能的速度區間。',
'dialog.audioToMidiHint': 'Audio-to-MIDI 轉換仍屬實驗性功能。建議先切換到頻譜視圖,調整 Floor,直到只剩下真正的音符輪廓而大部分鬼影音高消失,再啟用使用目前 Floor dB 後執行轉換。',
'dialog.sourceHint.kgone': '為了獲得更可靠的和弦標籤,建議分析已經削弱或移除了人聲和打擊樂的 stem。如果已啟用 K.G.One Music Studio 伺服器整合,請使用帶有 "Vocal, Drums, Bass, Guitar, Piano, and Others" 的 Separator 模型,並優先分析 Piano 或 Others stem。', 'dialog.sourceHint.kgone': '為了獲得更可靠的和弦標籤,建議分析已經削弱或移除了人聲和打擊樂的 stem。如果已啟用 K.G.One Music Studio 伺服器整合,請使用帶有 "Vocal, Drums, Bass, Guitar, Piano, and Others" 的 Separator 模型,並優先分析 Piano 或 Others stem。',
'dialog.sourceHint.local': '為了獲得更可靠的和弦標籤,建議分析已經削弱或移除了人聲和打擊樂的 stem。如果您使用本地分軌器,請選擇 "Vocal, Drums, Bass, and Others" 模型,並使用 Others stem 進行分析。', 'dialog.sourceHint.local': '為了獲得更可靠的和弦標籤,建議分析已經削弱或移除了人聲和打擊樂的 stem。如果您使用本地分軌器,請選擇 "Vocal, Drums, Bass, and Others" 模型,並使用 Others stem 進行分析。',
'dialog.label.sensitivity': '靈敏度', 'dialog.label.sensitivity': '靈敏度',
@@ -217,7 +219,18 @@ export const zhHkMessages: TranslationMessages = {
'dialog.label.minimumBpm': '最小 BPM', 'dialog.label.minimumBpm': '最小 BPM',
'dialog.label.maximumBpm': '最大 BPM', 'dialog.label.maximumBpm': '最大 BPM',
'dialog.label.autoAlignRegionToBeat': '自動將片段對齊到拍點', 'dialog.label.autoAlignRegionToBeat': '自動將片段對齊到拍點',
'dialog.label.monophonicOnly': '僅限單音 (v1)',
'dialog.label.useCurrentFloorDb': '使用目前 Floor dB',
'dialog.label.manualFloorDb': '手動 Floor dB',
'dialog.label.pitchRange': '音高範圍',
'dialog.label.quantizeNoteStart': '量化音符起點',
'dialog.label.quantizeNoteLength': '量化音符長度',
'dialog.label.convertLoopRangeOnly': '僅轉換 loop 範圍內的音訊片段',
'dialog.label.groupAdjacentPitchesToHighest': '將相鄰音高群組到最強的音高格',
'dialog.label.targetTrack': '目標 MIDI 軌',
'dialog.to': '至',
'dialog.message.chordDetection': '在處理前調整音訊和弦檢測設定。', 'dialog.message.chordDetection': '在處理前調整音訊和弦檢測設定。',
'dialog.message.audioToMidi': '在處理前調整 Audio-to-MIDI 轉換設定。',
'dialog.message.tempoDetection': '在處理前調整音訊速度檢測設定。', 'dialog.message.tempoDetection': '在處理前調整音訊速度檢測設定。',
'dialog.message.tempoApply': '檢測到速度:{bpm} BPM。請選擇應用方式。\n\n"更新當前速度"會修改此片段位置的有效速度。"插入速度變更"會在片段起始點前最近的小節加入新的速度區域。', 'dialog.message.tempoApply': '檢測到速度:{bpm} BPM。請選擇應用方式。\n\n"更新當前速度"會修改此片段位置的有效速度。"插入速度變更"會在片段起始點前最近的小節加入新的速度區域。',
'dialog.action.updateCurrentTempo': '更新當前速度', 'dialog.action.updateCurrentTempo': '更新當前速度',
@@ -352,6 +365,7 @@ export const zhHkMessages: TranslationMessages = {
'pianoRoll.detectingChords': '正在檢測和弦...', 'pianoRoll.detectingChords': '正在檢測和弦...',
'pianoRoll.detectTempo': '檢測速度...', 'pianoRoll.detectTempo': '檢測速度...',
'pianoRoll.detectingTempo': '正在檢測速度...', 'pianoRoll.detectingTempo': '正在檢測速度...',
'pianoRoll.convertToMidi': '轉換為 MIDI...',
'percussion.short.35': '原底鼓', 'percussion.short.35': '原底鼓',
'percussion.short.36': '底鼓', 'percussion.short.36': '底鼓',
'percussion.short.37': '邊擊', 'percussion.short.37': '邊擊',
@@ -0,0 +1,67 @@
import { execFileSync, spawnSync } from 'node:child_process';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { pitchToNoteNameString } from '../../util/midiUtil';
import { detectMonophonicNotesFromAudio } from '../../util/audioToMidi';
const FIXTURE_PATH = path.resolve(process.cwd(), 'public/test-data/audio-to-midi-test01.mp3');
const ffmpegAvailable = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0;
const runIfFfmpeg = ffmpegAvailable ? it : it.skip;
function decodeMp3ToMonoPcm(pathname: string): { sampleRate: number; pcm: Float32Array } {
const wav = execFileSync(
'ffmpeg',
['-v', 'error', '-i', pathname, '-ac', '1', '-f', 'wav', 'pipe:1'],
{ maxBuffer: 64 * 1024 * 1024 },
);
const readString = (offset: number, length: number) => wav.toString('ascii', offset, offset + length);
if (readString(0, 4) !== 'RIFF' || readString(8, 4) !== 'WAVE') {
throw new Error('ffmpeg did not return a RIFF/WAVE stream');
}
let offset = 12;
let sampleRate = 44100;
let pcmData = Buffer.alloc(0);
while (offset + 8 <= wav.length) {
const chunkId = readString(offset, 4);
const chunkSize = wav.readUInt32LE(offset + 4);
const chunkStart = offset + 8;
if (chunkId === 'fmt ') {
sampleRate = wav.readUInt32LE(chunkStart + 4);
} else if (chunkId === 'data') {
pcmData = wav.subarray(chunkStart, chunkStart + chunkSize);
break;
}
offset = chunkStart + chunkSize + (chunkSize % 2);
}
const pcm = new Float32Array(pcmData.length / 2);
for (let sampleIndex = 0; sampleIndex < pcm.length; sampleIndex++) {
pcm[sampleIndex] = pcmData.readInt16LE(sampleIndex * 2) / 32768;
}
return { sampleRate, pcm };
}
describe('audio-to-MIDI fixture regression', () => {
runIfFfmpeg('extracts exactly A1 then D2 from the dedicated mp3 fixture at -16 dB', () => {
const { sampleRate, pcm } = decodeMp3ToMonoPcm(FIXTURE_PATH);
const notes = detectMonophonicNotesFromAudio({
pcm,
sampleRate,
startSeconds: 0,
endSeconds: pcm.length / sampleRate,
floorDb: -16,
pitchRangeStart: 12,
pitchRangeEnd: 107,
groupAdjacentPitchesToHighest: true,
});
expect(notes).toHaveLength(2);
expect(notes.map(note => pitchToNoteNameString(note.pitch))).toEqual(['A1', 'D2']);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import { KGProject } from '../core/KGProject';
import { KGAudioRegion } from '../core/region/KGAudioRegion';
import {
buildAudioToMidiAnalysisSpan,
convertDetectedAudioNotesToRawMidiNotes,
detectMonophonicNotesFromAudio,
} from './audioToMidi';
function createSineWavePcm(
frequency: number,
durationSeconds: number,
sampleRate: number,
amplitude: number = 0.8,
): Float32Array {
const sampleCount = Math.floor(durationSeconds * sampleRate);
const pcm = new Float32Array(sampleCount);
for (let i = 0; i < sampleCount; i++) {
pcm[i] = amplitude * Math.sin((2 * Math.PI * frequency * i) / sampleRate);
}
return pcm;
}
describe('audioToMidi analysis span helpers', () => {
it('uses the exact source audio region bounds when loop mode is off', () => {
const project = new KGProject('Test', 32, 0, 120);
const region = new KGAudioRegion('audio-1', '1', 0, 'Audio', 8, 4, 'file-1', 'test.wav', 10, 1.5);
const span = buildAudioToMidiAnalysisSpan(project, region, {
loopModeEnabled: false,
convertLoopRangeOnly: true,
loopingRange: [0, 0],
});
expect(span).toEqual({
regionStartBeat: 8,
regionEndBeat: 12,
startSeconds: 1.5,
endSeconds: 3.5,
});
});
it('uses the exact loop overlap when loop-only conversion is enabled', () => {
const project = new KGProject('Test', 32, 0, 120);
const region = new KGAudioRegion('audio-1', '1', 0, 'Audio', 8, 8, 'file-1', 'test.wav', 10, 0.5);
const span = buildAudioToMidiAnalysisSpan(project, region, {
loopModeEnabled: true,
convertLoopRangeOnly: true,
loopingRange: [3, 4],
});
expect(span).toEqual({
regionStartBeat: 12,
regionEndBeat: 16,
startSeconds: 2.5,
endSeconds: 4.5,
});
});
});
describe('audioToMidi note conversion helpers', () => {
it('drops raw notes shorter than the selected quantized note length and quantizes survivors', () => {
const project = new KGProject('Test', 32, 0, 120);
const rawNotes = convertDetectedAudioNotesToRawMidiNotes(
project,
{
regionStartBeat: 8,
regionEndBeat: 12,
startSeconds: 0,
endSeconds: 2,
},
[
{ startOffsetSeconds: 0.0, endOffsetSeconds: 0.24, pitch: 33, heat: 0.9 },
{ startOffsetSeconds: 0.5, endOffsetSeconds: 1.45, pitch: 38, heat: 0.7 },
],
{
quantizeNoteStart: '1/16',
quantizeNoteLength: '1/4',
},
);
expect(rawNotes).toEqual([
{
startBeat: 1,
endBeat: 3,
pitch: 38,
velocity: 102,
},
]);
});
});
describe('audioToMidi synthetic detection', () => {
it('detects sequential monophonic notes from a simple sine-wave fixture', () => {
const sampleRate = 44100;
const a1 = createSineWavePcm(55, 0.7, sampleRate);
const silence = new Float32Array(Math.floor(0.08 * sampleRate));
const d2 = createSineWavePcm(73.41619197935188, 0.7, sampleRate);
const pcm = new Float32Array(a1.length + silence.length + d2.length);
pcm.set(a1, 0);
pcm.set(silence, a1.length);
pcm.set(d2, a1.length + silence.length);
const notes = detectMonophonicNotesFromAudio({
pcm,
sampleRate,
startSeconds: 0,
endSeconds: pcm.length / sampleRate,
floorDb: -30,
pitchRangeStart: 21,
pitchRangeEnd: 38,
groupAdjacentPitchesToHighest: true,
});
expect(notes.map(note => note.pitch)).toEqual([33, 38]);
});
});
+161
View File
@@ -0,0 +1,161 @@
import { KGProject } from '../core/KGProject';
import { KGAudioRegion } from '../core/region/KGAudioRegion';
import type {
PianoRollQuantizeLengthValue,
PianoRollQuantizePositionValue,
} from '../core/state/KGPianoRollState';
import type { RawMidiNote } from './midiUtil';
import {
beatRangeToSeconds,
beatToSeconds,
secondsToBeat,
} from './globalTrackUtil';
export {
detectMonophonicNotesFromAudio,
type AudioToMidiDetectedNote,
type AudioToMidiDetectionRequest,
} from './audioToMidiCore';
import type { AudioToMidiDetectedNote } from './audioToMidiCore';
export interface AudioToMidiAnalysisSpan {
regionStartBeat: number;
regionEndBeat: number;
startSeconds: number;
endSeconds: number;
}
export interface AudioToMidiConversionOptions {
floorDb: number;
pitchRangeStart: number;
pitchRangeEnd: number;
quantizeNoteStart: PianoRollQuantizePositionValue;
quantizeNoteLength: PianoRollQuantizeLengthValue;
groupAdjacentPitchesToHighest: boolean;
}
export function buildAudioToMidiAnalysisSpan(
project: KGProject,
audioRegion: KGAudioRegion,
options: {
loopModeEnabled: boolean;
convertLoopRangeOnly: boolean;
loopingRange: [number, number];
},
): AudioToMidiAnalysisSpan | null {
const regionStartBeat = audioRegion.getStartFromBeat();
const regionEndBeat = regionStartBeat + audioRegion.getLength();
if (regionEndBeat <= regionStartBeat) {
return null;
}
if (options.loopModeEnabled && options.convertLoopRangeOnly) {
const beatsPerBar = project.getTimeSignature().numerator;
const loopStartBeat = options.loopingRange[0] * beatsPerBar;
const loopEndBeat = (options.loopingRange[1] + 1) * beatsPerBar;
const overlapStartBeat = Math.max(regionStartBeat, loopStartBeat);
const overlapEndBeat = Math.min(regionEndBeat, loopEndBeat);
if (overlapEndBeat <= overlapStartBeat) {
return null;
}
return {
regionStartBeat: overlapStartBeat,
regionEndBeat: overlapEndBeat,
startSeconds: audioRegion.getClipStartOffsetSeconds() + beatRangeToSeconds(project, regionStartBeat, overlapStartBeat),
endSeconds: audioRegion.getClipStartOffsetSeconds() + beatRangeToSeconds(project, regionStartBeat, overlapEndBeat),
};
}
return {
regionStartBeat,
regionEndBeat,
startSeconds: audioRegion.getClipStartOffsetSeconds(),
endSeconds: audioRegion.getClipStartOffsetSeconds() + beatRangeToSeconds(project, regionStartBeat, regionEndBeat),
};
}
export function quantizationValueToBeats(value: string): number {
const denominator = Number.parseInt(value.split('/')[1] ?? '', 10);
if (!Number.isFinite(denominator) || denominator <= 0) {
throw new Error(`Invalid quantization value: ${value}`);
}
return 4 / denominator;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
export function convertDetectedAudioNotesToRawMidiNotes(
project: KGProject,
span: AudioToMidiAnalysisSpan,
detectedNotes: AudioToMidiDetectedNote[],
options: Pick<AudioToMidiConversionOptions, 'quantizeNoteStart' | 'quantizeNoteLength'>,
): RawMidiNote[] {
const regionStartAbsoluteSeconds = beatToSeconds(project, span.regionStartBeat);
const regionLengthBeats = Math.max(0, span.regionEndBeat - span.regionStartBeat);
const quantizedStartStep = quantizationValueToBeats(options.quantizeNoteStart);
const quantizedLengthStep = quantizationValueToBeats(options.quantizeNoteLength);
const converted = detectedNotes
.map(note => {
const startBeatAbsolute = secondsToBeat(project, regionStartAbsoluteSeconds + note.startOffsetSeconds);
const endBeatAbsolute = secondsToBeat(project, regionStartAbsoluteSeconds + note.endOffsetSeconds);
const rawStartBeat = startBeatAbsolute - span.regionStartBeat;
const rawEndBeat = endBeatAbsolute - span.regionStartBeat;
const rawDurationBeats = rawEndBeat - rawStartBeat;
if (rawDurationBeats < quantizedLengthStep) {
return null;
}
const quantizedStartBeat = Math.round(rawStartBeat / quantizedStartStep) * quantizedStartStep;
const quantizedDurationBeats = Math.max(
quantizedLengthStep,
Math.round(rawDurationBeats / quantizedLengthStep) * quantizedLengthStep,
);
const quantizedEndBeat = quantizedStartBeat + quantizedDurationBeats;
if (quantizedStartBeat >= regionLengthBeats) {
return null;
}
const clampedStartBeat = clamp(quantizedStartBeat, 0, regionLengthBeats);
const clampedEndBeat = clamp(
Math.max(clampedStartBeat + quantizedLengthStep, quantizedEndBeat),
clampedStartBeat + Math.min(quantizedLengthStep, Math.max(regionLengthBeats - clampedStartBeat, 0)),
regionLengthBeats,
);
if (clampedEndBeat <= clampedStartBeat) {
return null;
}
return {
startBeat: clampedStartBeat,
endBeat: clampedEndBeat,
pitch: note.pitch,
velocity: clamp(Math.round(45 + note.heat * 82), 1, 127),
} satisfies RawMidiNote;
})
.filter((note): note is RawMidiNote => note !== null)
.sort((left, right) => left.startBeat - right.startBeat || left.pitch - right.pitch);
const merged: RawMidiNote[] = [];
for (const note of converted) {
const previous = merged[merged.length - 1];
if (
previous &&
previous.pitch === note.pitch &&
note.startBeat <= previous.endBeat + 1e-6
) {
previous.endBeat = Math.max(previous.endBeat, note.endBeat);
previous.velocity = Math.max(previous.velocity, note.velocity);
continue;
}
merged.push({ ...note });
}
return merged;
}
+358
View File
@@ -0,0 +1,358 @@
import FFT from 'fft.js';
import {
SPECTROGRAM_FULL_MAX_MIDI_PITCH,
SPECTROGRAM_FULL_MIN_MIDI_PITCH,
getSpectrogramAnalysisResolution,
getSpectrogramPitchBinCount,
} from './spectrogramUtil';
const FFT_SIZE = 16384;
const HOP_SIZE = 512;
const MAX_HEIGHT_RESOLUTION = 5;
const MIN_ANALYSIS_FREQUENCY = 20;
const MAX_ANALYSIS_FREQUENCY = 5000;
export interface AudioToMidiDetectionRequest {
pcm: Float32Array;
sampleRate: number;
startSeconds: number;
endSeconds: number;
floorDb: number;
pitchRangeStart: number;
pitchRangeEnd: number;
groupAdjacentPitchesToHighest: boolean;
}
export interface AudioToMidiDetectedNote {
startOffsetSeconds: number;
endOffsetSeconds: number;
pitch: number;
heat: number;
}
const HANN_WINDOW = (() => {
const window = new Float32Array(FFT_SIZE);
for (let i = 0; i < FFT_SIZE; i++) {
window[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (FFT_SIZE - 1)));
}
return window;
})();
function midiToFrequency(midiPitch: number): number {
return 440 * Math.pow(2, (midiPitch - 69) / 12);
}
function frequencyToMidiPitch(frequency: number): number | null {
if (!Number.isFinite(frequency) || frequency <= 0) {
return null;
}
return 69 + 12 * Math.log2(frequency / 440);
}
function mapMidiPitchToPosition(midiPitch: number, resolution: number): number | null {
const pitchOffset = midiPitch - SPECTROGRAM_FULL_MIN_MIDI_PITCH;
if (pitchOffset < 0 || pitchOffset > SPECTROGRAM_FULL_MAX_MIDI_PITCH) {
return null;
}
const scaled = pitchOffset * resolution + (resolution - 1) / 2;
const maxBin = getSpectrogramPitchBinCount(resolution) - 1;
return Math.max(0, Math.min(maxBin, scaled));
}
function estimateParabolicPeakOffset(leftMagnitude: number, centerMagnitude: number, rightMagnitude: number): number {
const denominator = leftMagnitude - 2 * centerMagnitude + rightMagnitude;
if (!Number.isFinite(denominator) || Math.abs(denominator) < Number.EPSILON) {
return 0;
}
const offset = 0.5 * (leftMagnitude - rightMagnitude) / denominator;
return Math.max(-0.5, Math.min(0.5, offset));
}
function estimatePeakFrequency(bin: number, hzPerBin: number, magnitudes: Float32Array): number {
const centerFrequency = bin * hzPerBin;
if (bin <= 1 || bin >= magnitudes.length - 1) {
return centerFrequency;
}
const leftMagnitude = magnitudes[bin - 1];
const centerMagnitude = magnitudes[bin];
const rightMagnitude = magnitudes[bin + 1];
if (centerMagnitude <= leftMagnitude || centerMagnitude <= rightMagnitude) {
return centerFrequency;
}
return (bin + estimateParabolicPeakOffset(leftMagnitude, centerMagnitude, rightMagnitude)) * hzPerBin;
}
function getFrequencyBandEdges(centerFrequency: number, hzPerBin: number): { lowerFrequency: number; upperFrequency: number } {
const halfBandwidth = hzPerBin / 2;
return {
lowerFrequency: Math.max(Number.EPSILON, centerFrequency - halfBandwidth),
upperFrequency: centerFrequency + halfBandwidth,
};
}
function paintMagnitudeAcrossPitchSpan(
target: Float32Array,
pitchBins: number,
startPosition: number,
endPosition: number,
centerPosition: number,
magnitude: number,
): void {
const clampedStart = Math.max(0, Math.min(startPosition, pitchBins - 1));
const clampedEnd = Math.max(0, Math.min(endPosition, pitchBins - 1));
const startBin = Math.max(0, Math.floor(clampedStart));
const endBin = Math.min(pitchBins - 1, Math.ceil(clampedEnd));
const spanWidth = Math.max(clampedEnd - clampedStart, 1);
for (let targetBin = startBin; targetBin <= endBin; targetBin++) {
const cellStart = targetBin - 0.5;
const cellEnd = targetBin + 0.5;
const overlap = Math.max(0, Math.min(clampedEnd, cellEnd) - Math.max(clampedStart, cellStart));
if (overlap <= 0) {
continue;
}
const overlapWeight = Math.min(1, overlap);
const centerDistance = Math.abs(targetBin - centerPosition);
const centerWeight = Math.max(0, 1 - centerDistance / (spanWidth + 1));
const weight = Math.max(overlapWeight * (0.6 + 0.4 * centerWeight), overlapWeight * 0.35);
const weightedMagnitude = magnitude * weight;
if (weightedMagnitude > target[targetBin]) {
target[targetBin] = weightedMagnitude;
}
}
}
function collapseAdjacentPitchClusters(activeBins: number[], frameBins: Float32Array): number[] {
if (activeBins.length === 0) {
return [];
}
const grouped: number[] = [];
let clusterStart = 0;
while (clusterStart < activeBins.length) {
let clusterEnd = clusterStart;
let strongestBin = activeBins[clusterStart];
let strongestValue = frameBins[strongestBin];
while (
clusterEnd + 1 < activeBins.length &&
activeBins[clusterEnd + 1] <= activeBins[clusterEnd] + 1
) {
clusterEnd += 1;
const candidateBin = activeBins[clusterEnd];
const candidateValue = frameBins[candidateBin];
if (candidateValue > strongestValue) {
strongestBin = candidateBin;
strongestValue = candidateValue;
}
}
grouped.push(strongestBin);
clusterStart = clusterEnd + 1;
}
return grouped;
}
function normalizePitchBinToMidiPitch(bin: number, resolution: number): number {
return Math.round((bin - ((resolution - 1) / 2)) / resolution);
}
export function detectMonophonicNotesFromAudio(request: AudioToMidiDetectionRequest): AudioToMidiDetectedNote[] {
const {
pcm,
sampleRate,
startSeconds,
endSeconds,
floorDb,
pitchRangeStart,
pitchRangeEnd,
groupAdjacentPitchesToHighest,
} = request;
const clampedStartSeconds = Math.max(0, startSeconds);
const clampedEndSeconds = Math.max(clampedStartSeconds, endSeconds);
const startSample = Math.max(0, Math.floor(clampedStartSeconds * sampleRate));
const endSample = Math.min(pcm.length, Math.ceil(clampedEndSeconds * sampleRate));
const sampleCount = Math.max(0, endSample - startSample);
if (sampleCount <= 0) {
return [];
}
const analysisResolution = getSpectrogramAnalysisResolution(MAX_HEIGHT_RESOLUTION);
const pitchBins = getSpectrogramPitchBinCount(analysisResolution);
const fft = new FFT(FFT_SIZE);
const complexOut = fft.createComplexArray() as number[];
const inputPadded = new Float32Array(FFT_SIZE);
const hzPerBin = sampleRate / FFT_SIZE;
const totalHops = Math.max(1, Math.ceil((sampleCount - FFT_SIZE) / HOP_SIZE) + 1);
const frameBinValues: Float32Array[] = [];
let globalMax = 0;
const minFrequency = Math.max(MIN_ANALYSIS_FREQUENCY, midiToFrequency(Math.max(0, pitchRangeStart - 2)));
const maxFrequency = Math.min(MAX_ANALYSIS_FREQUENCY, midiToFrequency(Math.min(127, pitchRangeEnd + 2)));
for (let hop = 0; hop < totalHops; hop++) {
const frameBins = new Float32Array(pitchBins);
const offset = hop * HOP_SIZE;
inputPadded.fill(0);
const available = Math.min(FFT_SIZE, sampleCount - offset);
for (let i = 0; i < available; i++) {
inputPadded[i] = pcm[startSample + offset + i] * HANN_WINDOW[i];
}
fft.realTransform(complexOut, inputPadded as unknown as number[]);
fft.completeSpectrum(complexOut);
const numBins = FFT_SIZE / 2;
const magnitudes = new Float32Array(numBins);
for (let bin = 1; bin < numBins; bin++) {
const re = complexOut[2 * bin];
const im = complexOut[2 * bin + 1];
magnitudes[bin] = Math.sqrt(re * re + im * im);
}
for (let bin = 1; bin < numBins; bin++) {
const centerFrequency = estimatePeakFrequency(bin, hzPerBin, magnitudes);
const { lowerFrequency, upperFrequency } = getFrequencyBandEdges(centerFrequency, hzPerBin);
if (upperFrequency < minFrequency || lowerFrequency > maxFrequency) {
continue;
}
const magnitude = magnitudes[bin];
if (magnitude <= 0) {
continue;
}
const lowerMidi = frequencyToMidiPitch(Math.max(lowerFrequency, minFrequency));
const upperMidi = frequencyToMidiPitch(Math.min(upperFrequency, maxFrequency));
const centerMidi = frequencyToMidiPitch(centerFrequency);
if (lowerMidi === null || upperMidi === null || centerMidi === null) {
continue;
}
const startPosition = mapMidiPitchToPosition(lowerMidi, analysisResolution);
const endPosition = mapMidiPitchToPosition(upperMidi, analysisResolution);
const centerPosition = mapMidiPitchToPosition(centerMidi, analysisResolution);
if (startPosition === null || endPosition === null || centerPosition === null) {
continue;
}
paintMagnitudeAcrossPitchSpan(
frameBins,
pitchBins,
Math.min(startPosition, endPosition),
Math.max(startPosition, endPosition),
centerPosition,
magnitude,
);
}
for (let i = 0; i < frameBins.length; i++) {
if (frameBins[i] > globalMax) {
globalMax = frameBins[i];
}
}
frameBinValues.push(frameBins);
}
if (globalMax <= 0) {
return [];
}
const linearThreshold = Math.pow(10, floorDb / 20);
const hopDurationSeconds = HOP_SIZE / sampleRate;
const notes: AudioToMidiDetectedNote[] = [];
let current: AudioToMidiDetectedNote | null = null;
let currentHeatSum = 0;
let currentFrameCount = 0;
for (let frameIndex = 0; frameIndex < frameBinValues.length; frameIndex++) {
const normalizedBins = frameBinValues[frameIndex].slice();
const activeBins: number[] = [];
for (let i = 0; i < normalizedBins.length; i++) {
normalizedBins[i] = normalizedBins[i] / globalMax;
if (normalizedBins[i] >= linearThreshold) {
activeBins.push(i);
}
}
const candidateBins = groupAdjacentPitchesToHighest
? collapseAdjacentPitchClusters(activeBins, normalizedBins)
: activeBins;
let winningPitch: number | null = null;
let winningHeat = 0;
for (const bin of candidateBins) {
const heat = normalizedBins[bin];
if (heat <= winningHeat) {
continue;
}
const midiPitch = normalizePitchBinToMidiPitch(bin, analysisResolution);
if (midiPitch < pitchRangeStart || midiPitch > pitchRangeEnd) {
continue;
}
winningPitch = midiPitch;
winningHeat = heat;
}
const frameStartSeconds = frameIndex * hopDurationSeconds;
const frameEndSeconds = Math.min((frameIndex + 1) * hopDurationSeconds, clampedEndSeconds - clampedStartSeconds);
if (winningPitch === null) {
if (current) {
current.heat = currentHeatSum / Math.max(1, currentFrameCount);
notes.push(current);
current = null;
currentHeatSum = 0;
currentFrameCount = 0;
}
continue;
}
if (current && current.pitch === winningPitch) {
current.endOffsetSeconds = frameEndSeconds;
currentHeatSum += winningHeat;
currentFrameCount += 1;
continue;
}
if (current) {
current.heat = currentHeatSum / Math.max(1, currentFrameCount);
notes.push(current);
}
// Future polyphonic support should preserve the strongest bin inside an
// adjacent cluster (for example C4 over weaker B3/D4 neighbors) before
// selecting additional simultaneous note candidates.
current = {
startOffsetSeconds: frameStartSeconds,
endOffsetSeconds: frameEndSeconds,
pitch: winningPitch,
heat: winningHeat,
};
currentHeatSum = winningHeat;
currentFrameCount = 1;
}
if (current) {
current.heat = currentHeatSum / Math.max(1, currentFrameCount);
notes.push(current);
}
return notes;
}
+49
View File
@@ -37,6 +37,19 @@ export interface TempoApplyResult {
autoAlignRegionToBeat: boolean; autoAlignRegionToBeat: boolean;
} }
export interface AudioToMidiOptionsResult {
monophonic: boolean;
useCurrentFloorDb: boolean;
manualFloorDb: number;
pitchRangeStart: number;
pitchRangeEnd: number;
quantizeNoteStart: string;
quantizeNoteLength: string;
convertLoopRangeOnly: boolean;
groupAdjacentPitchesToHighest: boolean;
targetTrackId: string;
}
export interface ChoiceOption { export interface ChoiceOption {
label: string; label: string;
value: string; value: string;
@@ -51,6 +64,12 @@ let _showChordDetectionOptionsFn: ((message: string, defaultValue?: ChordDetecti
let _showMidiChordDetectionOptionsFn: ((message: string, defaultValue?: MidiChordDetectionOptionsResult) => Promise<MidiChordDetectionOptionsResult | null>) | null = null; let _showMidiChordDetectionOptionsFn: ((message: string, defaultValue?: MidiChordDetectionOptionsResult) => Promise<MidiChordDetectionOptionsResult | null>) | null = null;
let _showTempoDetectionOptionsFn: ((message: string, defaultValue?: TempoDetectionOptionsResult) => Promise<TempoDetectionOptionsResult | null>) | null = null; let _showTempoDetectionOptionsFn: ((message: string, defaultValue?: TempoDetectionOptionsResult) => Promise<TempoDetectionOptionsResult | null>) | null = null;
let _showTempoApplyFn: ((message: string, choices: ChoiceOption[]) => Promise<TempoApplyResult | null>) | null = null; let _showTempoApplyFn: ((message: string, choices: ChoiceOption[]) => Promise<TempoApplyResult | null>) | null = null;
let _showAudioToMidiOptionsFn: ((
message: string,
targetTracks: ChoiceOption[],
loopModeEnabled: boolean,
defaultValue?: AudioToMidiOptionsResult,
) => Promise<AudioToMidiOptionsResult | null>) | null = null;
export function registerDialogFns( export function registerDialogFns(
alertFn: (message: string) => Promise<void>, alertFn: (message: string) => Promise<void>,
@@ -62,6 +81,12 @@ export function registerDialogFns(
midiChordDetectionOptionsFn?: (message: string, defaultValue?: MidiChordDetectionOptionsResult) => Promise<MidiChordDetectionOptionsResult | null>, midiChordDetectionOptionsFn?: (message: string, defaultValue?: MidiChordDetectionOptionsResult) => Promise<MidiChordDetectionOptionsResult | null>,
tempoDetectionOptionsFn?: (message: string, defaultValue?: TempoDetectionOptionsResult) => Promise<TempoDetectionOptionsResult | null>, tempoDetectionOptionsFn?: (message: string, defaultValue?: TempoDetectionOptionsResult) => Promise<TempoDetectionOptionsResult | null>,
tempoApplyFn?: (message: string, choices: ChoiceOption[]) => Promise<TempoApplyResult | null>, tempoApplyFn?: (message: string, choices: ChoiceOption[]) => Promise<TempoApplyResult | null>,
audioToMidiOptionsFn?: (
message: string,
targetTracks: ChoiceOption[],
loopModeEnabled: boolean,
defaultValue?: AudioToMidiOptionsResult,
) => Promise<AudioToMidiOptionsResult | null>,
) { ) {
_showAlertFn = alertFn; _showAlertFn = alertFn;
_showConfirmFn = confirmFn; _showConfirmFn = confirmFn;
@@ -72,6 +97,7 @@ export function registerDialogFns(
if (midiChordDetectionOptionsFn) _showMidiChordDetectionOptionsFn = midiChordDetectionOptionsFn; if (midiChordDetectionOptionsFn) _showMidiChordDetectionOptionsFn = midiChordDetectionOptionsFn;
if (tempoDetectionOptionsFn) _showTempoDetectionOptionsFn = tempoDetectionOptionsFn; if (tempoDetectionOptionsFn) _showTempoDetectionOptionsFn = tempoDetectionOptionsFn;
if (tempoApplyFn) _showTempoApplyFn = tempoApplyFn; if (tempoApplyFn) _showTempoApplyFn = tempoApplyFn;
if (audioToMidiOptionsFn) _showAudioToMidiOptionsFn = audioToMidiOptionsFn;
} }
export function showAlert(message: string): Promise<void> { export function showAlert(message: string): Promise<void> {
@@ -166,3 +192,26 @@ export function showTempoApply(message: string, choices: ChoiceOption[]): Promis
} }
return _showTempoApplyFn(message, choices); return _showTempoApplyFn(message, choices);
} }
export function showAudioToMidiOptions(
message: string,
targetTracks: ChoiceOption[],
loopModeEnabled: boolean,
defaultValue?: AudioToMidiOptionsResult,
): Promise<AudioToMidiOptionsResult | null> {
if (!_showAudioToMidiOptionsFn) {
return Promise.resolve(defaultValue ?? {
monophonic: true,
useCurrentFloorDb: true,
manualFloorDb: -25,
pitchRangeStart: 12,
pitchRangeEnd: 107,
quantizeNoteStart: '1/16',
quantizeNoteLength: '1/16',
convertLoopRangeOnly: true,
groupAdjacentPitchesToHighest: true,
targetTrackId: targetTracks[0]?.value ?? '',
});
}
return _showAudioToMidiOptionsFn(message, targetTracks, loopModeEnabled, defaultValue);
}
+22
View File
@@ -0,0 +1,22 @@
import {
detectMonophonicNotesFromAudio,
type AudioToMidiDetectedNote,
type AudioToMidiDetectionRequest,
} from '../util/audioToMidiCore';
export interface AudioToMidiWorkerResult {
type: 'result';
notes: AudioToMidiDetectedNote[];
}
type WorkerScopeLike = typeof globalThis & {
onmessage: ((event: MessageEvent<AudioToMidiDetectionRequest>) => void) | null;
postMessage: (message: AudioToMidiWorkerResult) => void;
};
const workerScope = self as WorkerScopeLike;
workerScope.onmessage = (event: MessageEvent<AudioToMidiDetectionRequest>) => {
const notes = detectMonophonicNotesFromAudio(event.data);
workerScope.postMessage({ type: 'result', notes });
};