diff --git a/web-ui/src/components/player/video-player.tsx b/web-ui/src/components/player/video-player.tsx index 2e5ef7d..f4747c2 100644 --- a/web-ui/src/components/player/video-player.tsx +++ b/web-ui/src/components/player/video-player.tsx @@ -64,6 +64,16 @@ function otherSlot(id: SlotId): SlotId { return id === "a" ? "b" : "a"; } +function isInterruptedPlayError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const message = err.message.toLowerCase(); + return err.name === "AbortError" && (message.includes("interrupted") || message.includes("new load request")); +} + +function ignoreInterruptedPlayError(err: unknown): void { + if (!isInterruptedPlayError(err)) throw err; +} + function PlayerTopLeftOverlay({ visible, loading, @@ -263,7 +273,7 @@ export function VideoPlayer({ userPausedRef.current = false; if (playMode === "live") { goLiveToSessionEdge(); - video?.play(); + video?.play()?.catch(ignoreInterruptedPlayError); return; } shouldAutoPlayRef.current = !video?.paused; @@ -291,7 +301,7 @@ export function VideoPlayer({ if (video) { if (video.paused) { userPausedRef.current = false; - video.play(); + video.play().catch(ignoreInterruptedPlayError); } else { userPausedRef.current = true; video.pause(); @@ -579,6 +589,7 @@ export function VideoPlayer({ if (playPromise) { playPromise .catch((err: Error) => { + if (isInterruptedPlayError(err)) return; if (slotId && !isPendingTransitionExpected(slotId, expected)) return; if (err.name === "NotAllowedError" || err.message.includes("user didn't interact")) { setNeedsUserInteraction(true); @@ -634,7 +645,7 @@ export function VideoPlayer({ const videoForActiveSlot = () => (activeSlotIdRef.current === "a" ? slotAVideoRef : slotBVideoRef).current; navigator.mediaSession.setActionHandler("play", () => { userPausedRef.current = false; - videoForActiveSlot()?.play(); + videoForActiveSlot()?.play()?.catch(ignoreInterruptedPlayError); }); navigator.mediaSession.setActionHandler("pause", () => { userPausedRef.current = true; @@ -751,9 +762,6 @@ export function VideoPlayer({ }, [liveSessionAnchor]); useEffect(() => { - const activeId = activeSlotIdRef.current; - const player = (activeId === "a" ? slotAPlayerRef : slotBPlayerRef).current; - if (!player) return; if (skipNextSegmentsLoadRef.current) { skipNextSegmentsLoadRef.current = false; return; @@ -889,6 +897,7 @@ export function VideoPlayer({ if (video.paused) { video.play()?.catch((err: Error) => { + if (isInterruptedPlayError(err)) return; if (err.name === "NotAllowedError") { setNeedsUserInteraction(true); } @@ -1143,6 +1152,7 @@ export function VideoPlayer({ setIsPlaying(true); userPausedRef.current = false; video.play()?.catch((err: Error) => { + if (isInterruptedPlayError(err)) return; console.error("Play error after user interaction:", err); setError(`${t("failedToPlay")}: ${err.message}`); onError?.(`${t("failedToPlay")}: ${err.message}`); diff --git a/web-ui/src/mpegts/audio/pcm-audio-player.ts b/web-ui/src/mpegts/audio/pcm-audio-player.ts index 5407996..97df8c4 100644 --- a/web-ui/src/mpegts/audio/pcm-audio-player.ts +++ b/web-ui/src/mpegts/audio/pcm-audio-player.ts @@ -16,7 +16,7 @@ * measures drift between the audio chain and video.currentTime and sets * ratio = playbackRate * (1 - k*drift), so audio genuinely follows * live-sync playbackRate (pitch preserved) and small drift converges - * smoothly. Large drift (> 250ms) triggers a hard resync with short fades. + * smoothly. Large discontinuities trigger a hard resync with short fades. * * - Stream timestamps arrive from the worker already normalized to the MSE * timeline (same space as video.currentTime), using the remuxer dts base. @@ -25,7 +25,7 @@ import { isIOS } from "../../lib/platform"; import { maxBufferHoleSec, type PlayerConfig } from "../config"; import Log from "../utils/logger"; -import { PassthroughStretcher, type Stretcher, WasmStretcher } from "./wasm-stretcher"; +import { type Stretcher, WasmStretcher } from "./wasm-stretcher"; const TAG = "PCMAudioPlayer"; @@ -48,8 +48,8 @@ export function markPlaybackUnlocked(): void { const SCHEDULE_AHEAD = 0.6; /** Delay before the first chunk when (re)starting the scheduling chain. */ const CHAIN_RESTART_DELAY = 0.04; -/** Drift beyond this triggers a hard resync (cancel + rebuild from buffer). */ -const HARD_RESYNC_THRESHOLD = 0.25; +/** Drift beyond this is treated as an emergency discontinuity and rebuilt from buffer. */ +const HARD_RESYNC_THRESHOLD = 1.5; /** Input gaps/overlaps within this are absorbed silently (PTS jitter). */ const GAP_SNAP = 0.005; /** Input gaps up to this long are filled with silence; larger ones re-anchor. */ @@ -61,6 +61,11 @@ const RATIO_DRIFT_GAIN = 0.5; /** Max stretch ratio deviation used for drift correction. WSOLA preserves * pitch, so a transient 10% tempo offset is inaudible while it converges. */ const RATIO_DRIFT_MAX = 0.1; +/** Initial/large-drift mode: allow stronger WSOLA correction before falling back to hard resync. */ +const SOFT_SYNC_WINDOW_SEC = 3.0; +const SOFT_SYNC_EXIT_DRIFT = 0.08; +const SOFT_SYNC_DRIFT_GAIN = 1.0; +const SOFT_SYNC_RATIO_DRIFT_MAX = 0.35; /** EMA smoothing factor for drift measurements. */ const DRIFT_EMA_ALPHA = 0.4; /** Control loop period (ms). */ @@ -107,6 +112,7 @@ export class PCMAudioPlayer { // Time stretcher private stretcher: Stretcher | null = null; private stretcherLoading = false; + private stretcherFailed = false; // Input-side state: stream time of the next sample to feed the stretcher. // null = not anchored (anchors at the next chunk's time). @@ -123,6 +129,7 @@ export class PCMAudioPlayer { // Drift control private driftEma = 0; private hasDriftEma = false; + private softSyncUntil = 0; private driftLogCounter = 0; private controlTimer: ReturnType | null = null; @@ -299,22 +306,21 @@ export class PCMAudioPlayer { this.inputCursor = null; } + if (this.stretcherFailed) { + return null; + } + if (this.stretcherLoading) { return null; } this.stretcherLoading = true; const wasmUrl = this.config.wasmDecoders.mp2; - const fallback = () => new PassthroughStretcher(chunk.sampleRate, chunk.channels); const promise = wasmUrl ? WasmStretcher.create(wasmUrl, chunk.sampleRate, chunk.channels) - : Promise.reject(new Error("no wasm url")); + : Promise.reject(new Error("MP2 WASM URL is not configured")); promise - .catch((err) => { - Log.w(TAG, `WASM stretcher unavailable, using passthrough (no rate matching): ${err}`); - return fallback(); - }) .then((stretcher) => { this.stretcherLoading = false; if (!this.context) { @@ -323,6 +329,12 @@ export class PCMAudioPlayer { } this.stretcher = stretcher; this.pump(); + }) + .catch((err) => { + this.stretcherLoading = false; + this.stretcherFailed = true; + this.pendingChunks = []; + Log.e(TAG, `WASM stretcher unavailable; cannot play software-decoded audio: ${err}`); }); return null; @@ -431,6 +443,7 @@ export class PCMAudioPlayer { this.inputCursor = time; this.stretcherBase = time; this.outputStreamCursor = time; + this.softSyncUntil = (this.context?.currentTime ?? 0) + SOFT_SYNC_WINDOW_SEC; } private feedStretcher(stretcher: Stretcher, samples: Float32Array, sampleRate: number): void { @@ -588,8 +601,8 @@ export class PCMAudioPlayer { this.hasDriftEma = true; } - if (Math.abs(this.driftEma) > HARD_RESYNC_THRESHOLD) { - Log.v(TAG, `Hard resync: drift=${this.driftEma.toFixed(3)}s`); + if (Math.abs(drift) > HARD_RESYNC_THRESHOLD) { + Log.v(TAG, `Emergency hard resync: drift=${drift.toFixed(3)}s`); this.resyncFromBuffer(video.currentTime); return; } @@ -597,13 +610,20 @@ export class PCMAudioPlayer { // Rate matching: follow video.playbackRate, correct residual drift. // Positive drift = audio ahead → slow down (smaller ratio). const rate = Math.min(2, Math.max(0.5, video.playbackRate || 1)); - const correction = Math.min(RATIO_DRIFT_MAX, Math.max(-RATIO_DRIFT_MAX, this.driftEma * RATIO_DRIFT_GAIN)); + const softSyncActive = ctx.currentTime < this.softSyncUntil || Math.abs(this.driftEma) > SOFT_SYNC_EXIT_DRIFT; + const correctionDrift = softSyncActive ? drift : this.driftEma; + const correctionMax = softSyncActive ? SOFT_SYNC_RATIO_DRIFT_MAX : RATIO_DRIFT_MAX; + const correctionGain = softSyncActive ? SOFT_SYNC_DRIFT_GAIN : RATIO_DRIFT_GAIN; + const correction = Math.min(correctionMax, Math.max(-correctionMax, correctionDrift * correctionGain)); const ratio = Math.min(2, Math.max(0.5, rate * (1 - correction))); this.stretcher.setRatio(ratio); if (++this.driftLogCounter >= DRIFT_LOG_TICKS) { this.driftLogCounter = 0; - Log.v(TAG, `A/V drift=${(this.driftEma * 1000).toFixed(1)}ms, rate=${rate}, stretch ratio=${ratio.toFixed(4)}`); + Log.v( + TAG, + `A/V drift=${(this.driftEma * 1000).toFixed(1)}ms, rate=${rate}, stretch ratio=${ratio.toFixed(4)}, mode=${softSyncActive ? "soft" : "steady"}`, + ); } } @@ -802,6 +822,8 @@ export class PCMAudioPlayer { this.isSeeking = false; this.inputCursor = null; this.stretcher?.reset(); + this.stretcherFailed = false; + this.softSyncUntil = 0; this.driftEma = 0; this.hasDriftEma = false; } diff --git a/web-ui/src/mpegts/audio/wasm-stretcher.ts b/web-ui/src/mpegts/audio/wasm-stretcher.ts index dd7fc4b..0800736 100644 --- a/web-ui/src/mpegts/audio/wasm-stretcher.ts +++ b/web-ui/src/mpegts/audio/wasm-stretcher.ts @@ -25,35 +25,6 @@ export interface Stretcher { destroy(): void; } -/** - * Fallback when WASM is unavailable on the main thread: identity passthrough. - * Sync then degrades to occasional hard resyncs instead of smooth rate matching, - * but the scheduling chain stays gapless. - */ -export class PassthroughStretcher implements Stretcher { - readonly sampleRate: number; - readonly channels: number; - position = 0; - - constructor(sampleRate: number, channels: number) { - this.sampleRate = sampleRate; - this.channels = channels; - } - - setRatio(_ratio: number): void {} - - process(input: Float32Array): Float32Array { - this.position += input.length / this.channels; - return input; - } - - reset(): void { - this.position = 0; - } - - destroy(): void {} -} - interface WsolaExports { memory: WebAssembly.Memory; _initialize: () => void;