feat: audio playback issue

Task #60 completed by ClaudeQueue

ClaudeQueue
This commit is contained in:
Claude Worker
2026-07-01 07:26:33 +00:00
parent 18ad8152bf
commit 42ff13b135

View File

@@ -407,6 +407,7 @@ const Player = {
master: els.video,
secondary: null, // synced audio element in dual mode
driftTimer: null,
bufferGraceTimer: null, // pending "pause audio after a stall that outlasts the grace window" timer
_wantsPlaying: false, // tracks user/app *intent* to be playing, independent
// of what the underlying <audio>/<video> elements
// report — iOS Safari (esp. PWA/standalone on iPhone)
@@ -675,6 +676,7 @@ const Player = {
},
pause() {
this._wantsPlaying = false;
this.clearBufferGrace();
this.master.pause();
if (this.secondary) this.secondary.pause();
},
@@ -698,6 +700,47 @@ const Player = {
if (this.secondary) this.secondary.playbackRate = r;
},
// Drift-correction thresholds for dual mode (muted video + synced audio).
// A hard `currentTime` jump on a *playing* <audio> element causes an
// audible pop/glitch — every earlier version of the watchdog snapped audio
// back into sync any time drift exceeded 0.3s, which is exactly what read
// as "audio stutter" (video stayed smooth because it was never touched).
// We now use a tiered response instead of one hard threshold:
// < SOFT_SYNC_THRESHOLD — inaudible, ignore (avoids constant micro-corrections)
// < HARD_SYNC_THRESHOLD — gently slew playbackRate so the audio eases back
// into sync over ~1s with no audible artifact
// >= HARD_SYNC_THRESHOLD — snap (only reached from seeks, buffer flushes,
// or background-resume, where a single correction
// is unavoidable and rare)
SOFT_SYNC_THRESHOLD: 0.08,
HARD_SYNC_THRESHOLD: 0.3,
SLEW_RATE: 0.04, // playbackRate offset applied while easing back into sync
// Shared by the 1s watchdog tick and the master 'playing' handler so both
// paths get the same no-pop behavior.
correctDrift() {
if (this.mode !== 'dual' || !this.secondary || this.master.paused) return;
const drift = this.secondary.currentTime - this.master.currentTime;
const abs = Math.abs(drift);
if (abs >= this.HARD_SYNC_THRESHOLD) {
this.secondary.currentTime = this.master.currentTime;
this._resyncSpeed();
} else if (abs >= this.SOFT_SYNC_THRESHOLD) {
// Audio ahead of video -> slow audio down; audio behind -> speed it up.
const base = parseFloat(els.speed.value) || 1;
this.secondary.playbackRate = drift > 0 ? base - this.SLEW_RATE : base + this.SLEW_RATE;
} else {
this._resyncSpeed();
}
},
// Restore the secondary's playbackRate to the user-selected speed once
// drift is within tolerance (or after a hard snap) so a slew correction
// never lingers and overshoots.
_resyncSpeed() {
const base = parseFloat(els.speed.value) || 1;
if (this.secondary && this.secondary.playbackRate !== base) this.secondary.playbackRate = base;
},
// Runs for every mode (not just 'dual') as a watchdog: iOS Safari — most
// visibly in PWA/standalone mode on iPhone — can silently pause the element
// actually producing sound (the <audio> tag in 'dual'/'audio' mode) while
@@ -710,11 +753,9 @@ const Player = {
this.stopDrift();
this.driftTimer = setInterval(() => {
if (!this._wantsPlaying) return;
// Dual-mode: keep the secondary audio track in sync with the master.
if (this.mode === 'dual' && !this.master.paused) {
const drift = Math.abs(this.secondary.currentTime - this.master.currentTime);
if (drift > 0.3) this.secondary.currentTime = this.master.currentTime;
}
// Dual-mode: keep the secondary audio track in sync with the master,
// via the tiered corrector above (no hard snap for small, normal drift).
this.correctDrift();
// The element that's actually producing sound in the current mode.
const sounder = this.soundEl;
if (sounder && sounder.paused) {
@@ -726,6 +767,17 @@ const Player = {
stopDrift() {
if (this.driftTimer) clearInterval(this.driftTimer);
this.driftTimer = null;
this.clearBufferGrace();
},
// Cancel a pending "pause synced audio after a stall" timer — used once the
// video recovers (playing/canplay), on an explicit pause, and when tearing
// down the current element (attach()/stopDrift()) so a stale timer never
// fires against a track that's already moved on.
clearBufferGrace() {
if (this.bufferGraceTimer) {
clearTimeout(this.bufferGraceTimer);
this.bufferGraceTimer = null;
}
},
// Called when the page/tab regains foreground focus (screen unlock, app
// switch back). This is the single most common trigger for iOS silently
@@ -800,10 +852,28 @@ function wirePlayerEvents() {
function bind(el) {
el.addEventListener('play', () => { if (masterIs(el) && Player.secondary && Player.secondary.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } updatePlayBtn(); });
el.addEventListener('pause', () => { if (masterIs(el) && Player.secondary) Player.secondary.pause(); updatePlayBtn(); });
el.addEventListener('pause', () => { if (masterIs(el) && Player.secondary) { Player.clearBufferGrace(); Player.secondary.pause(); } updatePlayBtn(); });
el.addEventListener('seeking', () => { if (masterIs(el) && Player.secondary) Player.secondary.currentTime = el.currentTime; });
el.addEventListener('waiting', () => { if (masterIs(el)) { showSpinner(true); if (Player.secondary) Player.secondary.pause(); } });
el.addEventListener('playing', () => { if (masterIs(el)) { showSpinner(false); if (Player.secondary && !el.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } } });
// A brief 'waiting' (video re-buffering under CPU/memory pressure) used to
// pause the synced audio immediately — every few-hundred-ms video stall
// cut audio output, which is heard as a stutter even though the audio
// pipeline itself was fine. Give the video a short grace window to
// recover on its own before touching the still-playing audio track; only
// pause it if the stall actually outlasts that window.
el.addEventListener('waiting', () => {
if (!masterIs(el)) return;
showSpinner(true);
if (Player.secondary && !Player.secondary.paused) {
Player.clearBufferGrace();
Player.bufferGraceTimer = setTimeout(() => {
Player.bufferGraceTimer = null;
if (Player.master.paused || Player.master.readyState < 3) {
if (Player.secondary) Player.secondary.pause();
}
}, 250);
}
});
el.addEventListener('playing', () => { if (masterIs(el)) { showSpinner(false); Player.clearBufferGrace(); if (Player.secondary && !el.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } } });
el.addEventListener('canplay', () => { if (masterIs(el)) showSpinner(false); });
el.addEventListener('timeupdate', () => {
if (masterIs(el)) {