From 603b8f2db08ec49ffc6683853d5fd309ce2744ae Mon Sep 17 00:00:00 2001 From: Jaewan Park Date: Sun, 7 Jun 2026 03:20:54 +0900 Subject: [PATCH 1/3] feat: implement transport pause and playhead --- docs/audio-engine.md | 6 +- .../07-transport-pause-and-playhead.md | 2 +- src/app/App.tsx | 53 +++++++++++++- src/audio/browser-audio-engine.ts | 16 +++- src/audio/lookahead-scheduler.ts | 61 ++++++++++++++-- src/audio/types.ts | 3 + .../drum-sequencer/DrumSequencer.module.css | 14 ++++ src/features/drum-sequencer/DrumSequencer.tsx | 22 ++++++ src/features/piano-roll/PianoRoll.module.css | 12 +++ src/features/piano-roll/PianoRoll.tsx | 29 ++++++++ src/features/transport/TransportBar.tsx | 16 +++- src/styles/tokens.css | 5 ++ tests/unit/audio/lookahead-scheduler.test.ts | 73 +++++++++++++++++++ 13 files changed, 293 insertions(+), 19 deletions(-) diff --git a/docs/audio-engine.md b/docs/audio-engine.md index 80f1344..c5da018 100644 --- a/docs/audio-engine.md +++ b/docs/audio-engine.md @@ -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 @@ -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: @@ -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. diff --git a/docs/features/07-transport-pause-and-playhead.md b/docs/features/07-transport-pause-and-playhead.md index 71a1e92..326f00e 100644 --- a/docs/features/07-transport-pause-and-playhead.md +++ b/docs/features/07-transport-pause-and-playhead.md @@ -1,7 +1,7 @@ # Feature: 07 Transport Pause and Playhead ## Status -Planned +In Review ## Goal diff --git a/src/app/App.tsx b/src/app/App.tsx index 22fba48..54fb974 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { BUNDLED_DRUM_SAMPLES, @@ -71,8 +71,32 @@ export function App() { useState("leadSynth"); const [selectedClip, setSelectedClip] = useState(() => createEmptyHybridClip()); const selectedClipRef = useRef(selectedClip); + const [playheadTick, setPlayheadTick] = useState(0); + const playheadTickRef = useRef(0); const [audioError, setAudioError] = useState(null); + 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; setSelectedClip(nextClip); @@ -82,6 +106,11 @@ export function App() { } } + function commitPlayheadTick(nextTick: Tick) { + playheadTickRef.current = nextTick; + setPlayheadTick(nextTick); + } + function handleDrumStepToggle(laneId: DrumLaneId, stepIndex: number) { commitSelectedClip( toggleDrumStep({ @@ -167,25 +196,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.", ); @@ -248,15 +290,18 @@ export function App() { drumLanes={selectedClip.drumLanes} onLaneMove={handleLaneMove} onLaneSampleChange={handleLaneSampleChange} + playheadTick={playheadTick} onStepToggle={handleDrumStepToggle} samples={BUNDLED_DRUM_SAMPLES} /> diff --git a/src/audio/browser-audio-engine.ts b/src/audio/browser-audio-engine.ts index 5f0a450..0865153 100644 --- a/src/audio/browser-audio-engine.ts +++ b/src/audio/browser-audio-engine.ts @@ -144,6 +144,7 @@ export class BrowserAudioEngine implements AudioEngine { lookaheadMs, ppq, scheduleAheadTime, + startTick, tempoBpm, }: StartSampleLoopOptions): Promise { return this.startClipLoop({ @@ -154,6 +155,7 @@ export class BrowserAudioEngine implements AudioEngine { ppq, sampleEvents: events, scheduleAheadTime, + startTick, tempoBpm, }); } @@ -166,6 +168,7 @@ export class BrowserAudioEngine implements AudioEngine { ppq, sampleEvents, scheduleAheadTime, + startTick, tempoBpm, }: StartClipLoopOptions): Promise { await this.resume(); @@ -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 { diff --git a/src/audio/lookahead-scheduler.ts b/src/audio/lookahead-scheduler.ts index e14045b..0a4fb78 100644 --- a/src/audio/lookahead-scheduler.ts +++ b/src/audio/lookahead-scheduler.ts @@ -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; @@ -37,6 +37,7 @@ export interface ScheduleWindowOptions { loopEndTick?: Tick; loopStartTick?: Tick; ppq?: number; + startTick?: Tick; tempoBpm: number; windowEndTick: Tick; windowStartTick: Tick; @@ -56,6 +57,10 @@ export interface LookaheadSchedulerOptions { tempoBpm: number; } +export interface StartSchedulerOptions { + startTick?: Tick; +} + const DEFAULT_LOOKAHEAD_MS = 25; const DEFAULT_SCHEDULE_AHEAD_TIME = 0.1; @@ -77,6 +82,7 @@ export function collectScheduledEventsForWindow({ loopEndTick = TICKS_PER_4_4_BAR, loopStartTick = 0, ppq = DEFAULT_PPQ, + startTick = loopStartTick, tempoBpm, windowEndTick, windowStartTick, @@ -122,6 +128,7 @@ export function collectScheduledEventsForWindow({ audioTime: tickToAudioTime({ audioStartTime, ppq, + startTick, tempoBpm, tick: absoluteTick, }), @@ -166,6 +173,7 @@ export class LookaheadScheduler { private audioStartTime: number | null = null; private events: readonly TEvent[]; private nextScheduleTick: Tick; + private startTick: Tick; private status: SchedulerStatus = "stopped"; private timerId: SchedulerTimerId | null = null; @@ -200,16 +208,18 @@ export class LookaheadScheduler { 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( @@ -220,14 +230,26 @@ export class LookaheadScheduler { 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(); @@ -251,13 +273,14 @@ export class LookaheadScheduler { 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, }); @@ -278,12 +301,14 @@ export class LookaheadScheduler { 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); @@ -294,6 +319,7 @@ export class LookaheadScheduler { loopEndTick: this.loopEndTick, loopStartTick: this.loopStartTick, ppq: this.ppq, + startTick: this.startTick, tempoBpm: this.tempoBpm, windowEndTick, windowStartTick, @@ -305,6 +331,25 @@ export class LookaheadScheduler { 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 { diff --git a/src/audio/types.ts b/src/audio/types.ts index 78d750b..87a4a95 100644 --- a/src/audio/types.ts +++ b/src/audio/types.ts @@ -36,6 +36,7 @@ export interface StartSampleLoopOptions { lookaheadMs?: number; ppq?: number; scheduleAheadTime?: number; + startTick?: Tick; tempoBpm: number; } @@ -47,6 +48,7 @@ export interface StartClipLoopOptions { lookaheadMs?: number; ppq?: number; scheduleAheadTime?: number; + startTick?: Tick; tempoBpm: number; } @@ -66,6 +68,7 @@ export interface AudioEngine { loadSample(sampleId: SampleId): Promise; loadAllSamples(): Promise; playSample(sampleId: SampleId, options?: PlaySampleOptions): Promise; + pauseLoop(): TransportSnapshot; startClipLoop(options: StartClipLoopOptions): Promise; startSampleLoop(options: StartSampleLoopOptions): Promise; stopLoop(): TransportSnapshot; diff --git a/src/features/drum-sequencer/DrumSequencer.module.css b/src/features/drum-sequencer/DrumSequencer.module.css index bef9fdc..9b346ed 100644 --- a/src/features/drum-sequencer/DrumSequencer.module.css +++ b/src/features/drum-sequencer/DrumSequencer.module.css @@ -45,6 +45,11 @@ width: var(--drum-step-width); } +.stepNumberPlayhead { + color: var(--color-playhead); + text-shadow: var(--playhead-shadow); +} + .lane { align-items: center; cursor: grab; @@ -190,6 +195,15 @@ box-shadow: var(--step-active-shadow); } +.stepButtonPlayhead { + border-color: var(--color-playhead); + box-shadow: var(--playhead-shadow), var(--control-inset-shadow); +} + +.stepButtonActive.stepButtonPlayhead { + box-shadow: var(--playhead-shadow), var(--step-active-shadow); +} + @media (max-width: 900px) { .stepSequencerPanel { --drum-lane-width: 188px; diff --git a/src/features/drum-sequencer/DrumSequencer.tsx b/src/features/drum-sequencer/DrumSequencer.tsx index ec21d86..936e2e1 100644 --- a/src/features/drum-sequencer/DrumSequencer.tsx +++ b/src/features/drum-sequencer/DrumSequencer.tsx @@ -16,11 +16,13 @@ import { type DrumLaneDefinition, type DrumLaneId, } from "../../model"; +import { TICKS_PER_16_STEP, TICKS_PER_4_4_BAR, type Tick } from "../../utils"; import styles from "./DrumSequencer.module.css"; interface DrumSequencerProps { drumEvents: readonly DrumEvent[]; drumLanes: readonly DrumLaneDefinition[]; + playheadTick: Tick; samples: readonly BundledSampleMeta[]; onLaneMove: (laneId: DrumLaneId, targetIndex: number) => void; onLaneSampleChange: ( @@ -46,6 +48,7 @@ const SAMPLE_MENU_MIN_WIDTH_PX = 240; export function DrumSequencer({ drumEvents, drumLanes, + playheadTick, samples, onLaneMove, onLaneSampleChange, @@ -59,6 +62,7 @@ export function DrumSequencer({ const [draggingLaneId, setDraggingLaneId] = useState(null); const sampleButtonRefs = useRef(new Map()); const openSampleLane = drumLanes.find((lane) => lane.id === openSampleLaneId); + const playheadStepIndex = getPlayheadStepIndex(playheadTick); useEffect(() => { if (!openSampleLaneId) { @@ -148,6 +152,10 @@ export function DrumSequencer({ @@ -208,6 +216,7 @@ export function DrumSequencer({ ); const isAlternateGroup = Math.floor(stepIndex / 4) % 2 === 1; const isGroupStart = stepIndex > 0 && stepIndex % 4 === 0; + const isPlayheadStep = stepIndex === playheadStepIndex; return (