feat: added tempo detection with auto-align beats feature

This commit is contained in:
Xiaohan-Tian
2026-05-26 22:45:54 -07:00
parent e82c3cbfc2
commit 7fc5ac1590
21 changed files with 1504 additions and 36 deletions
+173 -1
View File
@@ -2,7 +2,7 @@ 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';
import { showChoice, showChordDetectionOptions, showTempoApply, showTempoDetectionOptions } from '../../util/dialogUtil';
function finishDialogCloseAnimation() {
const overlay = document.querySelector('.dialog-overlay');
@@ -84,3 +84,175 @@ describe('DialogProvider chord detection dialog', () => {
}));
});
});
describe('DialogProvider tempo detection dialog', () => {
it('opens the tempo detection modal with the expected defaults and resolves cancel to null', async () => {
let resolved: unknown = 'pending';
render(
<DialogProvider>
<button
type="button"
onClick={async () => {
resolved = await showTempoDetectionOptions('Tune audio tempo detection settings before processing.', {
minTempo: 80,
maxTempo: 180,
});
}}
>
Open tempo
</button>
</DialogProvider>,
);
fireEvent.click(screen.getByRole('button', { name: 'Open tempo' }));
expect(screen.getByText('Tempo Detection')).toBeInTheDocument();
expect(screen.getByLabelText('Minimum BPM')).toHaveValue(80);
expect(screen.getByLabelText('Maximum BPM')).toHaveValue(180);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
finishDialogCloseAnimation();
await waitFor(() => expect(resolved).toBeNull());
});
it('returns the adjusted tempo detection options when confirmed', async () => {
let resolved: unknown = null;
render(
<DialogProvider>
<button
type="button"
onClick={async () => {
resolved = await showTempoDetectionOptions('Tune audio tempo detection settings before processing.', {
minTempo: 80,
maxTempo: 180,
});
}}
>
Open tempo
</button>
</DialogProvider>,
);
fireEvent.click(screen.getByRole('button', { name: 'Open tempo' }));
fireEvent.change(screen.getByLabelText('Minimum BPM'), { target: { value: '96' } });
fireEvent.change(screen.getByLabelText('Maximum BPM'), { target: { value: '154' } });
fireEvent.click(screen.getByRole('button', { name: 'Detect' }));
finishDialogCloseAnimation();
await waitFor(() => expect(resolved).toEqual({
minTempo: 96,
maxTempo: 154,
}));
});
});
describe('DialogProvider choice dialog', () => {
it('renders the supplied choice labels and resolves the selected action', async () => {
let resolved: unknown = null;
render(
<DialogProvider>
<button
type="button"
onClick={async () => {
resolved = await showChoice(
'Detected tempo: 128 BPM. Choose how to apply it.',
[
{ label: 'Update Current Tempo', value: 'update-current-tempo' },
{ label: 'Insert Tempo Change', value: 'insert-tempo-change' },
],
);
}}
>
Open choice
</button>
</DialogProvider>,
);
fireEvent.click(screen.getByRole('button', { name: 'Open choice' }));
expect(screen.getByText('Detected tempo: 128 BPM. Choose how to apply it.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Update Current Tempo' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Insert Tempo Change' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Insert Tempo Change' }));
finishDialogCloseAnimation();
await waitFor(() => expect(resolved).toBe('insert-tempo-change'));
});
});
describe('DialogProvider tempo apply dialog', () => {
it('renders actions and resolves cancel to null', async () => {
let resolved: unknown = 'pending';
render(
<DialogProvider>
<button
type="button"
onClick={async () => {
resolved = await showTempoApply(
'Detected tempo: 128 BPM. Choose how to apply it.',
[
{ label: 'Update Current Tempo', value: 'update-current-tempo' },
{ label: 'Insert Tempo Change', value: 'insert-tempo-change' },
],
);
}}
>
Open apply
</button>
</DialogProvider>,
);
fireEvent.click(screen.getByRole('button', { name: 'Open apply' }));
expect(screen.getByText('Apply Tempo')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Update Current Tempo' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Insert Tempo Change' })).toBeInTheDocument();
expect(screen.getByLabelText('Auto-align region to beat')).not.toBeChecked();
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
finishDialogCloseAnimation();
await waitFor(() => expect(resolved).toBeNull());
});
it('returns selected action plus auto-align checkbox state', async () => {
let resolved: unknown = null;
render(
<DialogProvider>
<button
type="button"
onClick={async () => {
resolved = await showTempoApply(
'Detected tempo: 128 BPM. Choose how to apply it.',
[
{ label: 'Update Current Tempo', value: 'update-current-tempo' },
{ label: 'Insert Tempo Change', value: 'insert-tempo-change' },
],
);
}}
>
Open apply
</button>
</DialogProvider>,
);
fireEvent.click(screen.getByRole('button', { name: 'Open apply' }));
fireEvent.click(screen.getByLabelText('Auto-align region to beat'));
fireEvent.click(screen.getByRole('button', { name: 'Insert Tempo Change' }));
finishDialogCloseAnimation();
await waitFor(() => expect(resolved).toEqual({
action: 'insert-tempo-change',
autoAlignRegionToBeat: true,
}));
});
});
+135 -7
View File
@@ -8,11 +8,13 @@ import type {
ConfirmOptions,
MidiChordDetectionOptionsResult,
PromptOptions,
TempoApplyResult,
TempoDetectionOptionsResult,
TimeSigResult,
} from '../../util/dialogUtil';
interface DialogInfo {
type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice' | 'chord-detection' | 'midi-chord-detection';
type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice' | 'chord-detection' | 'midi-chord-detection' | 'tempo-detection' | 'tempo-apply';
message: string;
options?: ConfirmOptions | PromptOptions;
defaultValue?: string;
@@ -20,6 +22,7 @@ interface DialogInfo {
choices?: ChoiceOption[];
defaultChordDetectionOptions?: ChordDetectionOptionsResult;
defaultMidiChordDetectionOptions?: MidiChordDetectionOptionsResult;
defaultTempoDetectionOptions?: TempoDetectionOptionsResult;
}
const DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS: ChordDetectionOptionsResult = {
@@ -35,6 +38,11 @@ const DEFAULT_MIDI_CHORD_DETECTION_OPTIONS: MidiChordDetectionOptionsResult = {
harmonicFocus: 'favor-sustained-notes',
};
const DEFAULT_TEMPO_DETECTION_OPTIONS: TempoDetectionOptionsResult = {
minTempo: 80,
maxTempo: 180,
};
const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [dialog, setDialog] = useState<DialogInfo | null>(null);
const [isClosing, setIsClosing] = useState(false);
@@ -43,6 +51,8 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const [timeSigDenominator, setTimeSigDenominator] = useState('');
const [chordDetectionOptions, setChordDetectionOptions] = useState<ChordDetectionOptionsResult>(DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS);
const [midiChordDetectionOptions, setMidiChordDetectionOptions] = useState<MidiChordDetectionOptionsResult>(DEFAULT_MIDI_CHORD_DETECTION_OPTIONS);
const [tempoDetectionOptions, setTempoDetectionOptions] = useState<TempoDetectionOptionsResult>(DEFAULT_TEMPO_DETECTION_OPTIONS);
const [autoAlignRegionToBeat, setAutoAlignRegionToBeat] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolveRef = useRef<((value: any) => void) | null>(null);
const pendingValueRef = useRef<unknown>(undefined);
@@ -107,6 +117,28 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
});
}, []);
const openTempoDetectionOptions = useCallback((
message: string,
defaultValue?: TempoDetectionOptionsResult,
): Promise<TempoDetectionOptionsResult | null> => {
return new Promise<TempoDetectionOptionsResult | null>((resolve) => {
resolveRef.current = resolve;
setTempoDetectionOptions(defaultValue ?? DEFAULT_TEMPO_DETECTION_OPTIONS);
setDialog({ type: 'tempo-detection', message, defaultTempoDetectionOptions: defaultValue });
});
}, []);
const openTempoApply = useCallback((
message: string,
choices: ChoiceOption[],
): Promise<TempoApplyResult | null> => {
return new Promise<TempoApplyResult | null>((resolve) => {
resolveRef.current = resolve;
setAutoAlignRegionToBeat(false);
setDialog({ type: 'tempo-apply', message, choices });
});
}, []);
const close = useCallback((value: unknown) => {
pendingValueRef.current = value;
setIsClosing(true);
@@ -122,6 +154,8 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
setTimeSigDenominator('');
setChordDetectionOptions(DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS);
setMidiChordDetectionOptions(DEFAULT_MIDI_CHORD_DETECTION_OPTIONS);
setTempoDetectionOptions(DEFAULT_TEMPO_DETECTION_OPTIONS);
setAutoAlignRegionToBeat(false);
if (resolveRef.current) {
resolveRef.current(pendingValueRef.current);
resolveRef.current = null;
@@ -133,7 +167,17 @@ 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, openMidiChordDetectionOptions);
registerDialogFns(
openAlert,
openConfirm,
openPrompt,
openTimeSig,
openChoice,
openChordDetectionOptions,
openMidiChordDetectionOptions,
openTempoDetectionOptions,
openTempoApply,
);
}
if (!dialog) {
@@ -146,13 +190,19 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const isChoice = dialog.type === 'choice';
const isChordDetection = dialog.type === 'chord-detection';
const isMidiChordDetection = dialog.type === 'midi-chord-detection';
const isTempoDetection = dialog.type === 'tempo-detection';
const isTempoApply = dialog.type === 'tempo-apply';
const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined;
const title = isAlert
? 'Notice'
: isTimeSig
? 'Time Signature'
: (isChordDetection || isMidiChordDetection)
: isTempoDetection
? 'Tempo Detection'
: isTempoApply
? 'Apply Tempo'
: (isChordDetection || isMidiChordDetection)
? 'Chord Detection'
: isPrompt
? 'Input'
@@ -164,11 +214,11 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && mouseDownOnOverlay.current) {
close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice || isChordDetection) ? null : false);
close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice || isChordDetection || isTempoDetection || isTempoApply) ? null : false);
}
};
const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice || isChordDetection) ? null : false);
const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice || isChordDetection || isTempoDetection || isTempoApply) ? null : false);
const handleConfirm = () => {
if (isAlert) { close(undefined); return; }
@@ -185,6 +235,17 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
close(midiChordDetectionOptions);
return;
}
if (isTempoDetection) {
close(tempoDetectionOptions);
return;
}
if (isTempoApply) {
close({
action: dialog.choices?.[dialog.choices.length - 1]?.value ?? '',
autoAlignRegionToBeat,
} satisfies TempoApplyResult);
return;
}
close(true);
};
@@ -202,6 +263,13 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
setMidiChordDetectionOptions(current => ({ ...current, [key]: value }));
};
const updateTempoDetectionOption = <K extends keyof TempoDetectionOptionsResult>(
key: K,
value: TempoDetectionOptionsResult[K],
) => {
setTempoDetectionOptions(current => ({ ...current, [key]: value }));
};
return (
<>
{children}
@@ -366,6 +434,55 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
</label>
</div>
)}
{isTempoDetection && (
<div className="dialog-chord-detection-form">
<div className="dialog-slider-group">
<div className="dialog-slider-header">
<label className="dialog-slider-label" htmlFor="dialog-tempo-min-tempo">Minimum BPM</label>
</div>
<input
id="dialog-tempo-min-tempo"
className="dialog-input"
type="number"
min={40}
max={240}
step={1}
value={tempoDetectionOptions.minTempo}
onChange={(e) => updateTempoDetectionOption('minTempo', Number(e.target.value))}
autoFocus
/>
</div>
<div className="dialog-slider-group">
<div className="dialog-slider-header">
<label className="dialog-slider-label" htmlFor="dialog-tempo-max-tempo">Maximum BPM</label>
</div>
<input
id="dialog-tempo-max-tempo"
className="dialog-input"
type="number"
min={40}
max={240}
step={1}
value={tempoDetectionOptions.maxTempo}
onChange={(e) => updateTempoDetectionOption('maxTempo', Number(e.target.value))}
/>
</div>
</div>
)}
{isTempoApply && (
<div className="dialog-chord-detection-form">
<label className="dialog-checkbox-row" htmlFor="dialog-tempo-auto-align">
<input
id="dialog-tempo-auto-align"
type="checkbox"
checked={autoAlignRegionToBeat}
onChange={(e) => setAutoAlignRegionToBeat(e.target.checked)}
autoFocus
/>
<span>Auto-align region to beat</span>
</label>
</div>
)}
</div>
<div className="dialog-footer">
{!isAlert && (
@@ -387,13 +504,24 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
{choice.label}
</button>
))
) : isTempoApply ? (
dialog.choices?.map((choice, i) => (
<button
key={choice.value}
className={`dialog-btn ${i === (dialog.choices!.length - 1) ? 'dialog-btn-primary' : 'dialog-btn-secondary'}`}
onClick={() => close({ action: choice.value, autoAlignRegionToBeat } satisfies TempoApplyResult)}
autoFocus={i === dialog.choices!.length - 1}
>
{choice.label}
</button>
))
) : (
<button
className="dialog-btn dialog-btn-primary"
onClick={handleConfirm}
autoFocus={!isPrompt && !isTimeSig && !isChordDetection && !isMidiChordDetection}
autoFocus={!isPrompt && !isTimeSig && !isChordDetection && !isMidiChordDetection && !isTempoDetection && !isTempoApply}
>
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : (isChordDetection || isMidiChordDetection) ? 'Detect' : 'Yes'))}
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : (isChordDetection || isMidiChordDetection || isTempoDetection) ? '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, showChordDetectionOptions, showConfirm, showPrompt, showTimeSigPrompt } from '../../util/dialogUtil';
export type { ChordDetectionOptionsResult, ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
export { showAlert, showChordDetectionOptions, showConfirm, showPrompt, showTempoApply, showTempoDetectionOptions, showTimeSigPrompt } from '../../util/dialogUtil';
export type { ChordDetectionOptionsResult, ConfirmOptions, PromptOptions, TempoApplyResult, TempoDetectionOptionsResult, TimeSigResult } from '../../util/dialogUtil';
+92 -2
View File
@@ -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, showMidiChordDetectionOptions } from '../../util/dialogUtil';
import { showAlert, showChordDetectionOptions, showMidiChordDetectionOptions, showTempoApply, showTempoDetectionOptions } from '../../util/dialogUtil';
import {
normalizeSpectrogramHeightResolution,
type SpectrogramHeightResolution,
@@ -32,6 +32,18 @@ import {
type AudioChordDetectionOptions,
type DetectedAudioChord,
} from '../../util/audioChordDetection';
import {
buildAudioTempoAnalysisSpanForRegion,
DEFAULT_AUDIO_TEMPO_DETECTION_OPTIONS,
detectTempoFromAudio,
type AudioTempoDetectionOptions,
} from '../../util/audioTempoDetection';
import {
applyDetectedTempoAction,
buildDetectedTempoChoiceMessage,
DETECTED_TEMPO_ACTION_INSERT_REGION,
DETECTED_TEMPO_ACTION_UPDATE_CURRENT,
} from '../../util/audioTempoDetectionActions';
import {
DEFAULT_MIDI_CHORD_DETECTION_OPTIONS,
buildMidiChordWindowsForRegion,
@@ -77,7 +89,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}) => {
const isSpectrogram = mode === 'spectrogram';
const isHybrid = mode === 'hybrid';
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showEventListPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, automationRedrawVersion, refreshProjectState } = useProjectStore();
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showEventListPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, automationRedrawVersion, refreshProjectState, setBpm } = useProjectStore();
// Tool state for piano roll
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
@@ -89,6 +101,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
useState<SpectrogramHeightResolution>(3);
const [isDetectingChords, setIsDetectingChords] = useState(false);
const [detectChordProgressPercent, setDetectChordProgressPercent] = useState(0);
const [isDetectingTempo, setIsDetectingTempo] = useState(false);
// Piano roll zoom (1x8x); updates --region-grid-beat-width CSS variable
const [pianoRollZoom, setPianoRollZoom] = useState<number>(() => KGPianoRollState.instance().getPianoRollZoom());
@@ -580,6 +593,81 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}
}, [activeRegion, audioRegion, projectName, refreshProjectState, trackId]);
const handleDetectTempo = useCallback(async () => {
if (!audioRegion) {
await showAlert('Open an audio region before detecting tempo.');
return;
}
const project = KGCore.instance().getCurrentProject();
const analysisSpan = buildAudioTempoAnalysisSpanForRegion(project, audioRegion);
if (!analysisSpan) {
await showAlert('The selected audio region has no audible span to analyze.');
return;
}
const detectionOptions = await showTempoDetectionOptions(
'Tune audio tempo detection settings before processing.',
DEFAULT_AUDIO_TEMPO_DETECTION_OPTIONS,
);
if (!detectionOptions) {
return;
}
if (!projectName || !trackId) {
await showAlert('Open an audio region in spectrogram mode before detecting tempo.');
return;
}
setIsDetectingTempo(true);
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 result = await detectTempoFromAudio(
audioBuffer,
analysisSpan,
detectionOptions as AudioTempoDetectionOptions,
);
const applyResult = await showTempoApply(
buildDetectedTempoChoiceMessage(result.bpm),
[
{ label: 'Update Current Tempo', value: DETECTED_TEMPO_ACTION_UPDATE_CURRENT },
{ label: 'Insert Tempo Change', value: DETECTED_TEMPO_ACTION_INSERT_REGION },
],
);
if (!applyResult) {
return;
}
applyDetectedTempoAction({
action: applyResult.action as typeof DETECTED_TEMPO_ACTION_UPDATE_CURRENT | typeof DETECTED_TEMPO_ACTION_INSERT_REGION,
detectedBpm: result.bpm,
detectedTempo: result.tempo,
detectedOffsetSeconds: result.offsetSeconds,
autoAlignRegionToBeat: applyResult.autoAlignRegionToBeat,
project,
regionId: audioRegion.getId(),
regionStartBeat: audioRegion.getStartFromBeat(),
regionTrackId: audioRegion.getTrackId(),
regionTrackIndex: audioRegion.getTrackIndex(),
refreshProjectState,
setBpm,
});
} catch (error) {
console.error('Error detecting tempo:', error);
await showAlert('Failed to detect tempo from this audio region.');
} finally {
setIsDetectingTempo(false);
}
}, [audioRegion, projectName, refreshProjectState, setBpm, trackId]);
// Handle title click to rename the region
const handleTitleClick = () => {
// If we were just dragging, don't show the rename dialog
@@ -1461,6 +1549,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
onAutomationTypeChange={handleAutomationTypeChange}
onDetectChords={handleDetectChords}
detectingChords={isDetectingChords}
onDetectTempo={audioRegion ? handleDetectTempo : undefined}
detectingTempo={isDetectingTempo}
/>
<NoteAttributeBar selectedNotes={selectedNotes} isSpectrogram={isSpectrogram} activeRegion={activeRegion} />
@@ -137,6 +137,24 @@ describe('PianoRollToolbar', () => {
expect(onDetectChords).toHaveBeenCalledTimes(1);
});
it('shows the detect tempo action when the audio callback is provided and triggers it', () => {
const onDetectTempo = vi.fn();
render(
<PianoRollToolbar
{...baseProps}
mode="spectrogram"
showAutomationControls={false}
onDetectTempo={onDetectTempo}
/>
);
fireEvent.click(screen.getByTitle('More options'));
fireEvent.click(screen.getByText('Detect tempo...'));
expect(onDetectTempo).toHaveBeenCalledTimes(1);
});
it('shows the detect chords action in midi mode and triggers it', () => {
const onDetectChords = vi.fn();
@@ -175,6 +193,40 @@ describe('PianoRollToolbar', () => {
expect(onDetectChords).not.toHaveBeenCalled();
});
it('disables the detect tempo action while detection is running', () => {
const onDetectTempo = vi.fn();
render(
<PianoRollToolbar
{...baseProps}
mode="spectrogram"
showAutomationControls={false}
onDetectChords={undefined}
detectingTempo={true}
onDetectTempo={onDetectTempo}
/>
);
fireEvent.click(screen.getByTitle('More options'));
const detectItem = screen.getByText('Detecting tempo...');
expect(detectItem).toHaveAttribute('aria-disabled', 'true');
fireEvent.click(detectItem);
expect(onDetectTempo).not.toHaveBeenCalled();
});
it('does not show the detect tempo action without the audio callback', () => {
render(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
showAutomationControls={false}
/>
);
fireEvent.click(screen.getByTitle('More options'));
expect(screen.queryByText('Detect tempo...')).not.toBeInTheDocument();
});
it('shows only the sheet controls when sheet mode is enabled', () => {
render(
<PianoRollToolbar
+42 -21
View File
@@ -50,6 +50,8 @@ interface PianoRollToolbarProps {
onAutomationTypeChange?: (value: PianoRollAutomationType) => void;
onDetectChords?: () => void | Promise<void>;
detectingChords?: boolean;
onDetectTempo?: () => void | Promise<void>;
detectingTempo?: boolean;
}
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
@@ -86,10 +88,12 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
onAutomationTypeChange,
onDetectChords,
detectingChords = false,
onDetectTempo,
detectingTempo = false,
}) => {
const showMidiControls = mode !== 'spectrogram' && !sheetMusicViewEnabled; // midi-edit and hybrid
const showSpecControls = !sheetMusicViewEnabled && (mode === 'spectrogram' || mode === 'hybrid');
const showDetectChordMenu = !sheetMusicViewEnabled && !!onDetectChords;
const showSpecMenu = !sheetMusicViewEnabled && (!!onDetectChords || !!onDetectTempo);
const [showZoomSlider, setShowZoomSlider] = React.useState(false);
const zoomSliderRef = React.useRef<HTMLDivElement>(null);
@@ -105,19 +109,19 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showZoomSlider]);
const [showSpecMenu, setShowSpecMenu] = React.useState(false);
const [showMoreMenu, setShowMoreMenu] = React.useState(false);
const specMenuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!showSpecMenu) return;
if (!showMoreMenu) return;
const handleClickOutside = (e: MouseEvent) => {
if (specMenuRef.current && !specMenuRef.current.contains(e.target as Node)) {
setShowSpecMenu(false);
setShowMoreMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showSpecMenu]);
}, [showMoreMenu]);
return (
<div className="piano-roll-toolbar">
@@ -300,30 +304,47 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
</div>
)}
{showDetectChordMenu && (
{showSpecMenu && (
<div className="quant-dropdown-container" ref={specMenuRef}>
<button
className="quant-button"
onClick={() => setShowSpecMenu(!showSpecMenu)}
onClick={() => setShowMoreMenu(!showMoreMenu)}
title="More options"
>
...
</button>
{showSpecMenu && (
{showMoreMenu && (
<div className="quant-dropdown" style={{ right: 0, left: 'auto', width: 'auto', whiteSpace: 'nowrap' }}>
<div
className={`quant-option${(!onDetectChords || detectingChords) ? ' disabled' : ''}`}
onClick={() => {
if (!onDetectChords || detectingChords) {
return;
}
setShowSpecMenu(false);
void onDetectChords();
}}
aria-disabled={!onDetectChords || detectingChords}
>
{detectingChords ? 'Detecting chords...' : 'Detect chords...'}
</div>
{onDetectChords && (
<div
className={`quant-option${detectingChords ? ' disabled' : ''}`}
onClick={() => {
if (detectingChords) {
return;
}
setShowMoreMenu(false);
void onDetectChords();
}}
aria-disabled={detectingChords}
>
{detectingChords ? 'Detecting chords...' : 'Detect chords...'}
</div>
)}
{onDetectTempo && (
<div
className={`quant-option${detectingTempo ? ' disabled' : ''}`}
onClick={() => {
if (detectingTempo) {
return;
}
setShowMoreMenu(false);
void onDetectTempo();
}}
aria-disabled={detectingTempo}
>
{detectingTempo ? 'Detecting tempo...' : 'Detect tempo...'}
</div>
)}
</div>
)}
</div>