From 0f1bad4394f722632b496a356a6093eb83e80b6b Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Mon, 25 May 2026 23:22:42 -0700 Subject: [PATCH] feat: implemented chord detection feature for MIDI regions --- public/test-data/chord-progression-01.json | 486 ++++++++++++++++++ src/components/common/DialogProvider.tsx | 114 +++- src/components/piano-roll/PianoRoll.tsx | 136 +++-- .../piano-roll/PianoRollToolbar.test.tsx | 18 + .../piano-roll/PianoRollToolbar.tsx | 3 +- .../midi-chord-detection.integration.test.ts | 93 ++++ src/util/dialogUtil.ts | 23 + src/util/midiChordDetection.test.ts | 117 +++++ src/util/midiChordDetection.ts | 358 +++++++++++++ 9 files changed, 1274 insertions(+), 74 deletions(-) create mode 100755 public/test-data/chord-progression-01.json create mode 100644 src/test/integration/midi-chord-detection.integration.test.ts create mode 100644 src/util/midiChordDetection.test.ts create mode 100644 src/util/midiChordDetection.ts diff --git a/public/test-data/chord-progression-01.json b/public/test-data/chord-progression-01.json new file mode 100755 index 0000000..7da050a --- /dev/null +++ b/public/test-data/chord-progression-01.json @@ -0,0 +1,486 @@ +{ + "name": "chord-midi conversion test", + "maxBars": 32, + "currentBars": 0, + "timeSignature": { + "numerator": 4, + "denominator": 4 + }, + "bpm": 125, + "keySignature": "A minor", + "selectedMode": "ionian", + "isLooping": false, + "loopingRange": [ + 0, + 0 + ], + "barWidthMultiplier": 2, + "pianoRollZoom": 1, + "projectStructureVersion": 15, + "tracks": [ + { + "__type": "KGMidiTrack", + "name": "Melody", + "id": 1, + "trackIndex": 0, + "type": "MIDI", + "volume": 0, + "muted": false, + "solo": false, + "regions": [ + { + "__type": "KGMidiRegion", + "id": "KGMidiRegion_1779769428819_b7o8981rl", + "trackId": "1", + "trackIndex": 0, + "name": "Chord Progression", + "startFromBeat": 0, + "length": 32, + "selected": false, + "notes": [ + { + "id": "KGMidiNote_1779769428819_0rub4fxuu", + "startBeat": 0, + "endBeat": 4, + "pitch": 45, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_xm390pxjt", + "startBeat": 0, + "endBeat": 4, + "pitch": 57, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_lnjgm5h4y", + "startBeat": 0, + "endBeat": 4, + "pitch": 60, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_mhadr3rqs", + "startBeat": 0, + "endBeat": 4, + "pitch": 64, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_6iotgyszv", + "startBeat": 4, + "endBeat": 8, + "pitch": 41, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_t948mgv63", + "startBeat": 4, + "endBeat": 8, + "pitch": 53, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_17lfthyzq", + "startBeat": 4, + "endBeat": 8, + "pitch": 57, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_8rck5x4qu", + "startBeat": 4, + "endBeat": 8, + "pitch": 60, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_7lzkhxewk", + "startBeat": 8, + "endBeat": 12, + "pitch": 50, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_vudwound0", + "startBeat": 8, + "endBeat": 12, + "pitch": 62, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_3dlhf70kk", + "startBeat": 8, + "endBeat": 12, + "pitch": 65, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_yqcv8cfm1", + "startBeat": 8, + "endBeat": 12, + "pitch": 69, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_uyz56w2ln", + "startBeat": 12, + "endBeat": 16, + "pitch": 52, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_datxn33uz", + "startBeat": 12, + "endBeat": 16, + "pitch": 64, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_sd8mv9pr6", + "startBeat": 12, + "endBeat": 16, + "pitch": 68, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_ja7sz9w2k", + "startBeat": 12, + "endBeat": 16, + "pitch": 71, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_f68y3owck", + "startBeat": 12, + "endBeat": 16, + "pitch": 74, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_2kfouqrvw", + "startBeat": 16, + "endBeat": 20, + "pitch": 45, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_fvlkoqryj", + "startBeat": 16, + "endBeat": 20, + "pitch": 57, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_w9bz827to", + "startBeat": 16, + "endBeat": 20, + "pitch": 60, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_0tqsc6c0m", + "startBeat": 16, + "endBeat": 20, + "pitch": 64, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_ixzfcrjvc", + "startBeat": 20, + "endBeat": 24, + "pitch": 48, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_fyhjqc8iz", + "startBeat": 20, + "endBeat": 24, + "pitch": 60, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_h6o16p7os", + "startBeat": 20, + "endBeat": 24, + "pitch": 64, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_pruviieci", + "startBeat": 20, + "endBeat": 24, + "pitch": 67, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_tnskhun3e", + "startBeat": 24, + "endBeat": 28, + "pitch": 50, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_wm50u0odu", + "startBeat": 24, + "endBeat": 28, + "pitch": 62, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_6xj1yzqhb", + "startBeat": 24, + "endBeat": 28, + "pitch": 65, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_nm8rlk6bl", + "startBeat": 24, + "endBeat": 28, + "pitch": 69, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_dwu4rjqeb", + "startBeat": 28, + "endBeat": 32, + "pitch": 52, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_rq9tzzjee", + "startBeat": 28, + "endBeat": 32, + "pitch": 64, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_r678w4eew", + "startBeat": 28, + "endBeat": 32, + "pitch": 68, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_d2akv8vpt", + "startBeat": 28, + "endBeat": 32, + "pitch": 71, + "velocity": 127, + "selected": false + }, + { + "id": "KGMidiNote_1779769428819_q2l671ma4", + "startBeat": 28, + "endBeat": 32, + "pitch": 74, + "velocity": 127, + "selected": false + } + ], + "pitchBends": [], + "controllerEventsByType": [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [] + ] + } + ], + "volumeAutomation": [], + "panAutomation": [], + "instrument": "acoustic_grand_piano" + } + ], + "globalTracks": [ + { + "__type": "KGMarkerTrack", + "id": "global-marker", + "trackIndex": 0, + "type": "marker", + "name": "Marker", + "regions": [] + }, + { + "__type": "KGTempoTrack", + "id": "global-tempo", + "trackIndex": 1, + "type": "tempo", + "name": "Tempo", + "regions": [] + }, + { + "__type": "KGSignatureTrack", + "id": "global-signature", + "trackIndex": 2, + "type": "signature", + "name": "Key Signature", + "regions": [] + }, + { + "__type": "KGChordTrack", + "id": "global-chord", + "trackIndex": 3, + "type": "chord", + "name": "Chord", + "regions": [] + } + ] +} \ No newline at end of file diff --git a/src/components/common/DialogProvider.tsx b/src/components/common/DialogProvider.tsx index 1285d95..23baf9e 100644 --- a/src/components/common/DialogProvider.tsx +++ b/src/components/common/DialogProvider.tsx @@ -6,32 +6,43 @@ import type { ChoiceOption, ChordDetectionOptionsResult, ConfirmOptions, + MidiChordDetectionOptionsResult, PromptOptions, TimeSigResult, } from '../../util/dialogUtil'; interface DialogInfo { - type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice' | 'chord-detection'; + type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice' | 'chord-detection' | 'midi-chord-detection'; message: string; options?: ConfirmOptions | PromptOptions; defaultValue?: string; defaultTimeSig?: TimeSigResult; choices?: ChoiceOption[]; defaultChordDetectionOptions?: ChordDetectionOptionsResult; + defaultMidiChordDetectionOptions?: MidiChordDetectionOptionsResult; } +const DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS: ChordDetectionOptionsResult = { + sensitivity: 50, + stability: 50, + noChordThreshold: 0, + enableSevenths: false, +}; + +const DEFAULT_MIDI_CHORD_DETECTION_OPTIONS: MidiChordDetectionOptionsResult = { + enableSevenths: false, + shortNoteSuppression: 'medium', + harmonicFocus: 'favor-sustained-notes', +}; + const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [dialog, setDialog] = useState(null); const [isClosing, setIsClosing] = useState(false); const [inputValue, setInputValue] = useState(''); const [timeSigNumerator, setTimeSigNumerator] = useState(''); const [timeSigDenominator, setTimeSigDenominator] = useState(''); - const [chordDetectionOptions, setChordDetectionOptions] = useState({ - sensitivity: 50, - stability: 50, - noChordThreshold: 0, - enableSevenths: false, - }); + const [chordDetectionOptions, setChordDetectionOptions] = useState(DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS); + const [midiChordDetectionOptions, setMidiChordDetectionOptions] = useState(DEFAULT_MIDI_CHORD_DETECTION_OPTIONS); // eslint-disable-next-line @typescript-eslint/no-explicit-any const resolveRef = useRef<((value: any) => void) | null>(null); const pendingValueRef = useRef(undefined); @@ -80,16 +91,22 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = ): Promise => { return new Promise((resolve) => { resolveRef.current = resolve; - setChordDetectionOptions(defaultValue ?? { - sensitivity: 50, - stability: 50, - noChordThreshold: 0, - enableSevenths: false, - }); + setChordDetectionOptions(defaultValue ?? DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS); setDialog({ type: 'chord-detection', message, defaultChordDetectionOptions: defaultValue }); }); }, []); + const openMidiChordDetectionOptions = useCallback(( + message: string, + defaultValue?: MidiChordDetectionOptionsResult, + ): Promise => { + return new Promise((resolve) => { + resolveRef.current = resolve; + setMidiChordDetectionOptions(defaultValue ?? DEFAULT_MIDI_CHORD_DETECTION_OPTIONS); + setDialog({ type: 'midi-chord-detection', message, defaultMidiChordDetectionOptions: defaultValue }); + }); + }, []); + const close = useCallback((value: unknown) => { pendingValueRef.current = value; setIsClosing(true); @@ -103,12 +120,8 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = setInputValue(''); setTimeSigNumerator(''); setTimeSigDenominator(''); - setChordDetectionOptions({ - sensitivity: 50, - stability: 50, - noChordThreshold: 0, - enableSevenths: false, - }); + setChordDetectionOptions(DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS); + setMidiChordDetectionOptions(DEFAULT_MIDI_CHORD_DETECTION_OPTIONS); if (resolveRef.current) { resolveRef.current(pendingValueRef.current); resolveRef.current = null; @@ -120,7 +133,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = const registered = useRef(false); if (!registered.current) { registered.current = true; - registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig, openChoice, openChordDetectionOptions); + registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig, openChoice, openChordDetectionOptions, openMidiChordDetectionOptions); } if (!dialog) { @@ -132,13 +145,14 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = const isTimeSig = dialog.type === 'timesig'; const isChoice = dialog.type === 'choice'; const isChordDetection = dialog.type === 'chord-detection'; + const isMidiChordDetection = dialog.type === 'midi-chord-detection'; const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined; const title = isAlert ? 'Notice' : isTimeSig ? 'Time Signature' - : isChordDetection + : (isChordDetection || isMidiChordDetection) ? 'Chord Detection' : isPrompt ? 'Input' @@ -167,6 +181,10 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = close(chordDetectionOptions); return; } + if (isMidiChordDetection) { + close(midiChordDetectionOptions); + return; + } close(true); }; @@ -177,6 +195,13 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = setChordDetectionOptions(current => ({ ...current, [key]: value })); }; + const updateMidiChordDetectionOption = ( + key: K, + value: MidiChordDetectionOptionsResult[K], + ) => { + setMidiChordDetectionOptions(current => ({ ...current, [key]: value })); + }; + return ( <> {children} @@ -298,6 +323,49 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = )} + {isMidiChordDetection && ( +
+
+
+ +
+ +
+
+
+ +
+ +
+ +
+ )}
{!isAlert && ( @@ -323,9 +391,9 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = )}
diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index c23ad9f..dbd4994 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -20,7 +20,7 @@ import { ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../co import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage'; import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil'; -import { showAlert, showChordDetectionOptions } from '../../util/dialogUtil'; +import { showAlert, showChordDetectionOptions, showMidiChordDetectionOptions } from '../../util/dialogUtil'; import { normalizeSpectrogramHeightResolution, type SpectrogramHeightResolution, @@ -32,6 +32,13 @@ import { type AudioChordDetectionOptions, type DetectedAudioChord, } from '../../util/audioChordDetection'; +import { + DEFAULT_MIDI_CHORD_DETECTION_OPTIONS, + buildMidiChordWindowsForRegion, + detectChordsFromMidi, + type DetectedMidiChord, + type MidiChordDetectionOptions, +} from '../../util/midiChordDetection'; import type { AudioChordDetectionWorkerMessage } from '../../workers/audioChordDetectionWorker'; import type { PianoRollAutomationType } from './pianoRollAutomation'; import type { SheetMeasureMetric } from './sheetNotationTypes'; @@ -450,22 +457,32 @@ const PianoRoll: React.FC = ({ }; const handleDetectChords = useCallback(async () => { - if (!audioRegion || !projectName || !trackId) { - await showAlert('Open an audio region in spectrogram mode before detecting chords.'); + if (!audioRegion && !activeRegion) { + await showAlert('Open a MIDI or audio region before detecting chords.'); return; } const project = KGCore.instance().getCurrentProject(); - const windows = buildAudioChordWindowsForRegion(project, audioRegion); - if (windows.length === 0) { - await showAlert('The selected audio region has no audible span to analyze.'); + const audioWindows = audioRegion ? buildAudioChordWindowsForRegion(project, audioRegion) : null; + const midiWindows = !audioRegion && activeRegion ? buildMidiChordWindowsForRegion(project, activeRegion) : null; + const chordWindows = audioWindows ?? midiWindows ?? []; + if (chordWindows.length === 0) { + await showAlert(audioRegion + ? 'The selected audio region has no audible span to analyze.' + : 'The selected MIDI region has no bars to analyze.' + ); return; } - const detectionOptions = await showChordDetectionOptions( - 'Tune chord detection settings before processing.', - DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS, - ); + const detectionOptions = audioRegion + ? await showChordDetectionOptions( + 'Tune audio chord detection settings before processing.', + DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS, + ) + : await showMidiChordDetectionOptions( + 'Tune MIDI chord detection settings before processing.', + DEFAULT_MIDI_CHORD_DETECTION_OPTIONS, + ); if (!detectionOptions) { return; } @@ -475,48 +492,64 @@ const PianoRoll: React.FC = ({ let worker: Worker | null = null; try { - let audioBuffer = KGAudioInterface.instance().getAudioBuffer(trackId, audioRegion.getAudioFileId()); - if (!audioBuffer) { - const rawBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioRegion.getAudioFileId()); - const actx = Tone.getContext().rawContext as AudioContext; - audioBuffer = await actx.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; + let detectedChords: DetectedAudioChord[] | DetectedMidiChord[]; + if (audioRegion) { + if (!projectName || !trackId) { + await showAlert('Open an audio region in spectrogram mode before detecting chords.'); + return; } - } - const request: AudioChordDetectionRequest = { - pcm: monoPcm, - sampleRate: audioBuffer.sampleRate, - clipStartOffsetSeconds: audioRegion.getClipStartOffsetSeconds(), - windows, - options: detectionOptions as AudioChordDetectionOptions, - }; + let audioBuffer = KGAudioInterface.instance().getAudioBuffer(trackId, audioRegion.getAudioFileId()); + if (!audioBuffer) { + const rawBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioRegion.getAudioFileId()); + const actx = Tone.getContext().rawContext as AudioContext; + audioBuffer = await actx.decodeAudioData(rawBuffer); + } - const detectedChords = await new Promise((resolve, reject) => { - worker = new Worker( - new URL('../../workers/audioChordDetectionWorker.ts', import.meta.url), - { type: 'module' }, - ); - - worker.onmessage = (event: MessageEvent) => { - if (event.data.type === 'progress') { - setDetectChordProgressPercent(event.data.progress.percent); - return; + 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; } + } - resolve(event.data.results); + const request: AudioChordDetectionRequest = { + pcm: monoPcm, + sampleRate: audioBuffer.sampleRate, + clipStartOffsetSeconds: audioRegion.getClipStartOffsetSeconds(), + windows: audioWindows ?? [], + options: detectionOptions as AudioChordDetectionOptions, }; - worker.onerror = () => { - reject(new Error('Chord detection worker failed.')); - }; - worker.postMessage(request, [request.pcm.buffer]); - }); + + detectedChords = await new Promise((resolve, reject) => { + worker = new Worker( + new URL('../../workers/audioChordDetectionWorker.ts', import.meta.url), + { type: 'module' }, + ); + + worker.onmessage = (event: MessageEvent) => { + if (event.data.type === 'progress') { + setDetectChordProgressPercent(event.data.progress.percent); + return; + } + + resolve(event.data.results); + }; + worker.onerror = () => { + reject(new Error('Chord detection worker failed.')); + }; + worker.postMessage(request, [request.pcm.buffer]); + }); + } else { + setDetectChordProgressPercent(100); + detectedChords = detectChordsFromMidi({ + project, + region: activeRegion as KGMidiRegion, + windows: midiWindows ?? [], + options: detectionOptions as MidiChordDetectionOptions, + }); + } const replacements = detectedChords .filter(result => result.symbol !== 'N' && result.endBeat > result.startBeat) @@ -526,15 +559,18 @@ const PianoRoll: React.FC = ({ symbol: result.symbol, })); - const spanStartBeat = windows[0].startBeat; - const spanEndBeat = windows[windows.length - 1].endBeat; + const spanStartBeat = chordWindows[0].startBeat; + const spanEndBeat = chordWindows[chordWindows.length - 1].endBeat; KGCore.instance().executeCommand( new ReplaceChordRegionsInRangeCommand(spanStartBeat, spanEndBeat, replacements), ); refreshProjectState(); } catch (error) { console.error('Error detecting chords:', error); - await showAlert('Failed to detect chords from this audio region.'); + await showAlert(audioRegion + ? 'Failed to detect chords from this audio region.' + : 'Failed to detect chords from this MIDI region.' + ); } finally { if (worker) { worker.terminate(); @@ -542,7 +578,7 @@ const PianoRoll: React.FC = ({ setIsDetectingChords(false); setDetectChordProgressPercent(0); } - }, [audioRegion, projectName, refreshProjectState, trackId]); + }, [activeRegion, audioRegion, projectName, refreshProjectState, trackId]); // Handle title click to rename the region const handleTitleClick = () => { diff --git a/src/components/piano-roll/PianoRollToolbar.test.tsx b/src/components/piano-roll/PianoRollToolbar.test.tsx index 935245f..196a7d4 100644 --- a/src/components/piano-roll/PianoRollToolbar.test.tsx +++ b/src/components/piano-roll/PianoRollToolbar.test.tsx @@ -137,6 +137,24 @@ describe('PianoRollToolbar', () => { expect(onDetectChords).toHaveBeenCalledTimes(1); }); + it('shows the detect chords action in midi mode and triggers it', () => { + const onDetectChords = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByTitle('More options')); + fireEvent.click(screen.getByText('Detect chords...')); + + expect(onDetectChords).toHaveBeenCalledTimes(1); + }); + it('disables the detect chords action while detection is running', () => { const onDetectChords = vi.fn(); diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index 3236005..ce2d5c8 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -89,6 +89,7 @@ const PianoRollToolbar: React.FC = ({ }) => { const showMidiControls = mode !== 'spectrogram' && !sheetMusicViewEnabled; // midi-edit and hybrid const showSpecControls = !sheetMusicViewEnabled && (mode === 'spectrogram' || mode === 'hybrid'); + const showDetectChordMenu = !sheetMusicViewEnabled && !!onDetectChords; const [showZoomSlider, setShowZoomSlider] = React.useState(false); const zoomSliderRef = React.useRef(null); @@ -299,7 +300,7 @@ const PianoRollToolbar: React.FC = ({ )} - {showSpecControls && ( + {showDetectChordMenu && (