From 839f777a75f310e2df13bf911f1b17803705a9f5 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Tue, 16 Jun 2026 15:46:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20recover=20B=E7=AB=99=20long-stre?= =?UTF-8?q?am=20playback=20stalls=20instead=20of=20going=20silent=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues caused a long BiliBili stream to stop partway (~16 min) and never resume: 1. FFmpeg lacked -reconnect_at_eof. B站 CDN sessions can close the connection mid-file (premature EOF); without this flag FFmpeg treats that EOF as end-of-input and stops. Added it (HTTP only) so FFmpeg re-issues a Range request and finishes the stream. 2. The frame loop only ended a live-but-silent FFmpeg when within 5s of the song end (isNearEnd). Far from the end, emptyFrameAttempts grew unbounded, no trackEnd was emitted, and audio went permanently silent ('无法继续播放'). Added a far-from-end stall watchdog (MAX_STALL_ATTEMPTS ~= 60s) via a pure, tested shouldEndOnStall() helper, so a genuinely dead stream advances instead of hanging — while a transient underrun on a healthy stream is left alone. Tests: assert -reconnect_at_eof 1 is present (before -i) for HTTP and absent for local files; shouldEndOnStall covers near-end fast end, far-from-end no-false-skip, and far-from-end eventual recovery. Fixes #89 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/audio/player.test.ts | 38 +++++++++++++++++++++++++++- src/audio/player.ts | 53 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/audio/player.test.ts b/src/audio/player.test.ts index 21dd5be..cc75fa3 100644 --- a/src/audio/player.test.ts +++ b/src/audio/player.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { mkdtempSync, writeFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir } from "./player.js"; +import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall } from "./player.js"; function getHeadersArg(args: string[]): string { const idx = args.indexOf("-headers"); @@ -45,6 +45,14 @@ describe("buildFfmpegArgs", () => { expect(Number(args[idx + 1])).toBeGreaterThanOrEqual(30); }); + it("sets -reconnect_at_eof 1 (before -i) so long B站 streams resume after premature EOF (#89)", () => { + const args = buildFfmpegArgs("https://x.bilivideo.com/audio.m4s", 0); + const idx = args.indexOf("-reconnect_at_eof"); + expect(idx).toBeGreaterThan(-1); + expect(args[idx + 1]).toBe("1"); + expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i + }); + it("inserts -ss before -i when seekSeconds > 0", () => { const args = buildFfmpegArgs("https://example.com/song.mp3", 42); const ssIdx = args.indexOf("-ss"); @@ -62,6 +70,7 @@ describe("buildFfmpegArgs", () => { it("omits HTTP-only flags when input is a local file path", () => { const args = buildFfmpegArgs("C:/temp/song.mp3", 0); expect(args).not.toContain("-reconnect"); + expect(args).not.toContain("-reconnect_at_eof"); expect(args).not.toContain("-reconnect_on_network_error"); expect(args).not.toContain("-reconnect_on_http_error"); expect(args).not.toContain("-headers"); @@ -133,3 +142,30 @@ describe("cleanupTempDir", () => { expect(() => cleanupTempDir(dir)).not.toThrow(); }); }); + +describe("shouldEndOnStall (#89 mid-track stall watchdog)", () => { + const MAX_EMPTY = 250; // ~5s near-end threshold + const MAX_STALL = 3000; // ~60s far-from-end watchdog + + it("ends quickly near the end once the empty threshold is reached (normal EOF)", () => { + expect(shouldEndOnStall(MAX_EMPTY, true, MAX_EMPTY, MAX_STALL)).toBe(true); + expect(shouldEndOnStall(MAX_EMPTY - 1, true, MAX_EMPTY, MAX_STALL)).toBe(false); + }); + + it("does NOT end far from the end at the near-end threshold (avoids false skips on transient underruns)", () => { + // This is the core regression: a brief underrun mid-song must not end the track. + expect(shouldEndOnStall(MAX_EMPTY, false, MAX_EMPTY, MAX_STALL)).toBe(false); + expect(shouldEndOnStall(MAX_STALL - 1, false, MAX_EMPTY, MAX_STALL)).toBe(false); + }); + + it("eventually ends far from the end once the long stall watchdog trips (dead stream recovers)", () => { + // The pre-fix bug: far-from-end stalls grew unbounded and never ended -> permanent silence. + expect(shouldEndOnStall(MAX_STALL, false, MAX_EMPTY, MAX_STALL)).toBe(true); + expect(shouldEndOnStall(MAX_STALL + 500, false, MAX_EMPTY, MAX_STALL)).toBe(true); + }); + + it("never ends before any threshold", () => { + expect(shouldEndOnStall(0, true, MAX_EMPTY, MAX_STALL)).toBe(false); + expect(shouldEndOnStall(10, false, MAX_EMPTY, MAX_STALL)).toBe(false); + }); +}); diff --git a/src/audio/player.ts b/src/audio/player.ts index 765ba7a..3a56285 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -91,6 +91,11 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] { if (isHttp) { args.push( "-reconnect", "1", + // Long B站 streams sit on a CDN whose session/token can close the + // connection mid-file (premature EOF). Without this, FFmpeg treats that + // EOF as end-of-input and stops ~partway through (see #89); with it, it + // re-issues a Range request from the current offset to finish the stream. + "-reconnect_at_eof", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "30", "-reconnect_on_network_error", "1", @@ -103,6 +108,28 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] { return args; } +/** + * Decide whether to end the current track when FFmpeg is still alive but has + * produced no decodable audio for `emptyAttempts` consecutive frame ticks. + * + * - Near the song end we end quickly (`maxEmptyAttempts`): a normal EOF. + * - Far from the end we wait much longer (`maxStallAttempts`) before giving up, + * so a transient buffer underrun on a healthy stream does NOT cause a false + * skip — but a genuinely dead stream (e.g. a long B站 stream whose CDN session + * expired mid-playback, #89) still recovers by advancing instead of going + * permanently silent. + */ +export function shouldEndOnStall( + emptyAttempts: number, + isNearEnd: boolean, + maxEmptyAttempts: number, + maxStallAttempts: number, +): boolean { + if (isNearEnd && emptyAttempts >= maxEmptyAttempts) return true; + if (emptyAttempts >= maxStallAttempts) return true; + return false; +} + export interface PlayerEvents { frame: (opusFrame: Buffer) => void; trackEnd: () => void; @@ -138,6 +165,11 @@ export class AudioPlayer extends EventEmitter { private currentTempDir: string | null = null; private emptyFrameAttempts = 0; private static readonly MAX_EMPTY_ATTEMPTS = 250; // ~5秒的20ms帧循环(增加容错) + // Far-from-end stall watchdog (#89): if FFmpeg is alive but produces no audio + // for this many consecutive frame ticks (~60s at 20ms/frame), treat the stream + // as dead and advance instead of staying silent forever. Set high so a normal + // transient underrun never trips it. + private static readonly MAX_STALL_ATTEMPTS = 3000; private currentSongDuration = 0; // 当前歌曲总时长(秒) constructor(logger: Logger) { @@ -436,16 +468,27 @@ export class AudioPlayer extends EventEmitter { if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) { this.emptyFrameAttempts++; - // 只有同时满足:达到空帧阈值 + 接近结尾,才判定为播放结束 - if (this.emptyFrameAttempts >= AudioPlayer.MAX_EMPTY_ATTEMPTS && isNearEnd) { - this.logger.info({ + // End the track when FFmpeg has gone silent: quickly if we're near the + // end (normal EOF), or after a much longer stall window if we're not + // (a dead/expired stream — #89 — so playback recovers instead of going + // permanently silent). + if ( + shouldEndOnStall( + this.emptyFrameAttempts, + isNearEnd, + AudioPlayer.MAX_EMPTY_ATTEMPTS, + AudioPlayer.MAX_STALL_ATTEMPTS, + ) + ) { + this.logger.info({ sessionId: this.sessionId, emptyAttempts: this.emptyFrameAttempts, bufferSize: this.pcmBuffer.length, elapsed: Math.round(elapsed), duration: this.currentSongDuration, - remaining: Math.round(this.currentSongDuration - elapsed) - }, "FFmpeg stopped outputting data near end, ending track"); + remaining: Math.round(this.currentSongDuration - elapsed), + nearEnd: isNearEnd, + }, "FFmpeg stopped outputting data, ending track"); this.frameLoopRunning = false; if (this.state !== "idle") { this.state = "idle";