diff --git a/PLANS.md b/PLANS.md index 7c102e9..f112706 100644 --- a/PLANS.md +++ b/PLANS.md @@ -29,7 +29,7 @@ M1 should include: Use this section as the current execution order for agent work. Feature document numbering describes the planned product sequence, but actual issue order may change as dependencies, review feedback, or implementation risks become clearer. -1. #25 Tempo control and live BPM updates -> `docs/features/10-tempo-control-and-bpm-slider.md` +1. #25 Tempo control and live BPM updates -> `docs/features/10-tempo-control-and-bpm-slider.md` (PR in review) 2. #26 Arrangement view UI shell -> `docs/features/11-arrangement-view-ui-shell.md` 3. #23 Sampler for advanced sustain -> `docs/features/09-sampler-advanced-sustain.md` diff --git a/docs/features/10-tempo-control-and-bpm-slider.md b/docs/features/10-tempo-control-and-bpm-slider.md index 29cef82..4cb3347 100644 --- a/docs/features/10-tempo-control-and-bpm-slider.md +++ b/docs/features/10-tempo-control-and-bpm-slider.md @@ -1,7 +1,7 @@ # Feature: 10 Tempo Control and BPM Slider ## Status -Planned +In Review ## Goal diff --git a/src/app/App.tsx b/src/app/App.tsx index ef0bc67..b91bc9e 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -31,7 +31,7 @@ import { type NoteEvent, type PitchedInstrumentId, } from "../model"; -import { type Tick } from "../utils"; +import { DEFAULT_TEMPO_BPM, clampTempoBpm, type Tick } from "../utils"; import styles from "./App.module.css"; const audioEngine = createAudioEngine(); @@ -63,7 +63,8 @@ function noteEventsToNoteLoopEvents( export function App() { const [transportState, setTransportState] = useState("stopped"); const [transportMode, setTransportMode] = useState("pattern"); - const [bpm, setBpm] = useState(124); + const [bpm, setBpm] = useState(DEFAULT_TEMPO_BPM); + const bpmRef = useRef(DEFAULT_TEMPO_BPM); const [selectedInstrumentId, setSelectedInstrumentId] = useState("iowa-piano"); const [selectedPitchedInstrumentId, setSelectedPitchedInstrumentId] = @@ -117,6 +118,15 @@ export function App() { setPlayheadTick(nextTick); } + function commitBpm(nextBpm: number) { + const normalizedBpm = clampTempoBpm(nextBpm); + const snapshot = audioEngine.setTempoBpm(normalizedBpm); + + bpmRef.current = snapshot.tempoBpm; + setBpm(snapshot.tempoBpm); + commitPlayheadTick(snapshot.currentTick); + } + function handleDrumStepToggle(laneId: DrumLaneId, stepIndex: number) { commitSelectedClip( toggleDrumStep({ @@ -237,7 +247,7 @@ export function App() { selectedClipRef.current.drumEvents, ), startTick, - tempoBpm: bpm, + tempoBpm: bpmRef.current, }); commitPlayheadTick(snapshot.currentTick); } catch (error) { @@ -269,7 +279,7 @@ export function App() { AudioContext; @@ -54,6 +58,7 @@ export class BrowserAudioEngine implements AudioEngine { private audioContext: AudioContext | null = null; private sampleLoopUpdateToken = 0; private clipLoopScheduler: LookaheadScheduler | null = null; + private tempoBpm = DEFAULT_TEMPO_BPM; constructor(samples: readonly BundledSampleMeta[]) { this.samplesById = new Map(samples.map((sample) => [sample.id, sample])); @@ -79,7 +84,7 @@ export class BrowserAudioEngine implements AudioEngine { loopStartTick: 0, nextScheduleTick: 0, status: "stopped", - tempoBpm: DEFAULT_TRANSPORT_TEMPO_BPM, + tempoBpm: this.tempoBpm, }; } @@ -182,6 +187,8 @@ export class BrowserAudioEngine implements AudioEngine { startTick, tempoBpm, }: StartClipLoopOptions): Promise { + const normalizedTempoBpm = clampTempoBpm(tempoBpm); + await this.resume(); await Promise.all([ @@ -200,7 +207,7 @@ export class BrowserAudioEngine implements AudioEngine { loopStartTick, ppq, scheduleAheadTime, - scheduleEvent: ({ audioTime, event }) => { + scheduleEvent: ({ audioTime, event, tempoBpm: scheduledTempoBpm }) => { if (event.kind === "sample") { this.scheduleLoadedSample(event.sampleId, { gain: event.gain, @@ -210,12 +217,13 @@ export class BrowserAudioEngine implements AudioEngine { } this.schedulePitchedNote(event, { - tempoBpm, + tempoBpm: scheduledTempoBpm, when: audioTime, }); }, - tempoBpm, + tempoBpm: normalizedTempoBpm, }); + this.tempoBpm = normalizedTempoBpm; return this.clipLoopScheduler.start({ startTick }); } @@ -240,10 +248,23 @@ export class BrowserAudioEngine implements AudioEngine { } const snapshot = this.clipLoopScheduler.stop(); + this.tempoBpm = snapshot.tempoBpm; this.clipLoopScheduler = null; return snapshot; } + setTempoBpm(tempoBpm: number): TransportSnapshot { + const normalizedTempoBpm = clampTempoBpm(tempoBpm); + + this.tempoBpm = normalizedTempoBpm; + + if (!this.clipLoopScheduler) { + return this.getTransportSnapshot(); + } + + return this.clipLoopScheduler.setTempoBpm(normalizedTempoBpm); + } + async updateSampleLoopEvents( events: readonly SampleLoopEvent[], ): Promise { diff --git a/src/audio/lookahead-scheduler.ts b/src/audio/lookahead-scheduler.ts index 0a4fb78..6ac59f5 100644 --- a/src/audio/lookahead-scheduler.ts +++ b/src/audio/lookahead-scheduler.ts @@ -19,6 +19,7 @@ export interface ScheduledTickEvent { absoluteTick: Tick; loopIteration: number; loopTick: Tick; + tempoBpm: number; } export interface SchedulerSnapshot { @@ -135,6 +136,7 @@ export function collectScheduledEventsForWindow({ event, loopIteration, loopTick: event.startTick, + tempoBpm, }); loopIteration += 1; @@ -175,12 +177,12 @@ export class LookaheadScheduler { private nextScheduleTick: Tick; private startTick: Tick; private status: SchedulerStatus = "stopped"; + private tempoBpm: number; private timerId: SchedulerTimerId | null = null; readonly loopEndTick: Tick; readonly loopStartTick: Tick; readonly ppq: number; - readonly tempoBpm: number; constructor({ clearIntervalFn = defaultClearSchedulerInterval, @@ -196,6 +198,7 @@ export class LookaheadScheduler { tempoBpm, }: LookaheadSchedulerOptions) { validateLoopRange(loopStartTick, loopEndTick); + validateTempoBpm(tempoBpm); this.clearIntervalFn = clearIntervalFn; this.events = events; @@ -259,6 +262,24 @@ export class LookaheadScheduler { this.events = events; } + setTempoBpm(tempoBpm: number): SchedulerSnapshot { + validateTempoBpm(tempoBpm); + + if (this.status === "playing" && this.audioStartTime !== null) { + const currentAudioTime = this.getAudioTime(); + const currentAbsoluteTick = this.getCurrentAbsoluteTickAtAudioTime( + currentAudioTime, + ); + + this.audioStartTime = currentAudioTime; + this.nextScheduleTick = Math.max(this.nextScheduleTick, currentAbsoluteTick); + this.startTick = currentAbsoluteTick; + } + + this.tempoBpm = tempoBpm; + return this.getSnapshot(); + } + getSnapshot(): SchedulerSnapshot { return { audioStartTime: this.audioStartTime, @@ -272,23 +293,33 @@ export class LookaheadScheduler { } private getCurrentTick(): Tick { + return getLoopTickAtAbsoluteTick({ + absoluteTick: this.getCurrentAbsoluteTick(), + loopEndTick: this.loopEndTick, + loopStartTick: this.loopStartTick, + }); + } + + private getCurrentAbsoluteTick(): Tick { if (this.status !== "playing" || this.audioStartTime === null) { return this.startTick; } - const absoluteTick = audioTimeToTick({ + return this.getCurrentAbsoluteTickAtAudioTime(this.getAudioTime()); + } + + private getCurrentAbsoluteTickAtAudioTime(audioTime: number): Tick { + if (this.audioStartTime === null) { + return this.startTick; + } + + return audioTimeToTick({ audioStartTime: this.audioStartTime, - audioTime: this.getAudioTime(), + audioTime, ppq: this.ppq, startTick: this.startTick, tempoBpm: this.tempoBpm, }); - - return getLoopTickAtAbsoluteTick({ - absoluteTick, - loopEndTick: this.loopEndTick, - loopStartTick: this.loopStartTick, - }); } private scheduleNextWindow(): void { @@ -359,3 +390,9 @@ function validateLoopRange(loopStartTick: Tick, loopEndTick: Tick): void { ); } } + +function validateTempoBpm(tempoBpm: number): void { + if (!Number.isFinite(tempoBpm) || tempoBpm <= 0) { + throw new Error(`tempoBpm must be a positive finite number. Received ${tempoBpm}.`); + } +} diff --git a/src/audio/types.ts b/src/audio/types.ts index a3f0b8a..4f9ebf2 100644 --- a/src/audio/types.ts +++ b/src/audio/types.ts @@ -71,6 +71,7 @@ export interface AudioEngine { loadAllSamples(): Promise; playSample(sampleId: SampleId, options?: PlaySampleOptions): Promise; pauseLoop(): TransportSnapshot; + setTempoBpm(tempoBpm: number): TransportSnapshot; startClipLoop(options: StartClipLoopOptions): Promise; startSampleLoop(options: StartSampleLoopOptions): Promise; stopLoop(): TransportSnapshot; diff --git a/src/features/transport/TransportBar.tsx b/src/features/transport/TransportBar.tsx index 8ece4bf..bc61f41 100644 --- a/src/features/transport/TransportBar.tsx +++ b/src/features/transport/TransportBar.tsx @@ -1,4 +1,5 @@ import { Icon } from "../../components"; +import { MAX_TEMPO_BPM, MIN_TEMPO_BPM } from "../../utils"; import styles from "./TransportBar.module.css"; export type TransportState = "paused" | "playing" | "stopped"; @@ -73,8 +74,8 @@ export function TransportBar({ onBpmChange(Number(event.target.value))} type="range" value={bpm} diff --git a/src/utils/index.ts b/src/utils/index.ts index 0297b8a..89a5656 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,9 @@ +export { + DEFAULT_TEMPO_BPM, + MAX_TEMPO_BPM, + MIN_TEMPO_BPM, + clampTempoBpm, +} from "./tempo"; export { DEFAULT_PPQ, TICKS_PER_4_4_BAR, diff --git a/src/utils/tempo.ts b/src/utils/tempo.ts new file mode 100644 index 0000000..fb984f3 --- /dev/null +++ b/src/utils/tempo.ts @@ -0,0 +1,11 @@ +export const DEFAULT_TEMPO_BPM = 124; +export const MIN_TEMPO_BPM = 60; +export const MAX_TEMPO_BPM = 180; + +export function clampTempoBpm(tempoBpm: number): number { + if (!Number.isFinite(tempoBpm)) { + return DEFAULT_TEMPO_BPM; + } + + return Math.min(Math.max(Math.round(tempoBpm), MIN_TEMPO_BPM), MAX_TEMPO_BPM); +} diff --git a/tests/unit/audio/lookahead-scheduler.test.ts b/tests/unit/audio/lookahead-scheduler.test.ts index 08891d4..e9cd31b 100644 --- a/tests/unit/audio/lookahead-scheduler.test.ts +++ b/tests/unit/audio/lookahead-scheduler.test.ts @@ -217,4 +217,58 @@ describe("LookaheadScheduler", () => { 480, ]); }); + + it("updates tempo for future scheduling windows while preserving current tick", () => { + let audioTime = 0; + let intervalHandler: (() => void) | undefined; + const scheduledEvents: ScheduledTickEvent[] = []; + const scheduler = new LookaheadScheduler({ + events: [{ id: "future-note", label: "future-note", startTick: 720 }], + getAudioTime: () => audioTime, + scheduleAheadTime: 0.1, + scheduleEvent: (scheduledEvent) => { + scheduledEvents.push(scheduledEvent); + }, + setIntervalFn: (handler) => { + intervalHandler = handler; + return 1; + }, + tempoBpm: 120, + }); + + scheduler.start(); + audioTime = 0.5; + + const tempoSnapshot = scheduler.setTempoBpm(60); + + expect(tempoSnapshot.status).toBe("playing"); + expect(tempoSnapshot.currentTick).toBe(480); + expect(tempoSnapshot.tempoBpm).toBe(60); + + audioTime = 1; + intervalHandler?.(); + + expect( + scheduledEvents.map((scheduledEvent) => [ + scheduledEvent.event.id, + scheduledEvent.absoluteTick, + scheduledEvent.audioTime, + scheduledEvent.tempoBpm, + ]), + ).toEqual([["future-note", 720, 1, 60]]); + expect(scheduler.getSnapshot().currentTick).toBe(720); + }); + + it("rejects invalid tempo updates", () => { + const scheduler = new LookaheadScheduler({ + events: [], + getAudioTime: () => 0, + scheduleEvent: () => {}, + tempoBpm: 120, + }); + + expect(() => scheduler.setTempoBpm(0)).toThrow( + "tempoBpm must be a positive finite number", + ); + }); }); diff --git a/tests/unit/utils/tempo.test.ts b/tests/unit/utils/tempo.test.ts new file mode 100644 index 0000000..1568a40 --- /dev/null +++ b/tests/unit/utils/tempo.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_TEMPO_BPM, + MAX_TEMPO_BPM, + MIN_TEMPO_BPM, + clampTempoBpm, +} from "../../../src/utils"; + +describe("tempo utilities", () => { + it("clamps tempo to the supported transport range", () => { + expect(clampTempoBpm(40)).toBe(MIN_TEMPO_BPM); + expect(clampTempoBpm(250)).toBe(MAX_TEMPO_BPM); + expect(clampTempoBpm(127.6)).toBe(128); + }); + + it("falls back to the default tempo for non-finite values", () => { + expect(clampTempoBpm(Number.NaN)).toBe(DEFAULT_TEMPO_BPM); + expect(clampTempoBpm(Number.POSITIVE_INFINITY)).toBe(DEFAULT_TEMPO_BPM); + }); +});