普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月10日首页

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

作者 ZengLiangYi
2026年3月10日 13:27

在"边收帧边播放"的场景中,传统做法是缓冲 N 帧后再启动——本质上是在猜网络速度,猜错了音画就不同步。更可靠的方案:把 AudioContext.suspend() 当作同步门,帧没到就冻结音频时钟,帧到了再放行。这个做法成立的原因是 suspend() 同时冻结音频输出 currentTime 时钟,天然是一个同步原语。

本文假设你了解 Web Audio API 基础和 requestAnimationFrame


问题:帧和音频跑在两条不同的轨道上

考虑一个流式口型同步播放器——后端通过 SSE 逐帧推送视频帧,客户端边收边解码边渲染,同时播放对应的音频。

朴素实现:音频立即开始播放,requestAnimationFrame 根据 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]);
  }
  // 若 frames[targetFrame] 为 null,保持上一帧
  requestAnimationFrame(renderLoop);
}

这在网络良好时能工作。一旦后端帧到达速度跟不上播放速度:

音频时钟:  0s ────────── 2s ────────── 4s ────────────
已到达帧:  ████████████░░░░░░░░░░░░░░░░░░░░░░░░ (帧停在第 60 帧)
实际显示:  [正常]        [嘴型冻结]    [嘴型冻结,音频继续]

嘴型冻结,但语音继续——这是用户最容易感知到的 A/V 失同步。


天真的修复:缓冲阈值

最常见的修复是在开始播放前先缓冲足够的帧:

// ❌ 缓冲阈值方案
const BUFFER_THRESHOLD = 60; // 缓冲 60 帧再开始

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

这把问题延后了,但没有解决:

  • 阈值是拍脑袋定的:60 帧在慢网络下可能还不够,在快网络下只是增加延迟
  • 播放开始后依然会失同步:后端如果在中途卡顿,帧再次追不上音频
  • 首帧延迟增加:用户要等 ~60 帧(约 1 秒)才看到第一个画面

根本问题是:音频时钟在独立运行,不关心帧有没有到


关键洞察:suspend() 同时冻结两样东西

AudioContext 有一个经常被忽视的性质:

audioContext.suspend() 不仅暂停音频输出,还暂停 currentTime 时钟本身。

const ctx = new AudioContext();
ctx.currentTime; // 0.0

// ... 播放一段时间后 ...
ctx.currentTime; // 2.341

await ctx.suspend();
// 此时 currentTime 冻结在 2.341,不再增加
await sleep(3000);

ctx.currentTime; // 仍然是 2.341(不是 5.341)

await ctx.resume();
// currentTime 从 2.341 继续,而非跳到 5.341

这意味着 suspend() 可以暂停整个时间轴,而不仅仅是静音。

这个性质让它成为一个天然的同步原语——用帧的到达来控制时间轴的开闸:

帧 N+1 已就绪?
  → 是: resume() → 时钟流动 → 绘制帧 → rAF 调度下一次检查
  → 否: suspend() → 时钟冻结 → 等待帧到达事件 → 重新触发检查

实现

音频解码和帧流接收是并行进行的——帧 0 可能在音频解码完成之前就到达,也可能更晚。实现中用 audioReady flag 跟踪音频是否就绪,调度器在两个事件上都能被触发:帧到达时,或音频就绪时。

1. AudioContext 创建后立即 suspend

const AudioCtx = window.AudioContext;
const audioCtx = new AudioCtx();

// 创建后立即挂起——由帧驱动何时 resume
await audioCtx.suspend();

const buffer = await audioCtx.decodeAudioData(rawAudioBuffer);

const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
source.start(0); // start(0) 在 suspended 状态下不会实际播放
const startTime = audioCtx.currentTime;

注意 start(0) 在 suspended 状态下是合法的——它把播放位置固定在 0,但不产生声音。

2. suspend/resume 门控函数

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

// 不检查 audioCtx.state——resume()/suspend() 是异步的,
// state 属性的切换有延迟,检查它会导致 flag 与真实状态分裂。
// 只用本地 flag 做幂等守卫。
const ensureRunning = () => {
  if (!audioRunning && audioCtx) {
    audioRunning = true;
    audioCtx.resume().catch(() => {});
  }
};

const ensureSuspended = () => {
  if (audioRunning && audioCtx) {
    audioRunning = false;
    audioCtx.suspend().catch(() => {});
  }
};

为什么不检查 audioCtx.state

resume()suspend() 都是异步的,state 属性要等微任务队列清空后才切换。如果在同一个 rAF 周期内先调 resume() 再调 ensureSuspended()state 可能还停留在 'suspended'(resume 尚未完成),导致 ensureSuspended 认为"不需要 suspend"而跳过——但 resume 已经在途中,随后完成后音频就失控地开始播放。本地 flag 的翻转是同步的,不存在这个窗口。

3. 核心调度器

let audioReady = false; // 音频解码完成后置 true
let lastDrawnFrame = -1;

const tryAdvance = () => {
  const nextNeeded = lastDrawnFrame + 1;

  // 结束条件
  if (streamEnded && nextNeeded >= totalFrames) {
    ensureSuspended();
    onPlayEnd();
    return;
  }

  if (frames[nextNeeded] !== null) {
    if (!audioReady) {
      // 音频尚未就绪,rAF 轮询等待
      // 此时 audioCtx 还不存在,不能调 ensureRunning
      requestAnimationFrame(tryAdvance);
      return;
    }

    // 有帧且音频就绪 → 打开闸门
    ensureRunning();

    // 检查时钟是否已到该帧时间(后端快于实时时限速)
    const elapsed = audioCtx.currentTime - startTime;
    const frameTime = nextNeeded / fps;

    if (elapsed >= frameTime) {
      draw(frames[nextNeeded]);
      lastDrawnFrame = nextNeeded;
      requestAnimationFrame(tryAdvance); // 立即尝试下一帧
    } else {
      requestAnimationFrame(tryAdvance); // 等时钟追上
    }
  } else {
    // 帧未到 → 关闭闸门,等帧到达事件唤醒
    ensureSuspended();
    // 不调度 rAF——由 onFrameArrived 触发
  }
};

// 每次有新帧解码完成时调用
const onFrameArrived = (index: number) => {
  frames[index] = decodedBitmap;

  // 只有下一顺序帧到达才触发调度(乱序帧不触发)
  if (index === lastDrawnFrame + 1) {
    tryAdvance();
  }
};

4. 乱序帧的处理

后端可能乱序推送帧(帧 N+1 比帧 N 先到):

帧到达顺序: 0, 1, 3, 2, 4, 5 ...
              ↑ 帧 2 迟到

onFrameArrived(3) 时,lastDrawnFrame = 1index !== lastDrawnFrame + 1,不触发调度。 onFrameArrived(2) 时,index === lastDrawnFrame + 1,触发 tryAdvance(),绘制帧 2 后继续绘制已等待的帧 3、4、5。

这个模式保证严格顺序播放,无需额外排序逻辑。


边界情况

后端快于实时:所有帧在音频解码前到达。tryAdvance 看到 frames[nextNeeded] !== nullaudioReady = false,进入 rAF 轮询等待音频就绪。音频就绪后 elapsed >= frameTime 判断阻止帧提前绘制。

0 帧响应streamEnded = truenextNeeded >= totalFrames(0 >= 0),立即 resolve。

播放中断:外部调用 stop() → isPlaying = falsetryAdvance 首行 early return → rAF 循环自然终止。


取舍

优点 缺点
严格音画同步,无论网络如何抖动 网络卡顿时音频会有可感知的停顿
首帧延迟极低(~1 帧,而非缓冲 N 帧) 停顿期间无音频,用户可能误以为播放器崩了
不需要 AudioWorklet 或 ScriptProcessorNode 需要处理 resume() 的异步性(见上文 flag 而非 state)
与现有 close() 清理逻辑完全兼容 Safari 较旧版本在 suspend 过渡中 close() 有内存泄漏风险

何时不适用:如果视频帧来源稳定(本地文件、已缓冲的 HLS),直接用音频时钟驱动帧渲染更简单,不需要这套门控。这个模式的价值在于网络不稳定的流式推送场景。


这个方案把"同步"的责任从业务代码转移到了 AudioContext 本身——不需要手动计算音频和帧之间的时间差,不需要调整缓冲策略,状态也只有 audioRunning 这一个 flag。代价是引入了对 suspend/resume 异步性的理解门槛,以及停顿时无声音的 UX 取舍。


完整代码

audiocontext-sync-gate.ts


延伸阅读

昨天以前首页

如何优雅地上传大文件?分片上传实战指南

作者 leafyyuki
2026年3月5日 18:37

一、背景与流程

当文件体积较大(如视频)时,直接上传容易超时、失败,且无法展示进度。分片上传将文件切成多块依次上传,再在服务端合并,可提升稳定性和体验。

整体流程

初始化(init) → 判断是否分片 → 切片 → 逐块上传(upload) → 合并(merge)
                                    ↑
                            异常/取消时调用 abort

二、核心步骤

2.1 初始化

向服务端发起初始化请求,获取本次上传的 uploadIdfid 及建议的 chunkSize。若返回的 chunkCount <= 1,则走普通单文件上传,不分片。

项目 说明
请求方式 GET
参数 fileName、fileSize、chunkSize(可选)
响应 chunkCount、fid、uploadId、chunkSize

2.2 切片

  • 使用 File.prototype.slice(start, end) 切分文件
  • 每片建议 5M~70M,每片 ≥ 5M
  • 若最后一块 < 5M,需与倒数第二块合并

2.3 逐块上传

  • 请求方式:POST,Content-Type: multipart/form-data
  • 每块需传:fid、uploadId、chunkIndex(从 0 开始)、file
  • 每块返回 chunkTag(JSON 字符串),需按顺序收集,供合并使用
  • 建议控制并发数,避免压垮服务端

2.4 合并

  • 请求方式:POST
  • 参数:fid、uploadId、chunkTagList(按 partNumber 顺序的 JSON 数组字符串)
  • 成功即上传完成,返回最终文件标识

2.5 放弃上传(abort)

初始化后若异常或用户取消,应调用 abort 接口,传入 fid、uploadId,释放服务端资源。


三、关键代码示例

3.1 切片与合并最后小块

const MIN_CHUNK = 5 * 1024 * 1024  // 5M
const CHUNK_SIZE = 50 * 1024 * 1024  // 50M

function createChunkList(file, chunkSize = CHUNK_SIZE) {
  let chunkCount = Math.ceil(file.size / chunkSize)
  const chunkList = []
  for (let i = 0; i < chunkCount; i++) {
    const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize)
    chunkList.push(new File([chunk], file.name))
  }
  // 最后一块 < 5M 必须与前一块合并
  if (chunkList.length > 1 && chunkList[chunkList.length - 1].size < MIN_CHUNK) {
    const lastTwo = new Blob([
      chunkList[chunkList.length - 2],
      chunkList[chunkList.length - 1]
    ])
    chunkList[chunkList.length - 2] = new File([lastTwo], file.name)
    chunkList.pop()
  }
  return chunkList
}

3.2 分片上传主流程

async function uploadChunks(file, initResult, options) {
  const { fid, uploadId, chunkSize } = initResult
  const chunkList = createChunkList(file, chunkSize)
  const chunkTagList = []
  const total = chunkList.length

  for (let i = 0; i < chunkList.length; i++) {
    if (options.abortRequested) {
      await callAbort(fid, uploadId)
      options.onError('已取消上传')
      return
    }
    const chunkTag = await uploadOneChunk({
      file: chunkList[i],
      fid,
      uploadId,
      chunkIndex: i,
      onProgress: options.onProgress,
      partPercent: ((i + 1) / total) * 100
    })
    if (!chunkTag) {
      options.onError('分片上传失败')
      return
    }
    chunkTagList.push(chunkTag)
  }

  await mergeChunks(fid, uploadId, chunkTagList, options)
}

3.3 单块上传(含进度)

import axios from 'axios'

function uploadOneChunk({ file, fid, uploadId, chunkIndex, onProgress, partPercent }) {
  const formData = new FormData()
  formData.append('fid', fid)
  formData.append('uploadId', uploadId)
  formData.append('chunkIndex', chunkIndex)
  formData.append('file', file)

  return axios.post(`${UPLOAD_BASE}/chunk/upload/${fid}`, formData, {
    onUploadProgress: (e) => {
      if (e.lengthComputable) {
        const percent = (e.loaded / e.total) * partPercent
        onProgress({ percent })
      }
    }
  }).then(res => {
    const data = res.data
    if (data.code === 200 && data.result?.chunkTag) {
      return JSON.parse(data.result.chunkTag)
    }
    return null
  }).catch(() => null)
}

3.4 合并

import axios from 'axios'

function mergeChunks(fid, uploadId, chunkTagList, options) {
  const formData = new FormData()
  formData.append('fid', fid)
  formData.append('uploadId', uploadId)
  formData.append('chunkTagList', JSON.stringify(chunkTagList))

  axios.post(`${UPLOAD_BASE}/chunk/merge/${fid}`, formData).then(res => {
    const data = res.data
    if (data.code === 200) options.onSuccess(data)
    else options.onError(data.msg || '合并失败')
  }).catch(() => options.onError('合并失败'))
}

3.5 进度计算

普通上传:percent = (loaded / total) * 100

分片上传:按块权重累加

// 当前块进度 × 该块占总进度的权重
const partPercent = ((currentChunkIndex + 1) / totalChunks) * 100
const overallPercent = (chunkLoaded / chunkTotal) * partPercent

四、实现要点

说明
FormData axios 传 FormData 时无需手动设置 Content-Type,由 axios 自动添加 boundary
chunkTag 响应为 JSON 字符串,需 JSON.parse 后按顺序 push 到 chunkTagList
并发 建议串行或限制并发数,避免服务端压力过大
取消 用户取消或异常时调用 abort,释放服务端资源
鉴权 按项目约定在 FormData 或请求头中附带 token 等鉴权信息

五、检查清单

  • 初始化用 GET,chunkCount ≤ 1 时走普通上传
  • chunkSize 在 5M~70M,每片 ≥ 5M,最后小块已合并
  • 切片顺序与 chunkIndex 一致,chunkTagList 按 partNumber 顺序
  • 分片上传控制并发,取消/异常时调用 abort

六、视频播放:进度条拖动与 Range 请求排查

分片上传后,视频通常通过 CDN 或文件服务直链播放。若 <video> 标签的进度条无法拖动、或拖动后跳转失败,多半与 HTTP Range 请求 有关。

6.1 原理简述

拖动进度条本质是「跳转播放位置」。浏览器会发起带 Range 头的请求,按需拉取视频片段:

GET /video.mp4
Range: bytes=1048576-2097151

服务端需返回 206 Partial Content 及对应字节范围,否则无法按需跳转。

6.2 常见问题与排查

现象 可能原因 排查方法
进度条拖动无效 服务端未支持 Range 在 Network 面板查看请求是否有 Range 头,响应是否为 206
只能从头播放 未返回 Accept-Ranges: bytes 检查响应头是否包含 Accept-Ranges: bytes
跨域视频无法 seek CORS 未正确配置 服务端需返回 Access-Control-Allow-OriginAccess-Control-Expose-Headers: Content-Length, Content-Range
部分时段可拖动、部分不可 视频 moov 在文件末尾 MP4 的 moov 元数据在末尾时,需先下载到末尾才能 seek。用 ffmpeg -movflags faststart 将 moov 移到文件头
小文件可拖动、大文件不可 大文件未做 Range 支持 确认 CDN / 对象存储 / 反向代理均已开启 Range 支持

6.3 快速排查步骤

  1. 打开开发者工具 → Network,播放视频并拖动进度条。
  2. 观察视频请求:
    • 是否有 Request Headers: Range: bytes=xxx-xxx
    • 响应状态是否为 206 Partial Content
    • 响应头是否包含 Accept-Ranges: bytes
  3. 若为跨域视频,检查响应头是否包含 Access-Control-Allow-Origin 等 CORS 头。
  4. 若服务端不支持 Range,在 Nginx 等配置中可添加:
add_header Accept-Ranges bytes;
  1. 若为 MP4 格式,可用 ffmpeg 优化:
ffmpeg -i input.mp4 -movflags faststart output.mp4

6.4 前端 preload 影响

<video preload="metadata"> 只加载元数据,不预加载内容。在未缓冲到的区域拖动时,必须依赖服务端 Range 支持才能跳转。若改为 preload="auto" 可预加载更多,可 seek 范围更大,但会增加流量消耗。

❌
❌