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. #16 Transport pause/resume and editor playhead -> `docs/features/07-transport-pause-and-playhead.md`
1. #16 Transport pause/resume and editor playhead -> `docs/features/07-transport-pause-and-playhead.md` (PR #21 in review)
2. #19 Pitched instrument selection and Iowa Piano sustain -> `docs/features/08-pitched-instruments-and-sustain.md`

## Planned Milestones
Expand Down
6 changes: 5 additions & 1 deletion docs/audio-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
- Play one-shot samples.
- Convert tick positions to audio time using tempo and PPQ.
- Schedule sequenced playback with a lookahead scheduler.
- Track transport state such as stopped, playing, tempo, loop range, and playhead position.
- Track transport state such as stopped, playing, paused, tempo, loop range, and playhead position.
- Expose a small typed API to the UI.

## Non-responsibilities
Expand Down Expand Up @@ -91,6 +91,8 @@ Example terms:

The browser audio engine exposes sample loop playback through a typed API that accepts tick-based sample events, tempo, loop bounds, lookahead cadence, and schedule-ahead time. The scheduler itself is independent from React and can be unit tested without DOM rendering.

The transport API should allow playback to start from a tick offset when resuming from pause. The offset is runtime state only and should be passed as a `startTick` option, not persisted into project data.

## Tick-to-audio-time Conversion

Ticks convert to seconds using tempo and PPQ:
Expand Down Expand Up @@ -123,6 +125,8 @@ Pause and stop have different meanings:
- Stop clears scheduling and resets the runtime playhead tick to the loop start, which is tick 0 for the M1 1-bar clip.
- The paused playhead position is runtime state only. It should not be written to project JSON.

The audio engine should expose pause separately from stop. A pause operation preserves the scheduler object and its current tick snapshot; a stop operation clears the active loop and returns the transport to the loop start.

## Looping Behavior

For M1, loop playback targets a selected 1-bar clip. The default loop range is 0 to 1920 ticks.
Expand Down
3 changes: 2 additions & 1 deletion docs/features/07-transport-pause-and-playhead.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Feature: 07 Transport Pause and Playhead

## Status
Planned
In Review

## Goal

Expand Down Expand Up @@ -81,6 +81,7 @@ Manual check:
- Pause playback and confirm the playhead stops at its current position.
- Resume playback and confirm it continues from the paused position.
- Stop playback and confirm the playhead returns to the beginning.
- Confirm the visual playhead and drum step indicator are hidden while stopped.
- Confirm the visual playhead loops across the 1-bar clip without jumping outside the editor grid.
- Confirm drum steps still trigger at the expected musical positions after pause/resume.

Expand Down
56 changes: 52 additions & 4 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";

import {
BUNDLED_DRUM_SAMPLES,
Expand Down Expand Up @@ -71,7 +71,32 @@ export function App() {
useState<InstrumentId>("leadSynth");
const [selectedClip, setSelectedClip] = useState(() => createEmptyHybridClip());
const selectedClipRef = useRef(selectedClip);
const [playheadTick, setPlayheadTick] = useState<Tick>(0);
const playheadTickRef = useRef<Tick>(0);
const [audioError, setAudioError] = useState<string | null>(null);
const shouldShowPlayhead = transportState !== "stopped";

useEffect(() => {
if (transportState !== "playing") {
return;
}

let animationFrameId = 0;

function updatePlayhead() {
const currentTick = audioEngine.getTransportSnapshot().currentTick;

playheadTickRef.current = currentTick;
setPlayheadTick(currentTick);
animationFrameId = window.requestAnimationFrame(updatePlayhead);
}

animationFrameId = window.requestAnimationFrame(updatePlayhead);

return () => {
window.cancelAnimationFrame(animationFrameId);
};
}, [transportState]);

function commitSelectedClip(nextClip: HybridClip) {
selectedClipRef.current = nextClip;
Expand All @@ -82,6 +107,11 @@ export function App() {
}
}

function commitPlayheadTick(nextTick: Tick) {
playheadTickRef.current = nextTick;
setPlayheadTick(nextTick);
}

function handleDrumStepToggle(laneId: DrumLaneId, stepIndex: number) {
commitSelectedClip(
toggleDrumStep({
Expand Down Expand Up @@ -167,25 +197,38 @@ export function App() {
setAudioError(null);

if (nextTransportState === "stopped") {
audioEngine.stopLoop();
setTransportState("stopped");
const snapshot = audioEngine.stopLoop();
setTransportState(snapshot.status);
commitPlayheadTick(snapshot.currentTick);
return;
}

if (nextTransportState === "paused") {
const snapshot = audioEngine.pauseLoop();
setTransportState(snapshot.status);
commitPlayheadTick(snapshot.currentTick);
return;
}

const startTick = transportState === "paused" ? playheadTickRef.current : 0;

setTransportState("playing");

try {
await audioEngine.startClipLoop({
const snapshot = await audioEngine.startClipLoop({
noteEvents: noteEventsToNoteLoopEvents(
selectedClipRef.current.noteEvents,
),
sampleEvents: drumEventsToSampleLoopEvents(
selectedClipRef.current.drumEvents,
),
startTick,
tempoBpm: bpm,
});
commitPlayheadTick(snapshot.currentTick);
} catch (error) {
setTransportState("stopped");
commitPlayheadTick(audioEngine.stopLoop().currentTick);
setAudioError(
error instanceof Error ? error.message : "Audio playback failed.",
);
Expand Down Expand Up @@ -248,15 +291,20 @@ export function App() {
drumLanes={selectedClip.drumLanes}
onLaneMove={handleLaneMove}
onLaneSampleChange={handleLaneSampleChange}
playheadTick={playheadTick}
shouldShowPlayhead={shouldShowPlayhead}
onStepToggle={handleDrumStepToggle}
samples={BUNDLED_DRUM_SAMPLES}
/>
<PianoRoll
clipLengthTicks={selectedClip.lengthTicks}
instrumentName={instrumentLabels[selectedInstrumentId]}
noteEvents={selectedClip.noteEvents}
onNoteCreate={handleNoteCreate}
onNoteDelete={handleNoteDelete}
onNoteMove={handleNoteMove}
playheadTick={playheadTick}
shouldShowPlayhead={shouldShowPlayhead}
/>
</div>
</main>
Expand Down
16 changes: 15 additions & 1 deletion src/audio/browser-audio-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export class BrowserAudioEngine implements AudioEngine {
lookaheadMs,
ppq,
scheduleAheadTime,
startTick,
tempoBpm,
}: StartSampleLoopOptions): Promise<TransportSnapshot> {
return this.startClipLoop({
Expand All @@ -154,6 +155,7 @@ export class BrowserAudioEngine implements AudioEngine {
ppq,
sampleEvents: events,
scheduleAheadTime,
startTick,
tempoBpm,
});
}
Expand All @@ -166,6 +168,7 @@ export class BrowserAudioEngine implements AudioEngine {
ppq,
sampleEvents,
scheduleAheadTime,
startTick,
tempoBpm,
}: StartClipLoopOptions): Promise<TransportSnapshot> {
await this.resume();
Expand Down Expand Up @@ -200,7 +203,18 @@ export class BrowserAudioEngine implements AudioEngine {
tempoBpm,
});

return this.clipLoopScheduler.start();
return this.clipLoopScheduler.start({ startTick });
}

pauseLoop(): TransportSnapshot {
this.sampleLoopUpdateToken += 1;
this.stopActiveSynthVoices();

if (!this.clipLoopScheduler) {
return this.getTransportSnapshot();
}

return this.clipLoopScheduler.pause();
}

stopLoop(): TransportSnapshot {
Expand Down
61 changes: 53 additions & 8 deletions src/audio/lookahead-scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
type Tick,
} from "../utils";

export type SchedulerStatus = "stopped" | "playing";
export type SchedulerStatus = "stopped" | "playing" | "paused";

export interface TickEvent {
id: string;
Expand Down Expand Up @@ -37,6 +37,7 @@ export interface ScheduleWindowOptions<TEvent extends TickEvent> {
loopEndTick?: Tick;
loopStartTick?: Tick;
ppq?: number;
startTick?: Tick;
tempoBpm: number;
windowEndTick: Tick;
windowStartTick: Tick;
Expand All @@ -56,6 +57,10 @@ export interface LookaheadSchedulerOptions<TEvent extends TickEvent> {
tempoBpm: number;
}

export interface StartSchedulerOptions {
startTick?: Tick;
}

const DEFAULT_LOOKAHEAD_MS = 25;
const DEFAULT_SCHEDULE_AHEAD_TIME = 0.1;

Expand All @@ -77,6 +82,7 @@ export function collectScheduledEventsForWindow<TEvent extends TickEvent>({
loopEndTick = TICKS_PER_4_4_BAR,
loopStartTick = 0,
ppq = DEFAULT_PPQ,
startTick = loopStartTick,
tempoBpm,
windowEndTick,
windowStartTick,
Expand Down Expand Up @@ -122,6 +128,7 @@ export function collectScheduledEventsForWindow<TEvent extends TickEvent>({
audioTime: tickToAudioTime({
audioStartTime,
ppq,
startTick,
tempoBpm,
tick: absoluteTick,
}),
Expand Down Expand Up @@ -166,6 +173,7 @@ export class LookaheadScheduler<TEvent extends TickEvent> {
private audioStartTime: number | null = null;
private events: readonly TEvent[];
private nextScheduleTick: Tick;
private startTick: Tick;
private status: SchedulerStatus = "stopped";
private timerId: SchedulerTimerId | null = null;

Expand Down Expand Up @@ -200,16 +208,18 @@ export class LookaheadScheduler<TEvent extends TickEvent> {
this.scheduleAheadTime = scheduleAheadTime;
this.scheduleEvent = scheduleEvent;
this.setIntervalFn = setIntervalFn;
this.startTick = loopStartTick;
this.tempoBpm = tempoBpm;
}

start(): SchedulerSnapshot {
start({ startTick = this.loopStartTick }: StartSchedulerOptions = {}): SchedulerSnapshot {
if (this.status === "playing") {
return this.getSnapshot();
}

this.startTick = this.normalizeLoopTick(startTick);
this.audioStartTime = this.getAudioTime();
this.nextScheduleTick = this.loopStartTick;
this.nextScheduleTick = this.startTick;
this.status = "playing";
this.scheduleNextWindow();
this.timerId = this.setIntervalFn(
Expand All @@ -220,14 +230,26 @@ export class LookaheadScheduler<TEvent extends TickEvent> {
return this.getSnapshot();
}

stop(): SchedulerSnapshot {
if (this.timerId !== null) {
this.clearIntervalFn(this.timerId);
this.timerId = null;
pause(): SchedulerSnapshot {
if (this.status !== "playing") {
return this.getSnapshot();
}

this.startTick = this.getCurrentTick();
this.clearTimer();
this.status = "paused";
this.nextScheduleTick = this.startTick;
this.audioStartTime = null;

return this.getSnapshot();
}

stop(): SchedulerSnapshot {
this.clearTimer();

this.status = "stopped";
this.nextScheduleTick = this.loopStartTick;
this.startTick = this.loopStartTick;
this.audioStartTime = null;

return this.getSnapshot();
Expand All @@ -251,13 +273,14 @@ export class LookaheadScheduler<TEvent extends TickEvent> {

private getCurrentTick(): Tick {
if (this.status !== "playing" || this.audioStartTime === null) {
return this.loopStartTick;
return this.startTick;
}

const absoluteTick = audioTimeToTick({
audioStartTime: this.audioStartTime,
audioTime: this.getAudioTime(),
ppq: this.ppq,
startTick: this.startTick,
tempoBpm: this.tempoBpm,
});

Expand All @@ -278,12 +301,14 @@ export class LookaheadScheduler<TEvent extends TickEvent> {
audioStartTime: this.audioStartTime,
audioTime: currentAudioTime,
ppq: this.ppq,
startTick: this.startTick,
tempoBpm: this.tempoBpm,
});
const scheduleUntilTick = audioTimeToTick({
audioStartTime: this.audioStartTime,
audioTime: currentAudioTime + this.scheduleAheadTime,
ppq: this.ppq,
startTick: this.startTick,
tempoBpm: this.tempoBpm,
});
const windowStartTick = Math.max(this.nextScheduleTick, currentAbsoluteTick);
Expand All @@ -294,6 +319,7 @@ export class LookaheadScheduler<TEvent extends TickEvent> {
loopEndTick: this.loopEndTick,
loopStartTick: this.loopStartTick,
ppq: this.ppq,
startTick: this.startTick,
tempoBpm: this.tempoBpm,
windowEndTick,
windowStartTick,
Expand All @@ -305,6 +331,25 @@ export class LookaheadScheduler<TEvent extends TickEvent> {

this.nextScheduleTick = windowEndTick;
}

private clearTimer(): void {
if (this.timerId !== null) {
this.clearIntervalFn(this.timerId);
this.timerId = null;
}
}

private normalizeLoopTick(tick: Tick): Tick {
if (tick === this.loopEndTick) {
return this.loopStartTick;
}

return getLoopTickAtAbsoluteTick({
absoluteTick: tick,
loopEndTick: this.loopEndTick,
loopStartTick: this.loopStartTick,
});
}
}

function validateLoopRange(loopStartTick: Tick, loopEndTick: Tick): void {
Expand Down
Loading
Loading