浏览器长时间录音与切片上传的工程化实践

4 阅读3分钟

浏览器长时间录音与切片上传的工程化实践

目标:长时间录音(小时级)+ 稳定切片上传 + 不阻塞主线程 + 可控失败与边界处理

本文不是 Demo,而是一套可上线的工程方案总结,适用于:

  • Web 录音
  • Electron / WebView
  • 需要边录边传的场景(存储 / ASR / 审计)

一、背景问题

浏览器中使用 MediaRecorder 录音非常方便,但一旦进入 长时间录音 + 实时上传,会立刻遇到以下挑战:

  • 主线程不能被阻塞
  • 网络慢 / 断网
  • 上传失败重试
  • 内存持续增长
  • 页面切后台 / 锁屏

如果只是简单在 ondataavailablefetch一定会翻车


二、核心设计思想

1️⃣ 采集与上传彻底解耦

MediaRecorder
  ↓ (1s)
ondataavailable
  ↓
内存缓冲 buffer
  ↓ (≥2MB / pause / stop)
上传队列 uploadQueue
  ↓ (串行)
网络上传

采集只做内存操作,上传只在队列中发生


2️⃣ 为什么不能直接上传?

  • fetch 是慢 I/O
  • 网络不稳定
  • retry 会阻塞

👉 必须引入队列模型(生产者 / 消费者)


三、MediaRecorder 行为认知

mediaRecorder.start(1000)
  • ondataavailable 不是音频帧
  • e.data编码后的 Blob(时间片)
  • timeslice 决定触发频率

四、2MB 切片 ≠ 不会内存爆

常见误区

上传成功后会 shift 掉队列,为什么还会无限增长?

真相

  • 2MB 只是单个 task 的大小
  • 队列增长取决于:
录音产生速度  >  上传完成速度

这是一个典型的生产者-消费者失衡问题


五、关键实现方案

1️⃣ 状态与常量

const UPLOAD_THRESHOLD = 2 * 1024 * 1024 // 2MB
const MAX_QUEUE_LENGTH = 20 // 队列上限
const MAX_RETRY = 3

2️⃣ 非阻塞采集(主线程安全)

mediaRecorder.ondataavailable = (e) => {
  if (!e.data || e.data.size === 0) return

  bufferBlobs.push(e.data)
  bufferSize += e.data.size

  if (bufferSize >= UPLOAD_THRESHOLD) {
    flushBuffer("threshold")
  }
}

✔ 只做内存操作 ✔ 无 await / 无网络


3️⃣ flush:进入上传队列(解耦核心)

function flushBuffer(reason) {
  if (bufferBlobs.length === 0) return

  if (uploadQueue.length >= MAX_QUEUE_LENGTH) {
    mediaRecorder.pause()
    throw new Error("upload queue overflow")
  }

  const blob = new Blob(bufferBlobs, { type: bufferBlobs[0].type })

  uploadQueue.push({
    seq: seq++,
    blob,
    retry: 0,
    reason,
  })

  bufferBlobs = []
  bufferSize = 0

  processUploadQueue()
}

4️⃣ 串行上传 + 重试(防死循环)

function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

// 工具函数:带抖动的指数退避延迟
const expBackoffDelay = (retryCount) => {
  const maxDelay = 30000
  const base = Math.min(Math.pow(2, retryCount - 1) * 1000, maxDelay)

  const jitter = base * 0.3 * (Math.random() - 0.5) // ±15%
  const delay = Math.max(0, base + jitter)

  return new Promise((resolve) => setTimeout(resolve, delay))
}

async function processUploadQueue() {
  if (uploading) return
  uploading = true

  while (uploadQueue.length > 0) {
    const task = uploadQueue[0]
    try {
      await uploadChunk(task)
      uploadQueue.shift()
    } catch (e) {
      task.retry++
      if (task.retry > MAX_RETRY) {
        mediaRecorder.stop()
        uploadQueue = []
        break
      }
      await delay(Math.min(1000 * 2 ** task.retry, 30000))
    }
  }

  uploading = false
}

5️⃣ pause / stop 时不满 2MB 也要上传

function pauseRecord() {
  mediaRecorder.pause()
  flushBuffer("pause")
}

mediaRecorder.onstop = () => {
  flushBuffer("stop")
}

function stopRecord() {
  mediaRecorder.stop()
}

六、必须处理的边界情况(重点)

1️⃣ 队列无限增长(最危险)

  • 网络慢 / 断网
  • retry 期间持续产生数据

👉 必须限制 uploadQueue 长度


2️⃣ 页面切后台 / 锁屏(iOS 必踩)

document.addEventListener("visibilitychange", () => {
  if (document.hidden) {
    mediaRecorder.pause()
    flushBuffer("background")
  }
})

3️⃣ 网络断开

window.addEventListener("offline", () => {
  console.warn("network offline")
})

window.addEventListener("online", () => {
  processUploadQueue()
})

4️⃣ MediaRecorder 假死(无回调)

let lastDataTime = Date.now()

setInterval(() => {
  if (mediaRecorder.state === "recording" && Date.now() - lastDataTime > 5000) {
    console.warn("recorder stalled")
  }
}, 3000)

5️⃣ 页面关闭 / 刷新

window.addEventListener("beforeunload", () => {
  flushBuffer("unload")
})

兜底方案:navigator.sendBeacon


七、服务端必须配合的点

  • (sessionId, seq) 幂等
  • 允许乱序
  • 允许重复上传
  • 断点续传友好

八、关键经验总结

2MB 是粒度控制,不是稳定性保障

真正保证系统稳定的是:

  • 背压(backpressure)
  • 队列上限
  • 网络异常处理
  • 生命周期感知

九、适用场景

  • 长时间会议录音
  • 浏览器端审计录音
  • 边录边传 ASR