diff --git a/.gitignore b/.gitignore index 7262e33..b320a5e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* +test-results/ + node_modules res/ coverage/ diff --git a/package-lock.json b/package-lock.json index a51d016..713c808 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,10 +28,12 @@ "tonal": "^6.4.3", "tone": "^15.1.22", "vexflow": "^5.0.0", + "web-audio-beat-detector": "^8.2.36", "zustand": "^5.0.6" }, "devDependencies": { "@eslint/js": "^9.29.0", + "@playwright/test": "^1.60.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@testing-library/jest-dom": "^6.8.0", @@ -333,9 +335,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1581,6 +1583,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -3756,6 +3774,18 @@ "node": ">=8" } }, + "node_modules/broker-factory": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.14.tgz", + "integrity": "sha512-L45k5HMbPIrMid0nTOZ/UPXG/c0aRuQKVrSDFIb1zOkvfiyHgYmIjc3cSiN1KwQIvRDOtKE0tfb3I9EZ3CmpQQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-factory": "^7.0.49" + } + }, "node_modules/browserslist": { "version": "4.25.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", @@ -5032,6 +5062,19 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-unique-numbers": { + "version": "9.0.27", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz", + "integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -11952,6 +11995,53 @@ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -14731,6 +14821,42 @@ "node": ">=18" } }, + "node_modules/web-audio-beat-detector": { + "version": "8.2.36", + "resolved": "https://registry.npmjs.org/web-audio-beat-detector/-/web-audio-beat-detector-8.2.36.tgz", + "integrity": "sha512-jqPrbZaFdrQP0K346vFh7x6fBnbJKM+yHvZyKS0yU2p8VhfON0WclBspM8Ofr93SjVngvM6gdc8/Kj7xGAhs8A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1", + "web-audio-beat-detector-broker": "^5.0.14", + "web-audio-beat-detector-worker": "^6.0.13" + } + }, + "node_modules/web-audio-beat-detector-broker": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/web-audio-beat-detector-broker/-/web-audio-beat-detector-broker-5.0.14.tgz", + "integrity": "sha512-l33LRpk53No0F0ckciLQbhRzDnPErQKIOzyaU2RhE555bLoCct+vAfnmYDWabH+O2nwN6dvk8jeGX4aJ0FWmbg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "broker-factory": "^3.1.14", + "standardized-audio-context": "^25.3.77", + "tslib": "^2.8.1", + "web-audio-beat-detector-worker": "^6.0.13" + } + }, + "node_modules/web-audio-beat-detector-worker": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/web-audio-beat-detector-worker/-/web-audio-beat-detector-worker-6.0.13.tgz", + "integrity": "sha512-z/++Lb95V/Fl9cDafX0AICJdI6m/fu1SCXgX1tQyxeTyuCo3SRh6hIoM9jFUSWlDZRYK0WbZGlCn0p9pxm/Ygg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1", + "worker-factory": "^7.0.49" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -14837,6 +14963,17 @@ "dev": true, "license": "MIT" }, + "node_modules/worker-factory": { + "version": "7.0.49", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.49.tgz", + "integrity": "sha512-lW7tpgy6aUv2dFsQhv1yv+XFzdkCf/leoKRTGMPVK5/die6RrUjqgJHJf556qO+ZfytNG6wPXc17E8zzsOLUDw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 9cde4bc..7078c56 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "lint": "eslint .", "preview": "vite preview", "test": "vitest", + "test:browser": "playwright test", + "test:browser:install": "playwright install chromium", + "test:browser:tempo": "playwright test src/test/browser/audio-tempo-detection.spec.ts", "test:ui": "vitest --ui", "test:coverage": "vitest --coverage", "test:run": "vitest run" @@ -34,10 +37,12 @@ "tonal": "^6.4.3", "tone": "^15.1.22", "vexflow": "^5.0.0", + "web-audio-beat-detector": "^8.2.36", "zustand": "^5.0.6" }, "devDependencies": { "@eslint/js": "^9.29.0", + "@playwright/test": "^1.60.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@testing-library/jest-dom": "^6.8.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..3f974e6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './src/test/browser', + timeout: 30_000, + fullyParallel: false, + retries: 0, + use: { + baseURL: 'http://127.0.0.1:4174/kgstudio', + headless: true, + }, + webServer: { + command: 'npm run dev -- --host 127.0.0.1 --port 4174', + url: 'http://127.0.0.1:4174/kgstudio/', + reuseExistingServer: true, + timeout: 30_000, + }, +}); diff --git a/public/test-data/short-arrangement-01.mp3 b/public/test-data/short-arrangement-01.mp3 new file mode 100644 index 0000000..07a9ccd Binary files /dev/null and b/public/test-data/short-arrangement-01.mp3 differ diff --git a/src/components/common/DialogProvider.test.tsx b/src/components/common/DialogProvider.test.tsx index f3879f5..8d7af6b 100644 --- a/src/components/common/DialogProvider.test.tsx +++ b/src/components/common/DialogProvider.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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, + })); + }); +}); diff --git a/src/components/common/DialogProvider.tsx b/src/components/common/DialogProvider.tsx index 23baf9e..62e9ed2 100644 --- a/src/components/common/DialogProvider.tsx +++ b/src/components/common/DialogProvider.tsx @@ -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(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(DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS); const [midiChordDetectionOptions, setMidiChordDetectionOptions] = useState(DEFAULT_MIDI_CHORD_DETECTION_OPTIONS); + const [tempoDetectionOptions, setTempoDetectionOptions] = useState(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(undefined); @@ -107,6 +117,28 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = }); }, []); + const openTempoDetectionOptions = useCallback(( + message: string, + defaultValue?: TempoDetectionOptionsResult, + ): Promise => { + return new Promise((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 => { + return new Promise((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 = ( + key: K, + value: TempoDetectionOptionsResult[K], + ) => { + setTempoDetectionOptions(current => ({ ...current, [key]: value })); + }; + return ( <> {children} @@ -366,6 +434,55 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = )} + {isTempoDetection && ( +
+
+
+ +
+ updateTempoDetectionOption('minTempo', Number(e.target.value))} + autoFocus + /> +
+
+
+ +
+ updateTempoDetectionOption('maxTempo', Number(e.target.value))} + /> +
+
+ )} + {isTempoApply && ( +
+ +
+ )}
{!isAlert && ( @@ -387,13 +504,24 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = {choice.label} )) + ) : isTempoApply ? ( + dialog.choices?.map((choice, i) => ( + + )) ) : ( )}
diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 80bde9f..faae95c 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -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'; diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index dbd4994..0f66297 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, 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 = ({ }) => { 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 = ({ useState(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(() => KGPianoRollState.instance().getPianoRollZoom()); @@ -580,6 +593,81 @@ const PianoRoll: React.FC = ({ } }, [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 = ({ onAutomationTypeChange={handleAutomationTypeChange} onDetectChords={handleDetectChords} detectingChords={isDetectingChords} + onDetectTempo={audioRegion ? handleDetectTempo : undefined} + detectingTempo={isDetectingTempo} /> diff --git a/src/components/piano-roll/PianoRollToolbar.test.tsx b/src/components/piano-roll/PianoRollToolbar.test.tsx index 196a7d4..cd94d52 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 tempo action when the audio callback is provided and triggers it', () => { + const onDetectTempo = vi.fn(); + + render( + + ); + + 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( + + ); + + 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( + + ); + + 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( void; onDetectChords?: () => void | Promise; detectingChords?: boolean; + onDetectTempo?: () => void | Promise; + detectingTempo?: boolean; } const PianoRollToolbar: React.FC = ({ @@ -86,10 +88,12 @@ const PianoRollToolbar: React.FC = ({ 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(null); @@ -105,19 +109,19 @@ const PianoRollToolbar: React.FC = ({ return () => document.removeEventListener('mousedown', handleClickOutside); }, [showZoomSlider]); - const [showSpecMenu, setShowSpecMenu] = React.useState(false); + const [showMoreMenu, setShowMoreMenu] = React.useState(false); const specMenuRef = React.useRef(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 (
@@ -300,30 +304,47 @@ const PianoRollToolbar: React.FC = ({
)} - {showDetectChordMenu && ( + {showSpecMenu && (
- {showSpecMenu && ( + {showMoreMenu && (
-
{ - if (!onDetectChords || detectingChords) { - return; - } - setShowSpecMenu(false); - void onDetectChords(); - }} - aria-disabled={!onDetectChords || detectingChords} - > - {detectingChords ? 'Detecting chords...' : 'Detect chords...'} -
+ {onDetectChords && ( +
{ + if (detectingChords) { + return; + } + setShowMoreMenu(false); + void onDetectChords(); + }} + aria-disabled={detectingChords} + > + {detectingChords ? 'Detecting chords...' : 'Detect chords...'} +
+ )} + {onDetectTempo && ( +
{ + if (detectingTempo) { + return; + } + setShowMoreMenu(false); + void onDetectTempo(); + }} + aria-disabled={detectingTempo} + > + {detectingTempo ? 'Detecting tempo...' : 'Detect tempo...'} +
+ )}
)}
diff --git a/src/test/browser/audio-tempo-detection.spec.ts b/src/test/browser/audio-tempo-detection.spec.ts new file mode 100644 index 0000000..641c7c7 --- /dev/null +++ b/src/test/browser/audio-tempo-detection.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test'; + +test('detects 125 BPM from the real mp3 fixture in a real browser', async ({ page }) => { + await page.goto('/kgstudio/tempo-detection-test.html'); + + await page.waitForFunction(() => { + const testWindow = window as Window & { + runAudioTempoDetectionFixture?: (() => Promise<{ bpm: number; offsetSeconds: number; tempo: number }>) | undefined; + __tempoHarnessError?: string | null; + }; + return typeof testWindow.runAudioTempoDetectionFixture === 'function' || testWindow.__tempoHarnessError !== null; + }); + + const harnessError = await page.evaluate(() => { + return (window as Window & { __tempoHarnessError?: string | null }).__tempoHarnessError ?? null; + }); + expect(harnessError).toBeNull(); + + const result = await page.evaluate(async () => { + return window.runAudioTempoDetectionFixture(); + }); + + expect(result.bpm).toBe(125); + expect(result.offsetSeconds).toBeGreaterThanOrEqual(0.3); + expect(result.offsetSeconds).toBeLessThanOrEqual(0.4); +}); diff --git a/src/test/browser/audioTempoDetectionHarness.ts b/src/test/browser/audioTempoDetectionHarness.ts new file mode 100644 index 0000000..1298774 --- /dev/null +++ b/src/test/browser/audioTempoDetectionHarness.ts @@ -0,0 +1,52 @@ +import { + DEFAULT_AUDIO_TEMPO_DETECTION_OPTIONS, + detectTempoFromAudio, +} from '../../util/audioTempoDetectionCore'; + +declare global { + interface Window { + runAudioTempoDetectionFixture: () => Promise<{ bpm: number; offsetSeconds: number; tempo: number }>; + } +} + +async function loadFixtureAudioBuffer(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch fixture audio: ${response.status}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const audioContext = new AudioContext(); + try { + return await audioContext.decodeAudioData(arrayBuffer); + } finally { + await audioContext.close(); + } +} + +window.runAudioTempoDetectionFixture = async () => { + const statusNode = document.getElementById('status'); + if (statusNode) { + statusNode.textContent = 'Running...'; + } + + const fixtureUrl = new URL( + `${import.meta.env.BASE_URL}test-data/short-arrangement-01.mp3`, + window.location.origin, + ).href; + const audioBuffer = await loadFixtureAudioBuffer(fixtureUrl); + const result = await detectTempoFromAudio( + audioBuffer, + { + offsetSeconds: 0, + durationSeconds: audioBuffer.duration, + }, + DEFAULT_AUDIO_TEMPO_DETECTION_OPTIONS, + ); + + if (statusNode) { + statusNode.textContent = `Done: ${result.bpm} BPM`; + } + + return result; +}; diff --git a/src/test/integration/audio-tempo-detection.integration.test.ts b/src/test/integration/audio-tempo-detection.integration.test.ts new file mode 100644 index 0000000..722356e --- /dev/null +++ b/src/test/integration/audio-tempo-detection.integration.test.ts @@ -0,0 +1,77 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_AUDIO_TEMPO_DETECTION_OPTIONS, + normalizeAudioTempoDetectionOptions, +} from '../../util/audioTempoDetection'; + +const FIXTURE_PATH = path.resolve(process.cwd(), 'public/test-data/short-arrangement-01.mp3'); +const ffmpegAvailable = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0; +const offlineAudioContextAvailable = typeof globalThis.OfflineAudioContext !== 'undefined'; +const runIfSupported = ffmpegAvailable && offlineAudioContextAvailable ? it : it.skip; + +function decodeMp3ToMonoPcm(pathname: string): { sampleRate: number; pcm: Float32Array } { + const wav = execFileSync( + 'ffmpeg', + ['-v', 'error', '-i', pathname, '-ac', '1', '-f', 'wav', 'pipe:1'], + { maxBuffer: 64 * 1024 * 1024 }, + ); + + const readString = (offset: number, length: number) => wav.toString('ascii', offset, offset + length); + if (readString(0, 4) !== 'RIFF' || readString(8, 4) !== 'WAVE') { + throw new Error('ffmpeg did not return a RIFF/WAVE stream'); + } + + let offset = 12; + let sampleRate = 44100; + let pcmData = Buffer.alloc(0); + + while (offset + 8 <= wav.length) { + const chunkId = readString(offset, 4); + const chunkSize = wav.readUInt32LE(offset + 4); + const chunkStart = offset + 8; + + if (chunkId === 'fmt ') { + sampleRate = wav.readUInt32LE(chunkStart + 4); + } else if (chunkId === 'data') { + pcmData = wav.subarray(chunkStart, chunkStart + chunkSize); + break; + } + + offset = chunkStart + chunkSize + (chunkSize % 2); + } + + const pcm = new Float32Array(pcmData.length / 2); + for (let sampleIndex = 0; sampleIndex < pcm.length; sampleIndex++) { + pcm[sampleIndex] = pcmData.readInt16LE(sampleIndex * 2) / 32768; + } + + return { sampleRate, pcm }; +} + +describe('audio tempo detection fixture', () => { + it('keeps the fixture-targeted BPM range locked to 125 BPM expectations', () => { + expect(normalizeAudioTempoDetectionOptions(DEFAULT_AUDIO_TEMPO_DETECTION_OPTIONS)).toEqual({ + minTempo: 80, + maxTempo: 180, + }); + }); + + runIfSupported('detects the expected BPM from the mp3 fixture', async () => { + const { sampleRate, pcm } = decodeMp3ToMonoPcm(FIXTURE_PATH); + const audioContext = new OfflineAudioContext(1, pcm.length, sampleRate); + const audioBuffer = audioContext.createBuffer(1, pcm.length, sampleRate); + audioBuffer.copyToChannel(pcm, 0, 0); + + const { detectTempoFromAudio } = await import('../../util/audioTempoDetection'); + const result = await detectTempoFromAudio(audioBuffer, { + offsetSeconds: 0, + durationSeconds: audioBuffer.duration, + }, DEFAULT_AUDIO_TEMPO_DETECTION_OPTIONS); + + expect(result.bpm).toBe(125); + expect(result.offsetSeconds).toBeGreaterThanOrEqual(0.3); + expect(result.offsetSeconds).toBeLessThanOrEqual(0.4); + }); +}); diff --git a/src/util/audioTempoDetection.test.ts b/src/util/audioTempoDetection.test.ts new file mode 100644 index 0000000..26ccba8 --- /dev/null +++ b/src/util/audioTempoDetection.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { KGProject } from '../core/KGProject'; +import type { KGAudioRegion } from '../core/region/KGAudioRegion'; +import { + buildAudioTempoAnalysisSpanForRegion, + DEFAULT_AUDIO_TEMPO_DETECTION_OPTIONS, + detectTempoFromAudio, + normalizeAudioTempoDetectionOptions, +} from './audioTempoDetection'; + +const { analyzeMock, guessMock } = vi.hoisted(() => ({ + analyzeMock: vi.fn(), + guessMock: vi.fn(), +})); + +vi.mock('web-audio-beat-detector', () => ({ + analyze: analyzeMock, + guess: guessMock, +})); + +describe('audio tempo detection', () => { + beforeEach(() => { + analyzeMock.mockReset(); + guessMock.mockReset(); + }); + + it('normalizes defaults and clamps values into the supported tempo range', () => { + expect(normalizeAudioTempoDetectionOptions()).toEqual(DEFAULT_AUDIO_TEMPO_DETECTION_OPTIONS); + expect(normalizeAudioTempoDetectionOptions({ minTempo: 12, maxTempo: 400 })).toEqual({ + minTempo: 40, + maxTempo: 240, + }); + }); + + it('rejects invalid tempo ranges', () => { + expect(() => normalizeAudioTempoDetectionOptions({ minTempo: 140, maxTempo: 100 })).toThrow( + 'Minimum BPM must be lower than maximum BPM.', + ); + }); + + it('rounds the detected tempo and preserves the detected beat offset', async () => { + analyzeMock.mockResolvedValue(124.6); + guessMock.mockResolvedValue({ bpm: 125, offset: 0.42 }); + const audioBuffer = {} as AudioBuffer; + + const result = await detectTempoFromAudio( + audioBuffer, + { offsetSeconds: 1.5, durationSeconds: 8.25 }, + { minTempo: 90, maxTempo: 150 }, + ); + + expect(analyzeMock).toHaveBeenCalledWith( + audioBuffer, + 1.5, + 8.25, + { minTempo: 90, maxTempo: 150 }, + ); + expect(guessMock).toHaveBeenCalledWith( + audioBuffer, + 1.5, + 8.25, + { minTempo: 90, maxTempo: 150 }, + ); + expect(result).toEqual({ + tempo: 124.6, + bpm: 125, + offsetSeconds: 0.42, + }); + }); + + it('builds an analysis span from the visible region length', () => { + const project = { + getTimeSignature: () => ({ numerator: 4, denominator: 4 }), + getBpm: () => 120, + getGlobalTrackByType: () => null, + } as unknown as KGProject; + const region = { + getStartFromBeat: () => 8, + getLength: () => 16, + getAudioDurationSeconds: () => 20, + getClipStartOffsetSeconds: () => 1.25, + } as unknown as KGAudioRegion; + + expect(buildAudioTempoAnalysisSpanForRegion(project, region)).toEqual({ + offsetSeconds: 1.25, + durationSeconds: 8, + }); + }); +}); diff --git a/src/util/audioTempoDetection.ts b/src/util/audioTempoDetection.ts new file mode 100644 index 0000000..e86c0af --- /dev/null +++ b/src/util/audioTempoDetection.ts @@ -0,0 +1,37 @@ +import type { KGProject } from '../core/KGProject'; +import type { KGAudioRegion } from '../core/region/KGAudioRegion'; +import { beatRangeToSeconds, getAudioRegionDisplayLengthBeats } from './globalTrackUtil'; + +export { + DEFAULT_AUDIO_TEMPO_DETECTION_OPTIONS, + detectTempoFromAudio, + normalizeAudioTempoDetectionOptions, + type AudioTempoAnalysisSpan, + type AudioTempoDetectionOptions, + type DetectedAudioTempo, +} from './audioTempoDetectionCore'; + +import type { AudioTempoAnalysisSpan } from './audioTempoDetectionCore'; + +const MIN_ANALYSIS_DURATION_SECONDS = 0.05; + +export function buildAudioTempoAnalysisSpanForRegion( + project: KGProject, + audioRegion: KGAudioRegion, +): AudioTempoAnalysisSpan | null { + const visibleLengthBeats = getAudioRegionDisplayLengthBeats(project, audioRegion); + if (visibleLengthBeats <= 0) { + return null; + } + + const regionStartBeat = audioRegion.getStartFromBeat(); + const durationSeconds = beatRangeToSeconds(project, regionStartBeat, regionStartBeat + visibleLengthBeats); + if (durationSeconds < MIN_ANALYSIS_DURATION_SECONDS) { + return null; + } + + return { + offsetSeconds: audioRegion.getClipStartOffsetSeconds(), + durationSeconds, + }; +} diff --git a/src/util/audioTempoDetectionActions.test.ts b/src/util/audioTempoDetectionActions.test.ts new file mode 100644 index 0000000..b32f4b2 --- /dev/null +++ b/src/util/audioTempoDetectionActions.test.ts @@ -0,0 +1,312 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { KGCore } from '../core/KGCore'; +import { KGProject } from '../core/KGProject'; +import { GlobalTrackType } from '../core/global-track'; +import type { KGCommand } from '../core/commands'; +import { KGAudioTrack } from '../core/track/KGAudioTrack'; +import { KGAudioRegion } from '../core/region/KGAudioRegion'; +import { KGTempoRegion } from '../core/region/KGTempoRegion'; +import { + applyDetectedTempoAction, + buildDetectedTempoChoiceMessage, + DETECTED_TEMPO_ACTION_INSERT_REGION, + DETECTED_TEMPO_ACTION_UPDATE_CURRENT, + getRightwardBeatAlignmentShiftBeats, +} from './audioTempoDetectionActions'; +import { findGlobalTrackByType, getSortedTempoRegions } from './globalTrackUtil'; + +function mockCoreForProject(project: KGProject) { + vi.mocked(KGCore.instance).mockReturnValue({ + getCurrentProject: vi.fn(() => project), + executeCommand: vi.fn((command: KGCommand, options?: { rethrow?: boolean }) => { + try { + command.execute(); + } catch (error) { + if (options?.rethrow) { + throw error; + } + } + }), + } as unknown as KGCore); +} + +describe('audio tempo detection actions', () => { + beforeEach(() => { + const project = new KGProject('test-project', 8); + mockCoreForProject(project); + }); + + it('builds the detected tempo choice message', () => { + expect(buildDetectedTempoChoiceMessage(128)).toBe( + 'Detected tempo: 128 BPM. Choose how to apply it.\n\nUpdate Current Tempo changes the active tempo at this clip location. Insert Tempo Change adds a new tempo region at the nearest bar before the clip starts.', + ); + }); + + it('leaves the project unchanged when no action is applied by the caller', () => { + const project = new KGProject('test-project', 8); + const setBpm = vi.fn((bpm: number) => { + project.setBpm(bpm); + }); + const refreshProjectState = vi.fn(); + + expect(project.getBpm()).toBe(120); + expect(refreshProjectState).not.toHaveBeenCalled(); + expect(setBpm).not.toHaveBeenCalled(); + }); + + it('updates the tempo region covering the region start', () => { + const project = new KGProject('test-project', 8); + const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo); + if (!tempoTrack) { + throw new Error('Tempo track not found'); + } + + tempoTrack.setRegions([ + new KGTempoRegion('tempo-a', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 8, 4), + ]); + mockCoreForProject(project); + + const refreshProjectState = vi.fn(); + const setBpm = vi.fn(); + + applyDetectedTempoAction({ + action: DETECTED_TEMPO_ACTION_UPDATE_CURRENT, + detectedBpm: 132, + detectedTempo: 132, + detectedOffsetSeconds: 0, + autoAlignRegionToBeat: false, + project, + regionId: 'audio-1', + regionStartBeat: 10, + regionTrackId: 'track-1', + regionTrackIndex: 0, + refreshProjectState, + setBpm, + }); + + expect((tempoTrack.getRegions()[0] as KGTempoRegion).getBpm()).toBe(132); + expect(setBpm).not.toHaveBeenCalled(); + expect(refreshProjectState).toHaveBeenCalledTimes(1); + }); + + it('falls back to project BPM when no tempo region exists', () => { + const project = new KGProject('test-project', 8); + mockCoreForProject(project); + + const refreshProjectState = vi.fn(); + const setBpm = vi.fn((bpm: number) => { + project.setBpm(bpm); + }); + + applyDetectedTempoAction({ + action: DETECTED_TEMPO_ACTION_UPDATE_CURRENT, + detectedBpm: 136, + detectedTempo: 136, + detectedOffsetSeconds: 0, + autoAlignRegionToBeat: false, + project, + regionId: 'audio-1', + regionStartBeat: 6, + regionTrackId: 'track-1', + regionTrackIndex: 0, + refreshProjectState, + setBpm, + }); + + expect(project.getBpm()).toBe(136); + expect(setBpm).toHaveBeenCalledWith(136); + expect(refreshProjectState).toHaveBeenCalledTimes(1); + }); + + it('creates a new tempo region at the floored start bar for mid-bar regions', () => { + const project = new KGProject('test-project', 8); + const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo); + if (!tempoTrack) { + throw new Error('Tempo track not found'); + } + + tempoTrack.setRegions([ + new KGTempoRegion('tempo-a', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 8, 4), + ]); + mockCoreForProject(project); + + const refreshProjectState = vi.fn(); + const setBpm = vi.fn(); + + applyDetectedTempoAction({ + action: DETECTED_TEMPO_ACTION_INSERT_REGION, + detectedBpm: 128, + detectedTempo: 128, + detectedOffsetSeconds: 0, + autoAlignRegionToBeat: false, + project, + regionId: 'audio-1', + regionStartBeat: 10, + regionTrackId: 'track-1', + regionTrackIndex: 0, + refreshProjectState, + setBpm, + }); + + const tempoRegions = getSortedTempoRegions(tempoTrack, 4); + expect(tempoRegions).toHaveLength(2); + expect(tempoRegions[0].getStartBar()).toBe(0); + expect(tempoRegions[0].getLengthBars()).toBe(2); + expect(tempoRegions[1].getStartBar()).toBe(2); + expect(tempoRegions[1].getBpm()).toBe(128); + expect(setBpm).not.toHaveBeenCalled(); + expect(refreshProjectState).toHaveBeenCalledTimes(1); + }); + + it('uses the same bar when the region starts exactly on a bar boundary', () => { + const project = new KGProject('test-project', 8); + const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo); + if (!tempoTrack) { + throw new Error('Tempo track not found'); + } + + tempoTrack.setRegions([ + new KGTempoRegion('tempo-a', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 8, 4), + ]); + mockCoreForProject(project); + + const refreshProjectState = vi.fn(); + + applyDetectedTempoAction({ + action: DETECTED_TEMPO_ACTION_INSERT_REGION, + detectedBpm: 140, + detectedTempo: 140, + detectedOffsetSeconds: 0, + autoAlignRegionToBeat: false, + project, + regionId: 'audio-1', + regionStartBeat: 8, + regionTrackId: 'track-1', + regionTrackIndex: 0, + refreshProjectState, + setBpm: vi.fn(), + }); + + const tempoRegions = getSortedTempoRegions(tempoTrack, 4); + expect(tempoRegions).toHaveLength(2); + expect(tempoRegions[1].getStartBar()).toBe(2); + expect(tempoRegions[1].getBpm()).toBe(140); + expect(refreshProjectState).toHaveBeenCalledTimes(1); + }); + + it('updates the existing tempo region when one already starts at the target bar', () => { + const project = new KGProject('test-project', 8); + const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo); + if (!tempoTrack) { + throw new Error('Tempo track not found'); + } + + tempoTrack.setRegions([ + new KGTempoRegion('tempo-a', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 2, 4), + new KGTempoRegion('tempo-b', tempoTrack.getId(), tempoTrack.getTrackIndex(), 126, 2, 6, 4), + ]); + mockCoreForProject(project); + + const refreshProjectState = vi.fn(); + + applyDetectedTempoAction({ + action: DETECTED_TEMPO_ACTION_INSERT_REGION, + detectedBpm: 144, + detectedTempo: 144, + detectedOffsetSeconds: 0, + autoAlignRegionToBeat: false, + project, + regionId: 'audio-1', + regionStartBeat: 8, + regionTrackId: 'track-1', + regionTrackIndex: 0, + refreshProjectState, + setBpm: vi.fn(), + }); + + const tempoRegions = getSortedTempoRegions(tempoTrack, 4); + expect(tempoRegions).toHaveLength(2); + expect(tempoRegions[1].getStartBar()).toBe(2); + expect(tempoRegions[1].getBpm()).toBe(144); + expect(refreshProjectState).toHaveBeenCalledTimes(1); + }); + + it('returns zero alignment shift for non-positive offsets', () => { + expect(getRightwardBeatAlignmentShiftBeats(125, 0)).toBe(0); + expect(getRightwardBeatAlignmentShiftBeats(125, -0.1)).toBe(0); + }); + + it('returns the minimum fractional shift to the next beat', () => { + const shiftBeats = getRightwardBeatAlignmentShiftBeats(124.99114291787713, 0.38548752834467126); + expect(shiftBeats).toBeGreaterThan(0); + expect(shiftBeats).toBeCloseTo(0.19695788752686635, 6); + }); + + it('moves the audio region right when auto-align is enabled for update-current-tempo', () => { + const project = new KGProject('test-project', 8); + const track = new KGAudioTrack('Audio', 0); + const region = new KGAudioRegion('audio-1', String(track.getId()), track.getTrackIndex(), 'Clip', 8, 4, 'file-1', 'clip.wav', 2); + track.setRegions([region]); + project.setTracks([track]); + mockCoreForProject(project); + + const refreshProjectState = vi.fn(); + + applyDetectedTempoAction({ + action: DETECTED_TEMPO_ACTION_UPDATE_CURRENT, + detectedBpm: 125, + detectedTempo: 124.99114291787713, + detectedOffsetSeconds: 0.38548752834467126, + autoAlignRegionToBeat: true, + project, + regionId: region.getId(), + regionStartBeat: region.getStartFromBeat(), + regionTrackId: region.getTrackId(), + regionTrackIndex: region.getTrackIndex(), + refreshProjectState, + setBpm: vi.fn((bpm: number) => project.setBpm(bpm)), + }); + + expect(region.getStartFromBeat()).toBeCloseTo(8.196957887526867, 6); + expect(region.getTrackId()).toBe(String(track.getId())); + expect(region.getTrackIndex()).toBe(track.getTrackIndex()); + expect(refreshProjectState).toHaveBeenCalledTimes(1); + }); + + it('moves the audio region right when auto-align is enabled for insert-tempo-change', () => { + const project = new KGProject('test-project', 8); + const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo); + if (!tempoTrack) { + throw new Error('Tempo track not found'); + } + tempoTrack.setRegions([ + new KGTempoRegion('tempo-a', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 8, 4), + ]); + + const track = new KGAudioTrack('Audio', 0); + const region = new KGAudioRegion('audio-1', String(track.getId()), track.getTrackIndex(), 'Clip', 10, 4, 'file-1', 'clip.wav', 2); + track.setRegions([region]); + project.setTracks([track]); + mockCoreForProject(project); + + const refreshProjectState = vi.fn(); + + applyDetectedTempoAction({ + action: DETECTED_TEMPO_ACTION_INSERT_REGION, + detectedBpm: 125, + detectedTempo: 124.99114291787713, + detectedOffsetSeconds: 0.38548752834467126, + autoAlignRegionToBeat: true, + project, + regionId: region.getId(), + regionStartBeat: region.getStartFromBeat(), + regionTrackId: region.getTrackId(), + regionTrackIndex: region.getTrackIndex(), + refreshProjectState, + setBpm: vi.fn(), + }); + + expect(region.getStartFromBeat()).toBeCloseTo(10.196957887526867, 6); + expect(refreshProjectState).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/util/audioTempoDetectionActions.ts b/src/util/audioTempoDetectionActions.ts new file mode 100644 index 0000000..52838e0 --- /dev/null +++ b/src/util/audioTempoDetectionActions.ts @@ -0,0 +1,114 @@ +import { KGCore } from '../core/KGCore'; +import { CreateTempoRegionCommand } from '../core/commands/global-region/CreateTempoRegionCommand'; +import { UpdateTempoRegionCommand } from '../core/commands/global-region/UpdateTempoRegionCommand'; +import { MoveRegionCommand } from '../core/commands/region/MoveRegionCommand'; +import type { KGProject } from '../core/KGProject'; +import { + findTempoRegionAtBar, + findTempoRegionAtBeat, +} from './globalTrackUtil'; + +export const DETECTED_TEMPO_ACTION_UPDATE_CURRENT = 'update-current-tempo'; +export const DETECTED_TEMPO_ACTION_INSERT_REGION = 'insert-tempo-change'; + +export type DetectedTempoAction = + | typeof DETECTED_TEMPO_ACTION_UPDATE_CURRENT + | typeof DETECTED_TEMPO_ACTION_INSERT_REGION; + +export function buildDetectedTempoChoiceMessage(bpm: number): string { + return `Detected tempo: ${bpm} BPM. Choose how to apply it.\n\nUpdate Current Tempo changes the active tempo at this clip location. Insert Tempo Change adds a new tempo region at the nearest bar before the clip starts.`; +} + +const ALIGNMENT_EPSILON = 1e-6; + +interface ApplyDetectedTempoActionParams { + action: DetectedTempoAction; + detectedBpm: number; + detectedTempo: number; + detectedOffsetSeconds: number; + autoAlignRegionToBeat: boolean; + project: KGProject; + regionId: string; + regionStartBeat: number; + regionTrackId: string; + regionTrackIndex: number; + refreshProjectState: () => void; + setBpm: (bpm: number) => void; +} + +export function getRightwardBeatAlignmentShiftBeats( + detectedTempo: number, + offsetSeconds: number, +): number { + if (!Number.isFinite(detectedTempo) || detectedTempo <= 0 || !Number.isFinite(offsetSeconds) || offsetSeconds <= 0) { + return 0; + } + + const secondsPerBeat = 60 / detectedTempo; + const offsetWithinBeat = offsetSeconds % secondsPerBeat; + if (offsetWithinBeat <= ALIGNMENT_EPSILON || secondsPerBeat - offsetWithinBeat <= ALIGNMENT_EPSILON) { + return 0; + } + + return (secondsPerBeat - offsetWithinBeat) / secondsPerBeat; +} + +export function applyDetectedTempoAction({ + action, + detectedBpm, + detectedTempo, + detectedOffsetSeconds, + autoAlignRegionToBeat, + project, + regionId, + regionStartBeat, + regionTrackId, + regionTrackIndex, + refreshProjectState, + setBpm, +}: ApplyDetectedTempoActionParams): void { + const core = KGCore.instance(); + + if (action === DETECTED_TEMPO_ACTION_UPDATE_CURRENT) { + const targetRegion = findTempoRegionAtBeat(project, regionStartBeat); + if (targetRegion) { + core.executeCommand(new UpdateTempoRegionCommand(targetRegion.getId(), detectedBpm), { rethrow: true }); + } else { + setBpm(detectedBpm); + } + } else { + const beatsPerBar = project.getTimeSignature().numerator; + const targetBar = Math.max(0, Math.floor(regionStartBeat / beatsPerBar)); + const existingRegionAtBar = findTempoRegionAtBar(project, targetBar); + + if (existingRegionAtBar && existingRegionAtBar.getStartBar() === targetBar) { + core.executeCommand(new UpdateTempoRegionCommand(existingRegionAtBar.getId(), detectedBpm), { rethrow: true }); + } else { + const createTempoRegionCommand = new CreateTempoRegionCommand(targetBar); + core.executeCommand(createTempoRegionCommand, { rethrow: true }); + + const createdRegion = createTempoRegionCommand.getCreatedRegion(); + if (!createdRegion) { + throw new Error(`Failed to create tempo region at bar ${targetBar + 1}`); + } + + core.executeCommand(new UpdateTempoRegionCommand(createdRegion.getId(), detectedBpm), { rethrow: true }); + } + } + + if (autoAlignRegionToBeat) { + const shiftBeats = getRightwardBeatAlignmentShiftBeats(detectedTempo, detectedOffsetSeconds); + if (shiftBeats > 0) { + core.executeCommand( + MoveRegionCommand.createPositionOnlyMove( + regionId, + regionStartBeat + shiftBeats, + regionTrackId, + regionTrackIndex, + ), + { rethrow: true }, + ); + } + } + refreshProjectState(); +} diff --git a/src/util/audioTempoDetectionCore.ts b/src/util/audioTempoDetectionCore.ts new file mode 100644 index 0000000..6276b7a --- /dev/null +++ b/src/util/audioTempoDetectionCore.ts @@ -0,0 +1,70 @@ +import { analyze, guess } from 'web-audio-beat-detector'; + +const DEFAULT_MIN_TEMPO = 80; +const DEFAULT_MAX_TEMPO = 180; +const ABSOLUTE_MIN_TEMPO = 40; +const ABSOLUTE_MAX_TEMPO = 240; + +export interface AudioTempoDetectionOptions { + minTempo: number; + maxTempo: number; +} + +export interface AudioTempoAnalysisSpan { + offsetSeconds: number; + durationSeconds: number; +} + +export interface DetectedAudioTempo { + tempo: number; + bpm: number; + offsetSeconds: number; +} + +export const DEFAULT_AUDIO_TEMPO_DETECTION_OPTIONS: AudioTempoDetectionOptions = { + minTempo: DEFAULT_MIN_TEMPO, + maxTempo: DEFAULT_MAX_TEMPO, +}; + +function clampTempo(value: number): number { + if (!Number.isFinite(value)) { + return DEFAULT_MIN_TEMPO; + } + + return Math.max(ABSOLUTE_MIN_TEMPO, Math.min(ABSOLUTE_MAX_TEMPO, Math.round(value))); +} + +export function normalizeAudioTempoDetectionOptions( + options?: Partial, +): AudioTempoDetectionOptions { + const minTempo = clampTempo(options?.minTempo ?? DEFAULT_MIN_TEMPO); + const maxTempo = clampTempo(options?.maxTempo ?? DEFAULT_MAX_TEMPO); + + if (minTempo >= maxTempo) { + throw new Error('Minimum BPM must be lower than maximum BPM.'); + } + + return { minTempo, maxTempo }; +} + +export async function detectTempoFromAudio( + audioBuffer: AudioBuffer, + span: AudioTempoAnalysisSpan, + options?: Partial, +): Promise { + const normalizedOptions = normalizeAudioTempoDetectionOptions(options); + + // Keep the detector behind this wrapper because the library is MIT-licensed, + // works directly with browser AudioBuffers, supports subrange analysis, and + // exposes beat offset that we will need for future beat-alignment features. + const [tempo, guessResult] = await Promise.all([ + analyze(audioBuffer, span.offsetSeconds, span.durationSeconds, normalizedOptions), + guess(audioBuffer, span.offsetSeconds, span.durationSeconds, normalizedOptions), + ]); + + return { + tempo, + bpm: Math.round(tempo), + offsetSeconds: guessResult.offset, + }; +} diff --git a/src/util/dialogUtil.ts b/src/util/dialogUtil.ts index 2f32abe..2d18ced 100644 --- a/src/util/dialogUtil.ts +++ b/src/util/dialogUtil.ts @@ -27,6 +27,16 @@ export interface MidiChordDetectionOptionsResult { harmonicFocus: 'balanced' | 'favor-sustained-notes'; } +export interface TempoDetectionOptionsResult { + minTempo: number; + maxTempo: number; +} + +export interface TempoApplyResult { + action: string; + autoAlignRegionToBeat: boolean; +} + export interface ChoiceOption { label: string; value: string; @@ -39,6 +49,8 @@ let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise< let _showChoiceFn: ((message: string, choices: ChoiceOption[]) => Promise) | null = null; let _showChordDetectionOptionsFn: ((message: string, defaultValue?: ChordDetectionOptionsResult) => Promise) | null = null; let _showMidiChordDetectionOptionsFn: ((message: string, defaultValue?: MidiChordDetectionOptionsResult) => Promise) | null = null; +let _showTempoDetectionOptionsFn: ((message: string, defaultValue?: TempoDetectionOptionsResult) => Promise) | null = null; +let _showTempoApplyFn: ((message: string, choices: ChoiceOption[]) => Promise) | null = null; export function registerDialogFns( alertFn: (message: string) => Promise, @@ -48,6 +60,8 @@ export function registerDialogFns( choiceFn?: (message: string, choices: ChoiceOption[]) => Promise, chordDetectionOptionsFn?: (message: string, defaultValue?: ChordDetectionOptionsResult) => Promise, midiChordDetectionOptionsFn?: (message: string, defaultValue?: MidiChordDetectionOptionsResult) => Promise, + tempoDetectionOptionsFn?: (message: string, defaultValue?: TempoDetectionOptionsResult) => Promise, + tempoApplyFn?: (message: string, choices: ChoiceOption[]) => Promise, ) { _showAlertFn = alertFn; _showConfirmFn = confirmFn; @@ -56,6 +70,8 @@ export function registerDialogFns( if (choiceFn) _showChoiceFn = choiceFn; if (chordDetectionOptionsFn) _showChordDetectionOptionsFn = chordDetectionOptionsFn; if (midiChordDetectionOptionsFn) _showMidiChordDetectionOptionsFn = midiChordDetectionOptionsFn; + if (tempoDetectionOptionsFn) _showTempoDetectionOptionsFn = tempoDetectionOptionsFn; + if (tempoApplyFn) _showTempoApplyFn = tempoApplyFn; } export function showAlert(message: string): Promise { @@ -126,3 +142,27 @@ export function showMidiChordDetectionOptions( } return _showMidiChordDetectionOptionsFn(message, defaultValue); } + +export function showTempoDetectionOptions( + message: string, + defaultValue?: TempoDetectionOptionsResult, +): Promise { + if (!_showTempoDetectionOptionsFn) { + return Promise.resolve(defaultValue ?? { + minTempo: 80, + maxTempo: 180, + }); + } + return _showTempoDetectionOptionsFn(message, defaultValue); +} + +export function showTempoApply(message: string, choices: ChoiceOption[]): Promise { + if (!_showTempoApplyFn) { + return Promise.resolve( + window.confirm(message) + ? { action: choices[0]?.value ?? '', autoAlignRegionToBeat: false } + : null, + ); + } + return _showTempoApplyFn(message, choices); +} diff --git a/tempo-detection-test.html b/tempo-detection-test.html new file mode 100644 index 0000000..d09332b --- /dev/null +++ b/tempo-detection-test.html @@ -0,0 +1,26 @@ + + + + + + Tempo Detection Test + + +
+

Tempo Detection Test

+

Ready

+
+ + +