From 7a369cf9f250baf61b2bb5e4dac5ec30eceb3e68 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 9 Sep 2026 04:12:48 +0300 Subject: [PATCH] Sync the picture to the sound people hear, not the sound queued MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diagnostics came back clean — aheadBy steady at 3.3 s, nothing dropped, nothing re-pegged — so the remaining lip-sync error was a constant, and the cause is the clock: media.seconds counts audio handed to LiveKit, whose audio source holds up to a second (its default queue) before anyone hears it. Frames released against that ran a queue-length early, every time. The clock now subtracts what is still queued, which the source reports directly, so the compensation follows the real buffer instead of a guessed constant. Co-Authored-By: Claude Opus 5 --- src/core/player.ts | 16 ++++++++++++---- src/core/revoice.ts | 6 ++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/core/player.ts b/src/core/player.ts index a021db9..eddf3a7 100644 --- a/src/core/player.ts +++ b/src/core/player.ts @@ -383,10 +383,10 @@ export class GuildPlayer extends EventEmitter { width: video.width, height: video.height, fps: video.fps, - // Frames are released against the sound that has actually played, which is - // the only clock both sides share: the audio path runs through revoice's - // own ffmpeg and buffer, so it always trails the raw frames. - audioClock: () => this.media?.seconds ?? 0, + // Frames are released against the sound people have actually heard, not + // the sound handed to LiveKit: its audio source buffers up to a second, + // and gating on the queued position showed every frame that much early. + audioClock: () => this.heardSeconds(), }); try { await publisher.start(room, video.stream, (reason) => { @@ -402,6 +402,14 @@ export class GuildPlayer extends EventEmitter { } } + /** Playback position as heard, i.e. minus whatever is still queued in LiveKit. */ + private heardSeconds(): number { + const media = this.media; + if (!media) return 0; + const queuedMs = media.source?.queuedDuration ?? 0; + return Math.max(0, media.seconds - queuedMs / 1000); + } + private stopScreenShare(): void { const publisher = this.videoPublisher; this.videoPublisher = null; diff --git a/src/core/revoice.ts b/src/core/revoice.ts index 6b1cd89..1c8d472 100644 --- a/src/core/revoice.ts +++ b/src/core/revoice.ts @@ -9,6 +9,12 @@ const require = createRequire(import.meta.url); export interface MediaPlayerLike { readonly seconds: number; + /** + * LiveKit's audio source. `seconds` counts what has been handed to it, and it + * holds up to a second before anyone hears it — `queuedDuration` (in ms) is + * that gap, which matters when lining a picture up with the sound. + */ + source?: { readonly queuedDuration: number } | null; readonly duration: number; codecData?: { duration?: string } | null; paused: boolean;