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