From d158d0870f40a29fc6acab7feb6a4bd45d4c2fc3 Mon Sep 17 00:00:00 2001 From: Daniel Saewitz Date: Sat, 19 Sep 2026 15:39:19 -0400 Subject: [PATCH 1/5] Move pausedAtTrackTime into machine context as seekTarget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The track machine and Track class had a dual-authority problem: the machine owned lifecycle (states, transitions) but Track owned timing truth (pausedAtTrackTime). Neither was the single source of truth, causing bugs where SEEK events were dropped in idle/loading, activation erased pending seeks, and the currentTime getter reported stale values. This refactor: - Adds seekTarget to TrackContext as the single owner of "where should playback resume" - SEEK in idle/loading is now just assign({ seekTarget }) — no HTML5 side effects, no playback-method guards needed - ACTIVATE/DEACTIVATE atomically reset seekTarget alongside other timing state via clearSeekTarget - freezePausedTime (webaudio pause) is now an assign that writes seekTarget directly into context - Splits activateAndPlayCurrent in the queue machine into activateCurrent + playCurrent so GOTO does activate → seek → play in the correct order (activate no longer erases the seek) - _playHtml5 reconciles seekTarget with audio.currentTime at play time, handling seek-before-load without needing idle-state side effects - Mid-stream crossover writes to _crossoverComputedOffset then syncs via syncSeekTargetFromCrossover assign action Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Queue.ts | 9 +- src/Track.ts | 162 ++++++++++--------------------- src/machines/queue.machine.ts | 31 +++--- src/machines/track.machine.ts | 56 +++++------ tests/unit/features.test.ts | 1 + tests/unit/queue.test.ts | 10 +- tests/unit/track.machine.test.ts | 1 + 7 files changed, 106 insertions(+), 164 deletions(-) diff --git a/src/Queue.ts b/src/Queue.ts index dad04dd..9e7f8a6 100644 --- a/src/Queue.ts +++ b/src/Queue.ts @@ -146,13 +146,8 @@ export class Queue implements TrackQueueRef { deactivateEndedTrack: ({ context }) => { this._trackAt(context.currentTrackIndex)?.deactivate(); }, - activateAndPlayCurrent: ({ context }) => { - const track = this._trackAt(context.currentTrackIndex); - if (!track) return; - track.activate(); - if (this._scheduledNextIndex !== track.index) { - track.play(); - } + activateCurrent: ({ context }) => { + this._trackAt(context.currentTrackIndex)?.activate(); }, playOrContinueGapless: ({ context }) => { const cur = this._trackAt(context.currentTrackIndex); diff --git a/src/Track.ts b/src/Track.ts index 2e1c61f..b42f96b 100644 --- a/src/Track.ts +++ b/src/Track.ts @@ -2,7 +2,7 @@ // Track — owns one audio track's Web Audio nodes and drives TrackMachine // --------------------------------------------------------------------------- -import { createActor, fromPromise } from 'xstate'; +import { createActor, fromPromise, assign } from 'xstate'; import { getAudioContext, resumeAudioContext } from './utils/audioContext'; import { createTrackMachine } from './machines/track.machine'; import { fetchDecodeMachine } from './machines/fetchDecode.machine'; @@ -122,8 +122,8 @@ export class Track { private _waRefCtxTime = 0; /** Track position (seconds) at the start of the current playback segment. */ private _waRefTrackTime = 0; - /** Track-time (seconds) frozen at the moment of the most recent pause. */ - private pausedAtTrackTime = 0; + /** Temp storage for crossover offset — read by syncSeekTargetFromCrossover. */ + private _crossoverComputedOffset = 0; // ---- FSM ----------------------------------------------------------------- private readonly _actor; @@ -191,6 +191,7 @@ export class Track { notifiedLookahead: false, fetchStarted: false, pendingPlay: false, + seekTarget: 0, }; const machine = createTrackMachine(initialContext).provide({ guards: { @@ -231,14 +232,6 @@ export class Track { if (!buf || !this.ctx) throw new Error('No ArrayBuffer or AudioContext'); this.audioBuffer = await this.ctx.decodeAudioData(buf); this._maybeComputeBufferAlignment(); - // NOTE: do NOT notify the queue from here. The fetchDecode child - // is about to sendParent('BUFFER_READY'), which the track machine - // handles by transitioning state (and, in html5, by performing - // the mid-stream crossover). If we notified the queue first, it - // would observe a stale playbackType ('HTML5') and incorrectly - // defer next-track preload via the HTML5 deferral guard. The - // notification is emitted from the BUFFER_READY transitions in - // track.machine, after the crossover/state update is in effect. }), }, }), @@ -248,13 +241,16 @@ export class Track { this.preload(); resumeAudioContext(); }, - playHtml5: () => this._playHtml5(), - startSourceNode: () => { - this._startSourceNode(this.pausedAtTrackTime); + playHtml5: ({ context }: { context: TrackContext }) => this._playHtml5(context.seekTarget), + startSourceNode: ({ context }: { context: TrackContext }) => { + this._startSourceNode(context.seekTarget); }, crossoverHtml5ToWebAudio: ({ context }: { context: TrackContext }) => { - this._crossoverHtml5ToWebAudio(context.isPlaying); + this._crossoverHtml5ToWebAudio(context.isPlaying, context.seekTarget); }, + syncSeekTargetFromCrossover: assign({ + seekTarget: () => this._crossoverComputedOffset, + }), startScheduledSourceNode: ({ context }: { context: TrackContext }) => { const when = context.scheduledStartContextTime; if (when === null || !this.ctx || !this.audioBuffer || !this.gainNode) return; @@ -264,14 +260,7 @@ export class Track { this.sourceNode.buffer = this.audioBuffer; this.sourceNode.playbackRate.value = this.queueRef.playbackRate; this.sourceNode.connect(this.gainNode); - // gainNode → destination connected once at ctx setup; do not re-connect - // here or the gainNode's output is duplicated (Web Audio sums multiple - // edges between the same pair of nodes). this.sourceNode.onended = this._handleWebAudioEnded; - // Skip past any decoded-buffer start padding (ID3 tags, encoder - // priming) so the gapless transition begins at music-time 0, not at - // half a second of silence/garbage. _bufferStartPaddingSec is - // computed at decode time via _ensureBufferAlignment. this.sourceNode.start(when, this._bufferStartPaddingSec); const bufRemaining = this.audioBuffer.duration - this._bufferStartPaddingSec; const schedRate = this.sourceNode.playbackRate.value || 1; @@ -286,32 +275,30 @@ export class Track { }, startProgressLoop: () => this.startProgressLoop(), pauseHtml5: () => this.audio.pause(), - freezePausedTime: () => { - const t = this.currentTime; - this.pausedAtTrackTime = isFinite(t) ? t : 0; - }, + freezePausedTime: assign({ + seekTarget: () => { + const t = this.currentTime; + return isFinite(t) ? t : 0; + }, + }), stopSourceNode: () => this._stopSourceNode(), stopProgressLoop: () => this._stopProgressLoop(), reportProgress: () => { queueMicrotask(() => this.queueRef.onProgress(this.toInfo())); }, - seekHtml5: () => this._seekHtml5(), - seekWebAudio: () => this._seekWebAudio(), + seekHtml5: ({ context }: { context: TrackContext }) => this._seekHtml5(context.seekTarget), + seekWebAudio: ({ context }: { context: TrackContext }) => this._seekWebAudio(context.seekTarget), resetHtml5Element: () => { this.audio.currentTime = 0; }, resetTiming: () => { this._waRefCtxTime = 0; this._waRefTrackTime = 0; - this.pausedAtTrackTime = 0; }, notifyTrackEnded: () => { queueMicrotask(() => this.queueRef.onTrackEnded(this)); }, notifyBufferReady: () => { - // Emitted from BUFFER_READY transitions so the queue observes the - // post-transition state (e.g. WEBAUDIO after a crossover) rather - // than the pre-transition state. queueMicrotask(() => this.queueRef.onTrackBufferReady(this)); }, }, @@ -338,16 +325,11 @@ export class Track { seek(time: number): void { if (!isFinite(time)) return; const clamped = Math.max(0, isNaN(this.duration) ? time : Math.min(time, this.duration)); - this.pausedAtTrackTime = clamped; this._actor.send({ type: 'SEEK', time: clamped }); } setVolume(v: number): void { const vol = Math.min(1, Math.max(0, v)); - // When the HTML5 element is routed through MediaElementAudioSourceNode, - // master volume lives entirely on gainNode — keep audio.volume at 1 so the - // browser-side and AudioContext-side gains don't multiply (giving v²). - // Otherwise (HTML5_ONLY mode, or pre-ctx), audio.volume is the master. if (this._mediaElementSource) { this.audio.volume = 1; } else { @@ -358,12 +340,6 @@ export class Track { } setPlaybackRate(rate: number): void { - // Freeze current track position at the old rate before switching. - // Skip the anchor rewrite during the scheduling lead (when the source - // is scheduled but ctx.currentTime hasn't reached _waRefCtxTime yet) — - // the source hasn't started producing audio, so the anchors are still - // correct for the future start. Rewriting them with a negative elapsed - // time would corrupt playbackEndContextTime and cause overlap/gap. if (this.ctx && this.sourceNode && this._actor.getSnapshot().context.isPlaying && this.ctx.currentTime >= this._waRefCtxTime) { const oldRate = this.sourceNode.playbackRate.value; @@ -373,9 +349,6 @@ export class Track { this.audio.playbackRate = rate; if (this.sourceNode) { this.sourceNode.playbackRate.value = rate; - // Re-issue the hard stop ceiling at the new end time. The spec - // allows repeated stop() calls (latest wins), so this overwrites - // the stale stop scheduled at the old rate. if (this.audioBuffer && this.ctx) { const remaining = this.audioBuffer.duration - this._bufferStartPaddingSec - this._waRefTrackTime; if (remaining > 0) { @@ -428,23 +401,19 @@ export class Track { this.audioBuffer = null; this.gainNode?.disconnect(); this.gainNode = null; - this._actor.stop(); // Stops spawned fetchDecode child actor, aborting in-flight fetches + this._actor.stop(); } // -------------------------------------------------------------------------- // Gapless scheduling (called by Queue) // -------------------------------------------------------------------------- - /** Schedule the HTML5 gain node to mute at `when` — safety valve so an - * HTML5 element that runs past the predicted end is silenced instead of - * overlapping the next track. No-op if the HTML5 gain path isn't active. */ scheduleHtml5Mute(when: number): void { if (!this._html5GainNode || !this.ctx) return; this._html5GainNode.gain.setValueAtTime(1, when - 0.005); this._html5GainNode.gain.linearRampToValueAtTime(0, when); } - /** Cancel any scheduled HTML5 gain mute (called when gapless is cancelled). */ cancelHtml5Mute(): void { if (!this._html5GainNode || !this.ctx) return; this._html5GainNode.gain.cancelScheduledValues(this.ctx.currentTime); @@ -469,22 +438,25 @@ export class Track { // Getters // -------------------------------------------------------------------------- + get seekTarget(): number { + return this._actor.getSnapshot().context.seekTarget; + } + get currentTime(): number { const snap = this._actor.getSnapshot(); if (snap.value === 'webaudio') { - if (!snap.context.isPlaying) return this.pausedAtTrackTime; + if (!snap.context.isPlaying) return snap.context.seekTarget; if (!this.ctx) return 0; return Math.max(0, this._waRefTrackTime + (this.ctx.currentTime - this._waRefCtxTime) * this.queueRef.playbackRate); } + if ((snap.value === 'idle' || snap.value === 'loading') && snap.context.seekTarget > 0) { + return snap.context.seekTarget; + } return this.audio.currentTime; } get duration(): number { const snap = this._actor.getSnapshot(); - // When playing via HTML5, use audio.duration so it stays consistent - // with audio.currentTime. audioBuffer.duration can differ (codec padding, - // container overhead, VBR headers) causing gapless scheduling to start - // the next track before the HTML5 element actually finishes. if (snap.value === 'html5' && !isNaN(this.audio.duration)) { return this.audio.duration; } @@ -526,19 +498,6 @@ export class Track { return this._actor.getSnapshot().context.scheduledStartContextTime; } - /** - * Context-clock time at which the currently playing Web Audio source will - * reach the end of its buffer, derived from the live playback anchor - * (_waRefCtxTime/_waRefTrackTime). The anchor is re-established every time - * a source node starts (scheduled gapless start, resume, seek, crossover), - * so this stays correct across interruptions — unlike - * scheduledStartContextTime + duration, which goes stale the moment a - * pause/resume re-anchors the source at a later context time. For an - * uninterrupted gapless chain it is exact (the anchor IS the scheduled - * start), so next-track scheduling stays sample-accurate. - * - * Null when no source node is active (idle/html5/loading, or paused). - */ get playbackEndContextTime(): number | null { const snap = this._actor.getSnapshot(); if (snap.value !== 'webaudio' || !snap.context.isPlaying) return null; @@ -574,9 +533,19 @@ export class Track { // Private: HTML5 helpers // -------------------------------------------------------------------------- - private _playHtml5(): void { + private _playHtml5(seekTarget: number): void { if (this.audio.preload !== 'auto') this.audio.preload = 'auto'; this.audio.playbackRate = this.queueRef.playbackRate; + if (isFinite(seekTarget) && seekTarget > this.audio.currentTime + 0.5) { + if (this.audio.readyState >= HTMLMediaElement.HAVE_METADATA) { + this.audio.currentTime = seekTarget; + } else { + const target = seekTarget; + this.audio.addEventListener('loadedmetadata', () => { + this.audio.currentTime = target; + }, { once: true }); + } + } const promise = this.audio.play(); if (promise) { promise.catch((err: unknown) => { @@ -591,17 +560,16 @@ export class Track { } } - private _seekHtml5(): void { - const clamped = this.pausedAtTrackTime; - if (!isFinite(clamped)) return; + private _seekHtml5(target: number): void { + if (!isFinite(target)) return; if (this.audio.preload !== 'auto') this.audio.preload = 'auto'; if (this.audio.readyState >= HTMLMediaElement.HAVE_METADATA) { - this.audio.currentTime = clamped; + this.audio.currentTime = target; } else { this.audio.addEventListener( 'loadedmetadata', () => { - this.audio.currentTime = clamped; + this.audio.currentTime = target; }, { once: true } ); @@ -634,29 +602,25 @@ export class Track { * before we read it as the Web Audio start offset, so there's no brief * double-audio window at the crossover point. */ - private _crossoverHtml5ToWebAudio(wasPlaying: boolean): void { + private _crossoverHtml5ToWebAudio(wasPlaying: boolean, seekTarget: number): void { if (!this.ctx || !this.audioBuffer || !this.gainNode) return; const htmlTime = this.audio.currentTime; const offset = - isFinite(this.pausedAtTrackTime) && this.pausedAtTrackTime > htmlTime - ? this.pausedAtTrackTime + isFinite(seekTarget) && seekTarget > htmlTime + ? seekTarget : htmlTime; - this.pausedAtTrackTime = isFinite(offset) ? offset : 0; + this._crossoverComputedOffset = isFinite(offset) ? offset : 0; if (!wasPlaying) { this.audio.pause(); this.queueRef.onDebug( - `crossoverHtml5ToWebAudio track=${this.index} offset=${this.pausedAtTrackTime.toFixed(3)} wasPlaying=false` + `crossoverHtml5ToWebAudio track=${this.index} offset=${this._crossoverComputedOffset.toFixed(3)} wasPlaying=false` ); return; } - // Start the WebAudio source first to learn the exact context-clock time - // it will begin producing audio (the "when" returned by _startSourceNode). - // Then align the HTML5 fade-out to that same instant so both crossfade - // halves overlap at constant-summed gain. - const when = this._startSourceNode(this.pausedAtTrackTime, CROSSOVER_FADE_SEC); + const when = this._startSourceNode(this._crossoverComputedOffset, CROSSOVER_FADE_SEC); if (when !== null && this._html5GainNode) { this._html5GainNode.gain.cancelScheduledValues(when); @@ -673,9 +637,6 @@ export class Track { } }, delayMs); } else if (!this._html5GainNode) { - // No MediaElementSource path (CORS blocked or legacy browser). - // Delay the HTML5 pause until the WebAudio source actually starts - // at `when` so there's no silence gap during the scheduling lead. if (when !== null && this.ctx) { const delayMs = (when - this.ctx.currentTime) * 1000; setTimeout(() => { @@ -691,7 +652,7 @@ export class Track { } this.queueRef.onDebug( - `crossoverHtml5ToWebAudio track=${this.index} offset=${this.pausedAtTrackTime.toFixed(3)} wasPlaying=true fade=${CROSSOVER_FADE_SEC}s mediaSource=${!!this._html5GainNode} when=${when?.toFixed(3) ?? 'null'}` + `crossoverHtml5ToWebAudio track=${this.index} offset=${this._crossoverComputedOffset.toFixed(3)} wasPlaying=true fade=${CROSSOVER_FADE_SEC}s mediaSource=${!!this._html5GainNode} when=${when?.toFixed(3) ?? 'null'}` ); } @@ -727,26 +688,11 @@ export class Track { this.sourceNode.buffer = this.audioBuffer; this.sourceNode.playbackRate.value = this.queueRef.playbackRate; - // Schedule the source at an explicit future time rather than start(0). - // start(0) dispatches to the audio render thread asynchronously — the - // actual start lags the main-thread currentTime read by up to one - // hardware callback period (5-100 ms depending on device). That δ makes - // playbackEndContextTime compute an end time δ too early, causing the - // next gapless track to overlap briefly. Using start(when) with an - // explicit when that matches _waRefCtxTime eliminates the mismatch. const lead = wasSuspended ? 0.15 : Math.max(0.02, 2 * ((this.ctx as unknown as { baseLatency?: number }).baseLatency || 0) + 0.01); const when = this.ctx.currentTime + lead; - // Advance the buffer start position by the lead so the source begins - // from where playback will actually be at `when`, not where it was at - // the moment we read currentTime. This keeps the currentTime formula - // (waRefTrackTime + (ctx.currentTime - waRefCtxTime) * rate) equal to - // `offset` immediately after the call without accumulating drift across - // pause/resume cycles. During crossover it also aligns the WebAudio - // source to where the HTML5 element will be at the fade point. - // The skipped interval (≤20 ms at 1× rate) is below audible threshold. const rate = this.queueRef.playbackRate; const maxOffset = this.audioBuffer.duration - this._bufferStartPaddingSec; const effectiveOffset = Math.min(offset + lead * rate, maxOffset); @@ -766,9 +712,6 @@ export class Track { this._waRefTrackTime = effectiveOffset; this.sourceNode.start(when, effectiveOffset + this._bufferStartPaddingSec); - // Hard ceiling: stop the source at its computed end time so any - // platform-level late start (render thread couldn't honor `when`) - // produces an inaudible truncation instead of overlap with the next track. const bufRemaining = this.audioBuffer.duration - this._bufferStartPaddingSec - effectiveOffset; if (bufRemaining > 0) { this.sourceNode.stop(when + bufRemaining / rate); @@ -792,13 +735,12 @@ export class Track { this.sourceNode = null; } - private _seekWebAudio(): void { + private _seekWebAudio(target: number): void { const snap = this._actor.getSnapshot(); const wasPlaying = snap.context.isPlaying; - const clamped = this.pausedAtTrackTime; this._stopSourceNode(); if (wasPlaying) { - this._startSourceNode(clamped); + this._startSourceNode(target); } } diff --git a/src/machines/queue.machine.ts b/src/machines/queue.machine.ts index c7eafa8..93fef09 100644 --- a/src/machines/queue.machine.ts +++ b/src/machines/queue.machine.ts @@ -12,7 +12,7 @@ // Named actions (no-op defaults here, real implementations via .provide()): // Actions before assign() see the OLD context (e.g. deactivateCurrent // sees the old currentTrackIndex). Actions after assign() see the NEW -// context (e.g. activateAndPlayCurrent sees the incremented index). +// context (e.g. activateCurrent sees the incremented index). // Implementations MUST use the ({ context }) parameter, NOT getSnapshot(). // --------------------------------------------------------------------------- @@ -101,7 +101,7 @@ export function createQueueMachine(initialContext: QueueContext) { // --- side-effect actions (no-op defaults, provided by Queue.ts) --- deactivateCurrent: () => {}, deactivateEndedTrack: () => {}, - activateAndPlayCurrent: () => {}, + activateCurrent: () => {}, playOrContinueGapless: () => {}, cancelAllGapless: () => {}, notifyStartNewTrack: () => {}, @@ -151,8 +151,9 @@ export function createQueueMachine(initialContext: QueueContext) { 'deactivateCurrent', 'cancelAllGapless', 'gotoTrackIndex', + 'activateCurrent', 'seekCurrentToStartTime', - 'activateAndPlayCurrent', + 'playCurrent', 'notifyStartNewTrack', 'updateMediaSessionMetadata', 'preloadAhead', @@ -191,7 +192,8 @@ export function createQueueMachine(initialContext: QueueContext) { actions: [ 'cancelAllGapless', 'decrementTrackCount', - 'activateAndPlayCurrent', + 'activateCurrent', + 'playCurrent', 'notifyStartNewTrack', 'updateMediaSessionMetadata', 'preloadAhead', @@ -214,7 +216,8 @@ export function createQueueMachine(initialContext: QueueContext) { 'deactivateCurrent', 'cancelAllGapless', 'advanceToNextTrack', - 'activateAndPlayCurrent', + 'activateCurrent', + 'playCurrent', 'notifyStartNewTrack', 'notifyPlayNextTrack', 'updateMediaSessionMetadata', @@ -226,7 +229,8 @@ export function createQueueMachine(initialContext: QueueContext) { 'deactivateCurrent', 'cancelAllGapless', 'goToPreviousTrack', - 'activateAndPlayCurrent', + 'activateCurrent', + 'playCurrent', 'notifyStartNewTrack', 'notifyPlayPreviousTrack', 'updateMediaSessionMetadata', @@ -240,8 +244,9 @@ export function createQueueMachine(initialContext: QueueContext) { 'deactivateCurrent', 'cancelAllGapless', 'gotoTrackIndex', + 'activateCurrent', 'seekCurrentToStartTime', - 'activateAndPlayCurrent', + 'playCurrent', 'notifyStartNewTrack', 'updateMediaSessionMetadata', 'preloadAhead', @@ -328,7 +333,8 @@ export function createQueueMachine(initialContext: QueueContext) { 'deactivateCurrent', 'cancelAllGapless', 'advanceToNextTrack', - 'activateAndPlayCurrent', + 'activateCurrent', + 'playCurrent', 'notifyStartNewTrack', 'notifyPlayNextTrack', 'updateMediaSessionMetadata', @@ -340,7 +346,8 @@ export function createQueueMachine(initialContext: QueueContext) { 'deactivateCurrent', 'cancelAllGapless', 'goToPreviousTrack', - 'activateAndPlayCurrent', + 'activateCurrent', + 'playCurrent', 'notifyStartNewTrack', 'notifyPlayPreviousTrack', 'updateMediaSessionMetadata', @@ -355,8 +362,9 @@ export function createQueueMachine(initialContext: QueueContext) { 'deactivateCurrent', 'cancelAllGapless', 'gotoTrackIndex', + 'activateCurrent', 'seekCurrentToStartTime', - 'activateAndPlayCurrent', + 'playCurrent', 'notifyStartNewTrack', 'updateMediaSessionMetadata', 'preloadAhead', @@ -424,8 +432,9 @@ export function createQueueMachine(initialContext: QueueContext) { 'deactivateCurrent', 'cancelAllGapless', 'gotoTrackIndex', + 'activateCurrent', 'seekCurrentToStartTime', - 'activateAndPlayCurrent', + 'playCurrent', 'notifyStartNewTrack', 'updateMediaSessionMetadata', 'preloadAhead', diff --git a/src/machines/track.machine.ts b/src/machines/track.machine.ts index b5396aa..63c2b14 100644 --- a/src/machines/track.machine.ts +++ b/src/machines/track.machine.ts @@ -47,6 +47,8 @@ export interface TrackContext { fetchStarted: boolean; /** True when PLAY was received in webAudioOnly mode before buffer is ready. */ pendingPlay: boolean; + /** Track-time (seconds) the user last seeked to, or frozen pause position. */ + seekTarget: number; } // ---- Events ---------------------------------------------------------------- @@ -91,6 +93,7 @@ export function createTrackMachine(initialContext: TrackContext) { playHtml5: () => {}, startSourceNode: () => {}, crossoverHtml5ToWebAudio: () => {}, + syncSeekTargetFromCrossover: () => {}, notifyBufferReady: () => {}, startScheduledSourceNode: () => {}, startProgressLoop: () => {}, @@ -105,6 +108,10 @@ export function createTrackMachine(initialContext: TrackContext) { resetTiming: () => {}, notifyTrackEnded: () => {}, triggerFetchForPendingPlay: () => {}, + setSeekTarget: assign({ + seekTarget: ({ event }) => (event as { type: 'SEEK'; time: number }).time, + }), + clearSeekTarget: assign({ seekTarget: () => 0 }), setPendingPlay: assign({ pendingPlay: () => true }), clearPendingPlay: assign({ pendingPlay: () => false }), setIsPlaying: assign({ isPlaying: () => true }), @@ -177,6 +184,9 @@ export function createTrackMachine(initialContext: TrackContext) { // ----------------------------------------------------------------- idle: { on: { + SEEK: { + actions: ['setSeekTarget', 'reportProgress'], + }, HTML5_ENDED: { actions: ['notifyTrackEnded'], }, @@ -188,6 +198,7 @@ export function createTrackMachine(initialContext: TrackContext) { 'stopProgressLoop', 'clearScheduleAndLookahead', 'clearPendingPlay', + 'clearSeekTarget', ], }, ACTIVATE: { @@ -196,6 +207,7 @@ export function createTrackMachine(initialContext: TrackContext) { 'resetHtml5Element', 'clearScheduleAndLookahead', 'clearPendingPlay', + 'clearSeekTarget', ], }, PLAY: [ @@ -214,12 +226,6 @@ export function createTrackMachine(initialContext: TrackContext) { }, { target: 'html5', - // triggerFetchForPendingPlay also kicks off fetch+decode for the - // CURRENT track, not just the next one. That way BUFFER_READY - // fires while we're in html5, and crossoverHtml5ToWebAudio can - // hand the active track over to Web Audio mid-stream. The - // canStartFetch guard inside START_FETCH makes this a no-op if - // a fetch is already in flight (e.g. PLAY from loading state). actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop', 'triggerFetchForPendingPlay'], }, ], @@ -279,21 +285,12 @@ export function createTrackMachine(initialContext: TrackContext) { target: 'webaudio', actions: 'setPlayingWebAudio', }, - // Mid-stream crossover to Web Audio. Running `crossoverHtml5ToWebAudio` - // synchronously captures audio.currentTime, pauses the HTML5 element, - // and starts a Web Audio source node at that exact offset. After the - // transition completes we're in webaudio state with isPlaying preserved - // (true if the track was playing, false if paused), so a subsequent - // PLAY in webaudio state will resume from pausedAtTrackTime. - // - // clearNotifiedLookahead resets the gapless lookahead flag so the - // webaudio progress loop can re-trigger scheduling with the accurate - // shared-clock end time, replacing any stale HTML5-clock prediction. BUFFER_READY: { target: 'webaudio', actions: [ 'setLoadedState', 'crossoverHtml5ToWebAudio', + 'syncSeekTargetFromCrossover', 'setPlaybackTypeWebAudio', 'clearNotifiedLookahead', 'notifyBufferReady', @@ -304,6 +301,7 @@ export function createTrackMachine(initialContext: TrackContext) { }, SEEK: { actions: [ + 'setSeekTarget', 'seekHtml5', 'reportProgress', ], @@ -323,6 +321,7 @@ export function createTrackMachine(initialContext: TrackContext) { 'stopProgressLoop', 'resetTiming', 'resetHtml5Element', + 'clearSeekTarget', ], }, URL_RESOLVED: { @@ -330,7 +329,7 @@ export function createTrackMachine(initialContext: TrackContext) { }, DEACTIVATE: { target: 'idle', - actions: ['clearIsPlaying', 'pauseHtml5', 'resetHtml5Element', 'resetTiming', 'stopProgressLoop'], + actions: ['clearIsPlaying', 'pauseHtml5', 'resetHtml5Element', 'resetTiming', 'stopProgressLoop', 'clearSeekTarget'], }, }, }, @@ -340,6 +339,9 @@ export function createTrackMachine(initialContext: TrackContext) { // ----------------------------------------------------------------- loading: { on: { + SEEK: { + actions: ['setSeekTarget', 'reportProgress'], + }, BUFFER_LOADING: { actions: 'setLoadingState', }, @@ -374,12 +376,6 @@ export function createTrackMachine(initialContext: TrackContext) { }, { target: 'html5', - // triggerFetchForPendingPlay also kicks off fetch+decode for the - // CURRENT track, not just the next one. That way BUFFER_READY - // fires while we're in html5, and crossoverHtml5ToWebAudio can - // hand the active track over to Web Audio mid-stream. The - // canStartFetch guard inside START_FETCH makes this a no-op if - // a fetch is already in flight (e.g. PLAY from loading state). actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop', 'triggerFetchForPendingPlay'], }, ], @@ -405,11 +401,12 @@ export function createTrackMachine(initialContext: TrackContext) { 'clearPendingPlay', 'resetTiming', 'resetHtml5Element', + 'clearSeekTarget', ], }, DEACTIVATE: { target: 'idle', - actions: ['clearIsPlaying', 'clearPendingPlay', 'resetTiming'], + actions: ['clearIsPlaying', 'clearPendingPlay', 'resetTiming', 'clearSeekTarget'], }, URL_RESOLVED: { actions: 'setResolvedUrl', @@ -422,12 +419,6 @@ export function createTrackMachine(initialContext: TrackContext) { // ----------------------------------------------------------------- webaudio: { on: { - // clearScheduledStart: once the source node is stopped, the - // scheduled start time no longer describes anything real — resume - // re-anchors the source at a later context time, so any end-time - // math based on scheduledStartContextTime + duration would land - // mid-track (scheduling the next track to start while this one is - // still audible). PAUSE: { actions: [ 'clearIsPlaying', @@ -452,6 +443,7 @@ export function createTrackMachine(initialContext: TrackContext) { }, SEEK: { actions: [ + 'setSeekTarget', 'clearScheduledStart', 'seekWebAudio', 'reportProgress', @@ -465,6 +457,7 @@ export function createTrackMachine(initialContext: TrackContext) { 'stopSourceNode', 'stopProgressLoop', 'resetTiming', + 'clearSeekTarget', ], }, LOOKAHEAD_REACHED: { @@ -482,9 +475,9 @@ export function createTrackMachine(initialContext: TrackContext) { 'stopProgressLoop', 'resetTiming', 'resetHtml5Element', + 'clearSeekTarget', ], }, - // Bug #3 fix: DEACTIVATE from webaudio → idle (was staying in webaudio) DEACTIVATE: { target: 'idle', actions: [ @@ -493,6 +486,7 @@ export function createTrackMachine(initialContext: TrackContext) { 'resetTiming', 'resetHtml5Element', 'stopProgressLoop', + 'clearSeekTarget', ], }, }, diff --git a/tests/unit/features.test.ts b/tests/unit/features.test.ts index 1090ef7..32149d0 100644 --- a/tests/unit/features.test.ts +++ b/tests/unit/features.test.ts @@ -51,6 +51,7 @@ function makeCtx(overrides: Partial = {}): TrackContext { notifiedLookahead: false, fetchStarted: false, pendingPlay: false, + seekTarget: 0, ...overrides, }; } diff --git a/tests/unit/queue.test.ts b/tests/unit/queue.test.ts index 0a82bd7..f60cdc3 100644 --- a/tests/unit/queue.test.ts +++ b/tests/unit/queue.test.ts @@ -358,7 +358,7 @@ describe('Queue onTrackEnded resets finished track', () => { currentTime: number; _waRefCtxTime: number; _waRefTrackTime: number; - pausedAtTrackTime: number; + seekTarget: number; sourceNode: { simulateEnded(): void } | null; }; type InternalQueue = { _tracks: InternalTrack[] }; @@ -418,7 +418,7 @@ describe('Queue onTrackEnded resets finished track', () => { expect(internal._tracks[0]._waRefCtxTime).toBe(0); expect(internal._tracks[0]._waRefTrackTime).toBe(0); - expect(internal._tracks[0].pausedAtTrackTime).toBe(0); + expect(internal._tracks[0].seekTarget).toBe(0); expect(audioOf(q, 0).currentTime).toBe(0); }); }); @@ -1467,10 +1467,10 @@ describe('Queue seek targets correct track', () => { q.next(); // → track 1 q.seek(42); // Track 1 (current) should have been seeked - const tracks = (q as unknown as { _tracks: Array<{ pausedAtTrackTime: number }> })._tracks; - expect(tracks[1].pausedAtTrackTime).toBe(42); + const tracks = (q as unknown as { _tracks: Array<{ seekTarget: number }> })._tracks; + expect(tracks[1].seekTarget).toBe(42); // Track 0 should NOT have been seeked - expect(tracks[0].pausedAtTrackTime).toBe(0); + expect(tracks[0].seekTarget).toBe(0); }); }); diff --git a/tests/unit/track.machine.test.ts b/tests/unit/track.machine.test.ts index 436d66e..5ff7037 100644 --- a/tests/unit/track.machine.test.ts +++ b/tests/unit/track.machine.test.ts @@ -19,6 +19,7 @@ function makeCtx(overrides: Partial = {}): TrackContext { notifiedLookahead: false, fetchStarted: false, pendingPlay: false, + seekTarget: 0, ...overrides, }; } From 08665af7a10cfcc2ac399d5191bfbbb81a9fd329 Mon Sep 17 00:00:00 2001 From: Daniel Saewitz Date: Sat, 19 Sep 2026 15:45:20 -0400 Subject: [PATCH 2/5] Use AbortController to invalidate stale loadedmetadata listeners When a track is reset (ACTIVATE/DEACTIVATE), abort the seek signal so any pending loadedmetadata listener from a prior seek is removed rather than firing later and jumping playback to a stale position. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Track.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Track.ts b/src/Track.ts index b42f96b..b704353 100644 --- a/src/Track.ts +++ b/src/Track.ts @@ -124,6 +124,8 @@ export class Track { private _waRefTrackTime = 0; /** Temp storage for crossover offset — read by syncSeekTargetFromCrossover. */ private _crossoverComputedOffset = 0; + /** Aborted on reset to remove pending loadedmetadata listeners. */ + private _seekAbort = new AbortController(); // ---- FSM ----------------------------------------------------------------- private readonly _actor; @@ -289,6 +291,8 @@ export class Track { seekHtml5: ({ context }: { context: TrackContext }) => this._seekHtml5(context.seekTarget), seekWebAudio: ({ context }: { context: TrackContext }) => this._seekWebAudio(context.seekTarget), resetHtml5Element: () => { + this._seekAbort.abort(); + this._seekAbort = new AbortController(); this.audio.currentTime = 0; }, resetTiming: () => { @@ -543,7 +547,7 @@ export class Track { const target = seekTarget; this.audio.addEventListener('loadedmetadata', () => { this.audio.currentTime = target; - }, { once: true }); + }, { once: true, signal: this._seekAbort.signal }); } } const promise = this.audio.play(); @@ -571,7 +575,7 @@ export class Track { () => { this.audio.currentTime = target; }, - { once: true } + { once: true, signal: this._seekAbort.signal } ); this.audio.load(); } From c57c9d0723a322822a03c9ee039dd57c11a58aae Mon Sep 17 00:00:00 2001 From: Daniel Saewitz Date: Sat, 19 Sep 2026 15:52:09 -0400 Subject: [PATCH 3/5] Lower _playHtml5 seek threshold to handle sub-second targets seekTarget is only non-zero when explicitly set, so we can use a tight threshold (0.01s) instead of 0.5s. This ensures small start offsets like gotoTrack(n, true, 0.3) are applied correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Track.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Track.ts b/src/Track.ts index b704353..00d9795 100644 --- a/src/Track.ts +++ b/src/Track.ts @@ -540,7 +540,7 @@ export class Track { private _playHtml5(seekTarget: number): void { if (this.audio.preload !== 'auto') this.audio.preload = 'auto'; this.audio.playbackRate = this.queueRef.playbackRate; - if (isFinite(seekTarget) && seekTarget > this.audio.currentTime + 0.5) { + if (seekTarget > 0 && Math.abs(this.audio.currentTime - seekTarget) > 0.01) { if (this.audio.readyState >= HTMLMediaElement.HAVE_METADATA) { this.audio.currentTime = seekTarget; } else { From becb48795e27ea907803edef35f54b6f426dec7d Mon Sep 17 00:00:00 2001 From: Daniel Saewitz Date: Sat, 19 Sep 2026 15:59:03 -0400 Subject: [PATCH 4/5] Freeze seekTarget on HTML5 pause to prevent stale seek on resume Without this, seekTarget retained the last explicit seek position while audio.currentTime advanced during playback. On resume, _playHtml5 would jump back to the stale seekTarget. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/machines/track.machine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/machines/track.machine.ts b/src/machines/track.machine.ts index 63c2b14..e1705a6 100644 --- a/src/machines/track.machine.ts +++ b/src/machines/track.machine.ts @@ -273,7 +273,7 @@ export function createTrackMachine(initialContext: TrackContext) { html5: { on: { PAUSE: { - actions: ['clearIsPlaying', 'pauseHtml5', 'stopProgressLoop', 'reportProgress'], + actions: ['clearIsPlaying', 'freezePausedTime', 'pauseHtml5', 'stopProgressLoop', 'reportProgress'], }, PLAY: { actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'], From a946a3033a0915f8b79c5cd682fa5624a7e20b16 Mon Sep 17 00:00:00 2001 From: Daniel Saewitz Date: Sun, 20 Sep 2026 01:29:40 -0400 Subject: [PATCH 5/5] Preserve pending seekTarget when pausing before metadata loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit freezePausedTime now takes the max of the live position and the existing seekTarget. This prevents a pause before loadedmetadata from erasing a pending seek — the target is preserved until the audio element can actually apply it. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Track.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Track.ts b/src/Track.ts index 00d9795..011d83b 100644 --- a/src/Track.ts +++ b/src/Track.ts @@ -278,9 +278,10 @@ export class Track { startProgressLoop: () => this.startProgressLoop(), pauseHtml5: () => this.audio.pause(), freezePausedTime: assign({ - seekTarget: () => { + seekTarget: ({ context }: { context: TrackContext }) => { const t = this.currentTime; - return isFinite(t) ? t : 0; + const current = isFinite(t) ? t : 0; + return Math.max(current, context.seekTarget); }, }), stopSourceNode: () => this._stopSourceNode(),