feat: added v1 Meyda.js based chord detection feature
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user