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
22 changes: 16 additions & 6 deletions web-ui/src/components/player/video-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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}`);
Expand Down
50 changes: 36 additions & 14 deletions web-ui/src/mpegts/audio/pcm-audio-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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";

Expand All @@ -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;
Comment thread
stackia marked this conversation as resolved.
/** 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. */
Expand All @@ -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). */
Expand Down Expand Up @@ -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).
Expand All @@ -123,6 +129,7 @@ export class PCMAudioPlayer {
// Drift control
private driftEma = 0;
private hasDriftEma = false;
private softSyncUntil = 0;
private driftLogCounter = 0;
private controlTimer: ReturnType<typeof setInterval> | null = null;

Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -588,22 +601,29 @@ 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;
}

// 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"}`,
);
}
}

Expand Down Expand Up @@ -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;
}
Expand Down
29 changes: 0 additions & 29 deletions web-ui/src/mpegts/audio/wasm-stretcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading