feat: added v1 Meyda.js based chord detection feature

This commit is contained in:
Xiaohan-Tian
2026-05-25 20:55:06 -07:00
parent f12add935a
commit a588cb439f
12 changed files with 634 additions and 36 deletions
+53 -1
View File
@@ -146,6 +146,58 @@
line-height: 1;
}
.dialog-chord-detection-form {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.dialog-slider-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.dialog-slider-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dialog-slider-label {
color: #d0d0d0;
font-size: 13px;
font-weight: 500;
}
.dialog-slider-value {
color: #8fbce0;
font-size: 12px;
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.dialog-slider {
width: 100%;
accent-color: #5a9fd4;
}
.dialog-checkbox-row {
display: flex;
align-items: center;
gap: 10px;
color: #d0d0d0;
font-size: 13px;
cursor: pointer;
}
.dialog-checkbox-row input[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: #5a9fd4;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
@@ -194,4 +246,4 @@
.dialog-btn-secondary:hover {
background-color: rgba(90, 159, 212, 0.12);
transform: translateY(-1px);
}
}
@@ -0,0 +1,86 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import DialogProvider from './DialogProvider';
import { showChordDetectionOptions } from '../../util/dialogUtil';
function finishDialogCloseAnimation() {
const overlay = document.querySelector('.dialog-overlay');
if (overlay) {
fireEvent.animationEnd(overlay);
}
}
describe('DialogProvider chord detection dialog', () => {
it('opens the chord detection modal with the expected defaults and resolves cancel to null', async () => {
let resolved: unknown = 'pending';
render(
<DialogProvider>
<button
type="button"
onClick={async () => {
resolved = await showChordDetectionOptions('Tune chord detection settings before processing.', {
sensitivity: 50,
stability: 50,
noChordThreshold: 0,
enableSevenths: false,
});
}}
>
Open
</button>
</DialogProvider>,
);
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
expect(screen.getByText('Chord Detection')).toBeInTheDocument();
expect(screen.getByLabelText('Sensitivity')).toHaveValue('50');
expect(screen.getByLabelText('Stability')).toHaveValue('50');
expect(screen.getByLabelText('No-Chord Threshold')).toHaveValue('0');
expect(screen.getByLabelText('Chord Detail: Enable sevenths')).not.toBeChecked();
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
finishDialogCloseAnimation();
await waitFor(() => expect(resolved).toBeNull());
});
it('returns the adjusted chord detection options when confirmed', async () => {
let resolved: unknown = null;
render(
<DialogProvider>
<button
type="button"
onClick={async () => {
resolved = await showChordDetectionOptions('Tune chord detection settings before processing.', {
sensitivity: 50,
stability: 50,
noChordThreshold: 0,
enableSevenths: false,
});
}}
>
Open
</button>
</DialogProvider>,
);
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
fireEvent.change(screen.getByLabelText('Sensitivity'), { target: { value: '62' } });
fireEvent.change(screen.getByLabelText('Stability'), { target: { value: '81' } });
fireEvent.change(screen.getByLabelText('No-Chord Threshold'), { target: { value: '24' } });
fireEvent.click(screen.getByLabelText('Chord Detail: Enable sevenths'));
fireEvent.click(screen.getByRole('button', { name: 'Detect' }));
finishDialogCloseAnimation();
await waitFor(() => expect(resolved).toEqual({
sensitivity: 62,
stability: 81,
noChordThreshold: 24,
enableSevenths: true,
}));
});
});
+125 -8
View File
@@ -2,15 +2,22 @@ import React, { useState, useCallback, useRef } from 'react';
import './DialogProvider.css';
import { FaTimes } from 'react-icons/fa';
import { registerDialogFns } from '../../util/dialogUtil';
import type { ChoiceOption, ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
import type {
ChoiceOption,
ChordDetectionOptionsResult,
ConfirmOptions,
PromptOptions,
TimeSigResult,
} from '../../util/dialogUtil';
interface DialogInfo {
type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice';
type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice' | 'chord-detection';
message: string;
options?: ConfirmOptions | PromptOptions;
defaultValue?: string;
defaultTimeSig?: TimeSigResult;
choices?: ChoiceOption[];
defaultChordDetectionOptions?: ChordDetectionOptionsResult;
}
const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -19,6 +26,12 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const [inputValue, setInputValue] = useState('');
const [timeSigNumerator, setTimeSigNumerator] = useState('');
const [timeSigDenominator, setTimeSigDenominator] = useState('');
const [chordDetectionOptions, setChordDetectionOptions] = useState<ChordDetectionOptionsResult>({
sensitivity: 50,
stability: 50,
noChordThreshold: 0,
enableSevenths: false,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolveRef = useRef<((value: any) => void) | null>(null);
const pendingValueRef = useRef<unknown>(undefined);
@@ -61,6 +74,22 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
});
}, []);
const openChordDetectionOptions = useCallback((
message: string,
defaultValue?: ChordDetectionOptionsResult,
): Promise<ChordDetectionOptionsResult | null> => {
return new Promise<ChordDetectionOptionsResult | null>((resolve) => {
resolveRef.current = resolve;
setChordDetectionOptions(defaultValue ?? {
sensitivity: 50,
stability: 50,
noChordThreshold: 0,
enableSevenths: false,
});
setDialog({ type: 'chord-detection', message, defaultChordDetectionOptions: defaultValue });
});
}, []);
const close = useCallback((value: unknown) => {
pendingValueRef.current = value;
setIsClosing(true);
@@ -74,6 +103,12 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
setInputValue('');
setTimeSigNumerator('');
setTimeSigDenominator('');
setChordDetectionOptions({
sensitivity: 50,
stability: 50,
noChordThreshold: 0,
enableSevenths: false,
});
if (resolveRef.current) {
resolveRef.current(pendingValueRef.current);
resolveRef.current = null;
@@ -85,7 +120,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);
registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig, openChoice, openChordDetectionOptions);
}
if (!dialog) {
@@ -96,9 +131,18 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const isPrompt = dialog.type === 'prompt';
const isTimeSig = dialog.type === 'timesig';
const isChoice = dialog.type === 'choice';
const isChordDetection = dialog.type === 'chord-detection';
const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined;
const title = isAlert ? 'Notice' : isTimeSig ? 'Time Signature' : isPrompt ? 'Input' : 'Confirm';
const title = isAlert
? 'Notice'
: isTimeSig
? 'Time Signature'
: isChordDetection
? 'Chord Detection'
: isPrompt
? 'Input'
: 'Confirm';
const handleOverlayMouseDown = (e: React.MouseEvent) => {
mouseDownOnOverlay.current = e.target === e.currentTarget;
@@ -106,11 +150,11 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && mouseDownOnOverlay.current) {
close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice) ? null : false);
close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice || isChordDetection) ? null : false);
}
};
const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice) ? null : false);
const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice || isChordDetection) ? null : false);
const handleConfirm = () => {
if (isAlert) { close(undefined); return; }
@@ -119,9 +163,20 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
close({ numerator: Number(timeSigNumerator), denominator: Number(timeSigDenominator) });
return;
}
if (isChordDetection) {
close(chordDetectionOptions);
return;
}
close(true);
};
const updateChordDetectionOption = <K extends keyof ChordDetectionOptionsResult>(
key: K,
value: ChordDetectionOptionsResult[K],
) => {
setChordDetectionOptions(current => ({ ...current, [key]: value }));
};
return (
<>
{children}
@@ -181,6 +236,68 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
/>
</div>
)}
{isChordDetection && (
<div className="dialog-chord-detection-form">
<div className="dialog-slider-group">
<div className="dialog-slider-header">
<label className="dialog-slider-label" htmlFor="dialog-chord-sensitivity">Sensitivity</label>
<span className="dialog-slider-value">{chordDetectionOptions.sensitivity}</span>
</div>
<input
id="dialog-chord-sensitivity"
className="dialog-slider"
type="range"
min={0}
max={100}
step={1}
value={chordDetectionOptions.sensitivity}
onChange={(e) => updateChordDetectionOption('sensitivity', Number(e.target.value))}
autoFocus
/>
</div>
<div className="dialog-slider-group">
<div className="dialog-slider-header">
<label className="dialog-slider-label" htmlFor="dialog-chord-stability">Stability</label>
<span className="dialog-slider-value">{chordDetectionOptions.stability}</span>
</div>
<input
id="dialog-chord-stability"
className="dialog-slider"
type="range"
min={0}
max={100}
step={1}
value={chordDetectionOptions.stability}
onChange={(e) => updateChordDetectionOption('stability', Number(e.target.value))}
/>
</div>
<div className="dialog-slider-group">
<div className="dialog-slider-header">
<label className="dialog-slider-label" htmlFor="dialog-chord-no-chord-threshold">No-Chord Threshold</label>
<span className="dialog-slider-value">{chordDetectionOptions.noChordThreshold}</span>
</div>
<input
id="dialog-chord-no-chord-threshold"
className="dialog-slider"
type="range"
min={0}
max={100}
step={1}
value={chordDetectionOptions.noChordThreshold}
onChange={(e) => updateChordDetectionOption('noChordThreshold', Number(e.target.value))}
/>
</div>
<label className="dialog-checkbox-row" htmlFor="dialog-enable-sevenths">
<input
id="dialog-enable-sevenths"
type="checkbox"
checked={chordDetectionOptions.enableSevenths}
onChange={(e) => updateChordDetectionOption('enableSevenths', e.target.checked)}
/>
<span>Chord Detail: Enable sevenths</span>
</label>
</div>
)}
</div>
<div className="dialog-footer">
{!isAlert && (
@@ -206,9 +323,9 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
<button
className="dialog-btn dialog-btn-primary"
onClick={handleConfirm}
autoFocus={!isPrompt && !isTimeSig}
autoFocus={!isPrompt && !isTimeSig && !isChordDetection}
>
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : 'Yes'))}
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : isChordDetection ? 'Detect' : 'Yes'))}
</button>
)}
</div>
+2 -2
View File
@@ -6,5 +6,5 @@ export { default as OpenProjectModal } from './OpenProjectModal';
export { default as DialogProvider } from './DialogProvider';
export { default as TrackCreateDialog } from './TrackCreateDialog';
export { default as FloatingPopup } from './FloatingPopup';
export { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../../util/dialogUtil';
export type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
export { showAlert, showChordDetectionOptions, showConfirm, showPrompt, showTimeSigPrompt } from '../../util/dialogUtil';
export type { ChordDetectionOptionsResult, ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
+12 -1
View File
@@ -20,14 +20,16 @@ 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 } from '../../util/dialogUtil';
import { showAlert, showChordDetectionOptions } from '../../util/dialogUtil';
import {
normalizeSpectrogramHeightResolution,
type SpectrogramHeightResolution,
} from '../../util/spectrogramUtil';
import {
DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS,
buildAudioChordWindowsForRegion,
type AudioChordDetectionRequest,
type AudioChordDetectionOptions,
type DetectedAudioChord,
} from '../../util/audioChordDetection';
import type { AudioChordDetectionWorkerMessage } from '../../workers/audioChordDetectionWorker';
@@ -460,6 +462,14 @@ const PianoRoll: React.FC<PianoRollProps> = ({
return;
}
const detectionOptions = await showChordDetectionOptions(
'Tune chord detection settings before processing.',
DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS,
);
if (!detectionOptions) {
return;
}
setIsDetectingChords(true);
setDetectChordProgressPercent(0);
let worker: Worker | null = null;
@@ -485,6 +495,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
sampleRate: audioBuffer.sampleRate,
clipStartOffsetSeconds: audioRegion.getClipStartOffsetSeconds(),
windows,
options: detectionOptions as AudioChordDetectionOptions,
};
const detectedChords = await new Promise<DetectedAudioChord[]>((resolve, reject) => {