feat: added tempo detection with auto-align beats feature
This commit is contained in:
@@ -7,6 +7,8 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
test-results/
|
||||
|
||||
node_modules
|
||||
res/
|
||||
coverage/
|
||||
|
||||
Generated
+140
-3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Binary file not shown.
@@ -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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,12 +190,18 @@ 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'
|
||||
: isTempoDetection
|
||||
? 'Tempo Detection'
|
||||
: isTempoApply
|
||||
? 'Apply Tempo'
|
||||
: (isChordDetection || isMidiChordDetection)
|
||||
? 'Chord Detection'
|
||||
: isPrompt
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' }}>
|
||||
{onDetectChords && (
|
||||
<div
|
||||
className={`quant-option${(!onDetectChords || detectingChords) ? ' disabled' : ''}`}
|
||||
className={`quant-option${detectingChords ? ' disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (!onDetectChords || detectingChords) {
|
||||
if (detectingChords) {
|
||||
return;
|
||||
}
|
||||
setShowSpecMenu(false);
|
||||
setShowMoreMenu(false);
|
||||
void onDetectChords();
|
||||
}}
|
||||
aria-disabled={!onDetectChords || detectingChords}
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<AudioBuffer> {
|
||||
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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>,
|
||||
): 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<AudioTempoDetectionOptions>,
|
||||
): Promise<DetectedAudioTempo> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string | null>) | null = null;
|
||||
let _showChordDetectionOptionsFn: ((message: string, defaultValue?: ChordDetectionOptionsResult) => Promise<ChordDetectionOptionsResult | null>) | null = null;
|
||||
let _showMidiChordDetectionOptionsFn: ((message: string, defaultValue?: MidiChordDetectionOptionsResult) => Promise<MidiChordDetectionOptionsResult | null>) | null = null;
|
||||
let _showTempoDetectionOptionsFn: ((message: string, defaultValue?: TempoDetectionOptionsResult) => Promise<TempoDetectionOptionsResult | null>) | null = null;
|
||||
let _showTempoApplyFn: ((message: string, choices: ChoiceOption[]) => Promise<TempoApplyResult | null>) | null = null;
|
||||
|
||||
export function registerDialogFns(
|
||||
alertFn: (message: string) => Promise<void>,
|
||||
@@ -48,6 +60,8 @@ export function registerDialogFns(
|
||||
choiceFn?: (message: string, choices: ChoiceOption[]) => Promise<string | null>,
|
||||
chordDetectionOptionsFn?: (message: string, defaultValue?: ChordDetectionOptionsResult) => Promise<ChordDetectionOptionsResult | null>,
|
||||
midiChordDetectionOptionsFn?: (message: string, defaultValue?: MidiChordDetectionOptionsResult) => Promise<MidiChordDetectionOptionsResult | null>,
|
||||
tempoDetectionOptionsFn?: (message: string, defaultValue?: TempoDetectionOptionsResult) => Promise<TempoDetectionOptionsResult | null>,
|
||||
tempoApplyFn?: (message: string, choices: ChoiceOption[]) => Promise<TempoApplyResult | null>,
|
||||
) {
|
||||
_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<void> {
|
||||
@@ -126,3 +142,27 @@ export function showMidiChordDetectionOptions(
|
||||
}
|
||||
return _showMidiChordDetectionOptionsFn(message, defaultValue);
|
||||
}
|
||||
|
||||
export function showTempoDetectionOptions(
|
||||
message: string,
|
||||
defaultValue?: TempoDetectionOptionsResult,
|
||||
): Promise<TempoDetectionOptionsResult | null> {
|
||||
if (!_showTempoDetectionOptionsFn) {
|
||||
return Promise.resolve(defaultValue ?? {
|
||||
minTempo: 80,
|
||||
maxTempo: 180,
|
||||
});
|
||||
}
|
||||
return _showTempoDetectionOptionsFn(message, defaultValue);
|
||||
}
|
||||
|
||||
export function showTempoApply(message: string, choices: ChoiceOption[]): Promise<TempoApplyResult | null> {
|
||||
if (!_showTempoApplyFn) {
|
||||
return Promise.resolve(
|
||||
window.confirm(message)
|
||||
? { action: choices[0]?.value ?? '', autoAlignRegionToBeat: false }
|
||||
: null,
|
||||
);
|
||||
}
|
||||
return _showTempoApplyFn(message, choices);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tempo Detection Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Tempo Detection Test</h1>
|
||||
<p id="status">Ready</p>
|
||||
</main>
|
||||
<script type="module">
|
||||
window.__tempoHarnessLoaded = false;
|
||||
window.__tempoHarnessError = null;
|
||||
|
||||
import('/src/test/browser/audioTempoDetectionHarness.ts')
|
||||
.then(() => {
|
||||
window.__tempoHarnessLoaded = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
window.__tempoHarnessError = error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user