feat: added tempo detection with auto-align beats feature
This commit is contained in:
@@ -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 (1x–8x); 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user