Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PLANS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion docs/features/10-tempo-control-and-bpm-slider.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Feature: 10 Tempo Control and BPM Slider

## Status
Planned
In Review

## Goal

Expand Down
18 changes: 14 additions & 4 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -63,7 +63,8 @@ function noteEventsToNoteLoopEvents(
export function App() {
const [transportState, setTransportState] = useState<TransportState>("stopped");
const [transportMode, setTransportMode] = useState<TransportMode>("pattern");
const [bpm, setBpm] = useState(124);
const [bpm, setBpm] = useState(DEFAULT_TEMPO_BPM);
const bpmRef = useRef(DEFAULT_TEMPO_BPM);
const [selectedInstrumentId, setSelectedInstrumentId] =
useState<InstrumentId>("iowa-piano");
const [selectedPitchedInstrumentId, setSelectedPitchedInstrumentId] =
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -237,7 +247,7 @@ export function App() {
selectedClipRef.current.drumEvents,
),
startTick,
tempoBpm: bpm,
tempoBpm: bpmRef.current,
});
commitPlayheadTick(snapshot.currentTick);
} catch (error) {
Expand Down Expand Up @@ -269,7 +279,7 @@ export function App() {
<TransportBar
bpm={bpm}
mode={transportMode}
onBpmChange={setBpm}
onBpmChange={commitBpm}
onModeChange={setTransportMode}
onTransportStateChange={handleTransportStateChange}
transportState={transportState}
Expand Down
33 changes: 27 additions & 6 deletions src/audio/browser-audio-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@ import {
getSampleZoneForMidiNote,
resolveSustainLoopRegion,
} from "../model";
import { TICKS_PER_4_4_BAR, ticksToSeconds } from "../utils";
import {
DEFAULT_TEMPO_BPM,
TICKS_PER_4_4_BAR,
clampTempoBpm,
ticksToSeconds,
} from "../utils";

const DEFAULT_SAMPLE_GAIN = 0.9;
const DEFAULT_SYNTH_GAIN = 0.22;
const DEFAULT_PIANO_GAIN = 0.72;
const DEFAULT_TRANSPORT_TEMPO_BPM = 120;

type AudioContextConstructor = new () => AudioContext;

Expand Down Expand Up @@ -54,6 +58,7 @@ export class BrowserAudioEngine implements AudioEngine {
private audioContext: AudioContext | null = null;
private sampleLoopUpdateToken = 0;
private clipLoopScheduler: LookaheadScheduler<ClipLoopEvent> | null = null;
private tempoBpm = DEFAULT_TEMPO_BPM;

constructor(samples: readonly BundledSampleMeta[]) {
this.samplesById = new Map(samples.map((sample) => [sample.id, sample]));
Expand All @@ -79,7 +84,7 @@ export class BrowserAudioEngine implements AudioEngine {
loopStartTick: 0,
nextScheduleTick: 0,
status: "stopped",
tempoBpm: DEFAULT_TRANSPORT_TEMPO_BPM,
tempoBpm: this.tempoBpm,
};
}

Expand Down Expand Up @@ -182,6 +187,8 @@ export class BrowserAudioEngine implements AudioEngine {
startTick,
tempoBpm,
}: StartClipLoopOptions): Promise<TransportSnapshot> {
const normalizedTempoBpm = clampTempoBpm(tempoBpm);

await this.resume();

await Promise.all([
Expand 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,
Expand All @@ -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 });
}
Expand All @@ -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<TransportSnapshot> {
Expand Down
55 changes: 46 additions & 9 deletions src/audio/lookahead-scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface ScheduledTickEvent<TEvent extends TickEvent> {
absoluteTick: Tick;
loopIteration: number;
loopTick: Tick;
tempoBpm: number;
}

export interface SchedulerSnapshot {
Expand Down Expand Up @@ -135,6 +136,7 @@ export function collectScheduledEventsForWindow<TEvent extends TickEvent>({
event,
loopIteration,
loopTick: event.startTick,
tempoBpm,
});

loopIteration += 1;
Expand Down Expand Up @@ -175,12 +177,12 @@ export class LookaheadScheduler<TEvent extends TickEvent> {
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,
Expand All @@ -196,6 +198,7 @@ export class LookaheadScheduler<TEvent extends TickEvent> {
tempoBpm,
}: LookaheadSchedulerOptions<TEvent>) {
validateLoopRange(loopStartTick, loopEndTick);
validateTempoBpm(tempoBpm);

this.clearIntervalFn = clearIntervalFn;
this.events = events;
Expand Down Expand Up @@ -259,6 +262,24 @@ export class LookaheadScheduler<TEvent extends TickEvent> {
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,
Expand All @@ -272,23 +293,33 @@ export class LookaheadScheduler<TEvent extends TickEvent> {
}

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 {
Expand Down Expand Up @@ -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}.`);
}
}
1 change: 1 addition & 0 deletions src/audio/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface AudioEngine {
loadAllSamples(): Promise<AudioEngineSnapshot>;
playSample(sampleId: SampleId, options?: PlaySampleOptions): Promise<void>;
pauseLoop(): TransportSnapshot;
setTempoBpm(tempoBpm: number): TransportSnapshot;
startClipLoop(options: StartClipLoopOptions): Promise<TransportSnapshot>;
startSampleLoop(options: StartSampleLoopOptions): Promise<TransportSnapshot>;
stopLoop(): TransportSnapshot;
Expand Down
5 changes: 3 additions & 2 deletions src/features/transport/TransportBar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -73,8 +74,8 @@ export function TransportBar({
<input
aria-label="BPM"
className={styles.bpmSlider}
max="180"
min="60"
max={MAX_TEMPO_BPM}
min={MIN_TEMPO_BPM}
onChange={(event) => onBpmChange(Number(event.target.value))}
type="range"
value={bpm}
Expand Down
6 changes: 6 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/utils/tempo.ts
Original file line number Diff line number Diff line change
@@ -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);
}
54 changes: 54 additions & 0 deletions tests/unit/audio/lookahead-scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestEvent>[] = [];
const scheduler = new LookaheadScheduler<TestEvent>({
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<TestEvent>({
events: [],
getAudioTime: () => 0,
scheduleEvent: () => {},
tempoBpm: 120,
});

expect(() => scheduler.setTempoBpm(0)).toThrow(
"tempoBpm must be a positive finite number",
);
});
});
Loading
Loading