跳到正文
曾亮懿 ZengLiangYi Blog

用 AudioContext suspend/resume 作为流式音视频的同步门控

在逐帧流式播放中,用 AudioContext suspend 和 resume 冻结或放行音频时间轴,让帧到达决定播放推进并维持严格音画同步。

曾亮懿

曾亮懿

阅读约 4 分钟
目录 展开 收起

TL;DR: 在“边收帧边播放”的场景里,预先缓冲 N 帧只是在猜网络速度。AudioContext.suspend() 会暂停音频输出,也会停止音频上下文的时间推进,因此可以把它当作同步门:下一帧没到就挂起时间轴,帧到了再恢复。

本文假设你了解 Web Audio API、requestAnimationFrame 和逐帧流式传输。

问题:帧和音频运行在两条轨道上

考虑一个流式口型同步播放器:后端通过 SSE 或 WebSocket 推送视频帧,客户端边接收、边解码、边绘制,同时播放与之对应的音频。

最直接的实现是立即启动音频,再根据 audioContext.currentTime 计算应该显示的帧:

audioSource.start(0);
const startTime = audioContext.currentTime;

function renderLoop() {
  const elapsed = audioContext.currentTime - startTime;
  const targetFrame = Math.floor(elapsed * fps);

  if (frames[targetFrame]) {
    drawFrame(frames[targetFrame]);
  }

  requestAnimationFrame(renderLoop);
}

网络稳定时没有问题。一旦帧到达速度落后于音频:

音频时钟:0s ───────── 2s ───────── 4s ─────────
到达帧:  ████████████░░░░░░░░░░░░░░░░░░░░
显示画面:[正常]       [画面冻结,音频继续播放]

用户会立刻感知到嘴型和声音分离。

为什么固定缓冲阈值不够

常见做法是在播放前先缓存一定数量的帧:

const BUFFER_THRESHOLD = 60;

onFrameDecoded(() => {
  decodedCount += 1;

  if (!started && decodedCount >= BUFFER_THRESHOLD) {
    started = true;
    audioSource.start(0);
    renderLoop();
  }
});

这个策略只把问题推迟了:

  • 阈值依赖网络和后端速度,60 帧可能太少,也可能只是增加首帧延迟。
  • 播放开始后的任何卡顿仍会让帧落后于音频。
  • 为最坏情况配置缓冲,会让正常网络也承担额外等待。

根本问题是音频时钟独立运行,不知道下一帧是否可用。

关键性质:suspend 会停止时间推进

MDN 对 AudioContext.suspend() 的定义是暂停音频上下文中的时间推进,同时暂时停止访问音频硬件。它不只是静音:

const audioContext = new AudioContext();

// 播放一段时间后
console.log(audioContext.currentTime); // 例如 2.341

await audioContext.suspend();
await sleep(3000);

console.log(audioContext.currentTime); // 仍停留在挂起位置附近

await audioContext.resume();
// 时间轴从挂起位置继续推进

这使 AudioContext 可以充当同步原语:

下一顺序帧已经就绪?
  是 → resume()  → 时间推进 → 到达帧时间后绘制
  否 → suspend() → 时间冻结 → 等帧到达事件唤醒

初始化:先挂起,再安排音频源

音频解码和帧接收可以并行。创建上下文后先挂起,解码完成后在挂起状态安排音频源:

const AudioContextConstructor =
  window.AudioContext ??
  (
    window as typeof window & {
      webkitAudioContext: typeof AudioContext;
    }
  ).webkitAudioContext;

const audioContext = new AudioContextConstructor();
await audioContext.suspend();

const buffer = await audioContext.decodeAudioData(audioBytes);
const source = audioContext.createBufferSource();

source.buffer = buffer;
source.connect(audioContext.destination);
source.start(0);

const startTime = audioContext.currentTime;

start(0) 在挂起状态下可以安排播放,但不会让时间轴实际前进。第一帧和音频都准备好后,再由调度器调用 resume()。

浏览器的自动播放策略仍然适用。第一次允许发声的 resume() 通常需要发生在用户手势之后;生产代码不应吞掉它的 rejection,而应显示“点击播放”状态或把错误交给上层。

门控函数:用本地目标状态防抖

resume() 和 suspend() 都是异步操作。只读取 audioContext.state 容易遇到过渡窗口:调用已经发出,但 state 还没有更新。

可以用同步更新的本地标志表示期望状态:

let audioContext: AudioContext | null = null;
let audioRunning = false;

function ensureRunning() {
  if (!audioRunning && audioContext) {
    audioRunning = true;
    audioContext.resume().catch((error) => {
      audioRunning = false;
      reportAudioError(error);
    });
  }
}

function ensureSuspended() {
  if (audioRunning && audioContext) {
    audioRunning = false;
    audioContext.suspend().catch(reportAudioError);
  }
}

本地标志用于避免在每个动画帧重复发送相同命令。它不能替代错误处理:resume() 被自动播放策略拒绝时,必须恢复标志并通知界面。

核心调度器:帧驱动时钟

调度器同时由两类事件唤醒:

  1. 下一顺序帧完成解码。
  2. 音频完成解码并准备好。
let audioReady = false;
let lastDrawnFrame = -1;

function tryAdvance() {
  if (!isPlaying) return;

  const nextNeeded = lastDrawnFrame + 1;

  if (streamEnded && nextNeeded >= totalFrames) {
    ensureSuspended();
    isPlaying = false;
    onPlayEnd();
    return;
  }

  const nextFrame = frames[nextNeeded];

  if (nextFrame === null) {
    // 帧缺口:冻结时间轴,等待 onFrameArrived 再唤醒。
    ensureSuspended();
    return;
  }

  if (!audioReady || !audioContext) {
    requestAnimationFrame(tryAdvance);
    return;
  }

  ensureRunning();

  const elapsed = audioContext.currentTime - startTime;
  const frameTime = nextNeeded / fps;

  if (elapsed >= frameTime) {
    draw(nextFrame);
    lastDrawnFrame = nextNeeded;
  }

  // 帧已到但时间未到时继续等待;绘制后也继续尝试下一帧。
  requestAnimationFrame(tryAdvance);
}

这段逻辑同时处理两种速度:

  • 后端慢于实时:遇到帧缺口就挂起音频。
  • 后端快于实时:帧已经存在,但 elapsed < frameTime 时仍等待音频时钟追上,不会提前播放。

新帧到达时只唤醒必要的工作

function onFrameArrived(index: number, bitmap: ImageBitmap) {
  frames[index] = bitmap;

  if (!isPlaying && index === 0) {
    isPlaying = true;
    tryAdvance();
    return;
  }

  if (isPlaying && index === lastDrawnFrame + 1) {
    tryAdvance();
  }
}

如果帧按 0, 1, 3, 2, 4 的顺序到达,帧 3 只会先存入数组。帧 2 到达后才唤醒调度器,随后依次绘制 2、3、4。顺序由 lastDrawnFrame + 1 保证,无需额外排序。

结束与清理

停止播放时要同时取消调度、关闭音频上下文并释放 ImageBitmap 占用的 GPU 资源:

function stop() {
  isPlaying = false;

  if (rafId !== null) {
    cancelAnimationFrame(rafId);
    rafId = null;
  }

  audioContext?.close().catch(reportAudioError);
  audioContext = null;

  for (const bitmap of frames) {
    bitmap?.close();
  }
}

零帧流需要单独处理。因为常规流程由首帧启动,totalFrames === 0 时不会出现首帧事件;收到流结束信号后应直接调用结束回调,而不是等待 tryAdvance() 被唤醒。

音频时长、fps 和 totalFrames 也必须来自一致的元数据。如果音频先结束而视频仍有帧,currentTime 仍会前进,但用户已经听不到声音;门控无法替代输入数据的一致性校验。

取舍

优点 局限
网络抖动时仍保持严格音画同步 卡顿会表现为声音和画面一起暂停
首帧不必等待固定 N 帧缓冲 必须处理自动播放策略和异步状态
不需要 AudioWorklet 频繁挂起可能带来可感知的播放停顿
乱序帧可自然按索引等待 不适合本地文件或已充分缓冲的媒体

如果帧来自本地文件或稳定的 HLS,直接让音频时钟驱动画面通常更简单。这套门控的价值在于帧到达时间不可预测的流式推送。

相关代码与资料

分享:

相关文章

五种 AI 对话解析器:字段保留与损失边界

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵,检查工具标记、思考文本、顺序与快照覆盖范围。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

AI SDK Provider 抽象:能力表与协议边界

核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出,用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #TypeScript

SourceAdapter 内部链路:身份、内容哈希与重新导入

对照 ChatCrystal 的注册表、导入器和远端载荷,验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录,欢迎交流实现细节。