浏览器内录制无总时长? Js 编解码音视频解决

起因

最近有个需求要在浏览器里录制音频, 大致代码如下

js 复制代码
const mediaStream = await navigator.mediaDevices.getUserMedia({
    video: false,
    audio: true,
})

const mediaStreamRecorder = new MediaRecorder(mediaStream, {
    mimeType: "video/webm",
})

mediaStreamRecorder.addEventListener("dataavailable", ({ data }) => {
    appendBlob(audioBlob, data)
})

大致逻辑是通过 MediaRecorder 录制 webm 格式的文件, 但是播放的时候发现, 一开始音频播放器没有总时长, 进度条也不对, 在播放到快要结束的时候才正常显示总时长. 后面查了一下资料才发现, 是录制出来的webm文件没有总时长Duration的原因

对于这种情况, 解决方案是在录制的过程中记录时长, 结束录制的时候给文件写入一个固定时长, 这个过程需要对 webm 进行编解码

Webm 格式

webm 是基于 mkv 格式的, 并且有一些字段是固定值, 比如DocType一定是webm, 编解码 webm 就是编解码 mkv, 更多内容的可以访问webm 官网,

mkv 基于 EBML 实现,具体格式由一个 EBML Header 和一个 Segment 组成, 如果文件复杂一点,那么就由多个 header|segment 拼接组成, mkv 的官网在这里

而 EBML 的内容, 需要去看一下 rfc , 我只介绍下重要的部分

EBML 的最小单位是 EBML Element, 上面提到的 EBML Header,Segment 都是 EBML Element

下面我具体讲解一下 EBML Element 结构

EBML Element

Element 的结构非常简单, 由 3 个部分组成: ID | Data Size | Data

Element ID可以在 ID 表 里面查到对应的 Element Name, EBML rfc,mkv 分别定义了一些

下面是 EBML 定义的一些 ID 表

Data Size 表示 Data 的字节长度

Data 是该 Element 的数据, 里面可能

其中 Element ID 和 Data Size 都是 V_INT 编码, 要解码 Element 就要先理解 V_INT

V_INT

V_INT 全称是 Variable-Size Integer, V_INT 编码的数据被分成三个部分width,marker,data

从V_INT 最左边开始计数, 0 出现的个数加 1 就是整个 V_INT 的字节长度 width , 当遇到 marker 1 的时候停止, 后面蓝色的部分都是 data

比如上图的开头出现 0 的个数是 1, 那么 width 就是 1+1=2Byte, 去掉 width 和 marker 得到 data 的长度是 2 * 8 - 2 = 14bit, data 就是我们需要的数据

同理可以得到当width为不同值的时候, V_INT 的值
需要注意的是 Element ID 的值是需要带上 marker 的,我翻了 rfc ,但是没有找到哪里有写这个东西,有找到的朋友可以在评论区说一下,Data Size 是不带 marker 的,

总结一下目前的知识

Webm 基于 mkv, mkv 基于 EBML, EBML Element部分数据需要解码V_INT

所以需要一步步来, 先解码 V_INT, 再解码 EBML Element 和 Webm

所有的解码都是基于 ArrayBuffer 的, 不了解 ArrayBuffer 的可以去 MDN 上看看

解码 EBML Element

解码 V_INT

根据上面的内容,得到解码的代码如下所示,当 isElementID 为 true 的时候我们需要保留 marker

typescript 复制代码
function decodeVINT(data: ArrayBuffer, isElementID = false) {
  const dataView = new DataView(data)

  // 读取 V_INT width
  let vWidth = dataView.getUint8(0)
  // 记录长度
  let length = 1

  if (vWidth === 0) {
    return { value: 0, offset: 0 }
  }

  while ((vWidth & 0b1000_0000) === 0b0000_0000) {
    length += 1
    // 移掉前面的 0
    vWidth <<= 1
  }

  if (!isElementID) {
    // element id 需要保留 marker
    vWidth &= 0b0111_1111
  }

  vWidth >>= length - 1

  const uint8Array = new Uint8Array(data)
  let value = vWidth
  // 循环读取出所有的数据
  for (let i = 0; i < length - 1; i++) {
    value <<= 8
    value |= uint8Array[i + 1]
  }

  return {
    value,
    offset: length,
  }
}

根据上面提到的 Element 结构, 得到解码 EBML Element 的代码如下所示

typescript 复制代码
export function decodeEBMLElement(buffer: ArrayBuffer) {
  // 计算读取的偏移量
  let offset = 0
  const elementID = decodeVINT(buffer, true)
  offset += elementID.offset

  const dataSize = decodeVINT(buffer.slice(offset))
  offset += dataSize.offset

  const data = buffer.slice(offset, offset + dataSize.value)

  return {
    id: elementID.value,
    idHex: `0x${elementID.value.toString(16)}`,
    dataSize: dataSize.value,
    offset: dataSize.value + offset,
    data,
  }
}

我们来验证一下,把它拿来解码之前 MediaRecorder录制的audioBlob, 得到的结果如下所示

获取的 Element ID是 0x1a45dfa3, 看看上面的 ID 表, 对应的是EBML 的根元素,也就是 EBML Header

继续 decode EBML Header data 的数据

typescript 复制代码
let headerBuffer = ebmlElement.data
while (headerBuffer.byteLength > 0) {
  const headerField = decodeEBMLElement(headerBuffer)
  headerBuffer = headerBuffer.slice(headerField.offset)
  console.log(headerField)
}

得到所有 header 里面所有的Element, 如下图

还记得上面提到的 Webm 的DocType 一定是 webm 吗?

找到 ID 表 里面的 DocType 对应的 id 是 0x4282, 打印出DocType的值

typescript 复制代码
let headerBuffer = ebmlElement.data
while (headerBuffer.byteLength > 0) {
  const headerField = decodeEBMLElement(headerBuffer)
  headerBuffer = headerBuffer.slice(headerField.offset)
  if (headerField.id === 0x4282) {
    const docType = new TextDecoder().decode(headerField.data)
    console.log("docType", docType)
  }
}

控制台输出

输出确实是 webm,说明我们的解码代码是正确的,到了这一步,我们可以开始解码webm文件了

解码 Webm

现在我们再来看一下 mkv 的格式,地址在这里

具体格式如下所示

EBML header 我们之前已经成功解码,后面紧跟的 Segment Element,里面的 Info Element 结构如下

Info Element 里面的 Duration Element 就是我们的目标,只要把它编码进去,我们就可以给 webm 文件设置一个固定时长

总结一下整个流程

先解码 Segment Element, 再解码 Segment Element 的 Info Element , 将 Duration Element 编码进入 Info Element, 重新编码 Segment 和 Webm 文件

解码 Segement

提取出 Segment ,具体代码如下所示

typescript 复制代码
export function decodeWebm(buffer: ArrayBuffer) {
  let offset = 0
  const header = decodeEBMLElement(buffer)
  offset += header.offset

  const segment = decodeEBMLElement(buffer.slice(offset))
  offset += segment.offset
}

但是此处 decode 出来的 segement 的 Data Size 是 -1, 这表示当前数据无具体长度的

为了修正这个问题,还需要在 decodeEBMLElement 里面加点代码,具体代码如下所示

typescript 复制代码
export function decodeEBMLElement(buffer: ArrayBuffer) {
  // 计算读取的偏移量
  let offset = 0
  const elementID = decodeVINT(buffer, true)
  offset += elementID.offset

  const dataSize = decodeVINT(buffer.slice(offset))
  offset += dataSize.offset
  
  // 当 Data Size 不确定的时候
  if (dataSize.value === -1) {
    const data = buffer.slice(offset)

    return {
      id: elementID.value,
      idHex: `0x${elementID.value.toString(16)}`,
      dataSize: -1,
      offset,
      data,
    }
  }

  const data = buffer.slice(offset, offset + dataSize.value)

  return {
    id: elementID.value,
    idHex: `0x${elementID.value.toString(16)}`,
    dataSize: dataSize.value,
    offset: dataSize.value + offset,
    data,
  }
}

查表得到 Info Element 的 ID 是 0x1549A966

Duration Element 的 ID 是 0x4489

找到 Info Element 并解码, 代码如下

typescript 复制代码
function decodeInfoElement(buffer: ArrayBuffer) {
  const infos = [] as ReturnType<typeof decodeEBMLElement>[]
  while (buffer.byteLength > 0) {
    const infoField = decodeEBMLElement(buffer)
    buffer = buffer.slice(infoField.offset)
    infos.push(infoField)
  }
  return infos
}

function decodeSegment(buffer: ArrayBuffer) {
  while (buffer.byteLength > 0) {
    const segmentField = decodeEBMLElement(buffer)
    buffer = buffer.slice(segmentField.offset)
    if (segmentField.id === 0x1549a966) {
       // 解码 info Element
      const infos = decodeInfoElement(segmentField.data)
      console.log("infos", infos)
    }
  }
}

控制台输出

这三个 Info 的 子 Element 分别对应 0x2ad7b1 -> TimestampScale, 0x4d80 -> MuxingApp, 0x5741 -> WritingApp

确实没有 Duration 字段 , 下一步就是把 Duration 编码进去

编码 EBML Element

先定义 EBML Element

typescript 复制代码
type EBMLElement = {
  id: number
  data: ArrayBuffer | EBMLElement[]
}

整个编码的过程就是把解码的逆运算

Encode Element ID

需要注意的是 Element ID 是需要带上 marker 的

typescript 复制代码
function encodeElementID(value: number) {
  const rangeMap = [
    {
      start: 0x81,
      end: 0xfe,
    },
    {
      start: 0x407f,
      end: 0x7ffe,
    },
    {
      start: 0x203fff,
      end: 0x3ffffe,
    },
    {
      start: 0x101fffff,
      end: 0x1ffffffe,
    },
  ]
  for (let i = 0; i < rangeMap.length; i++) {
    const v = rangeMap[i]
    if (value >= v.start && value <= v.end) {
      const buffer = new ArrayBuffer(i + 1)
      const dataView = new DataView(buffer)
      for (let j = 0; j < i + 1; j++) {
        dataView.setUint8(j, (value >>> ((i - j) * 8)) & 0xff)
      }

      return buffer
    }
  }
  throw Error(`can't encode element id ${value}`)
}

Encode Element Data Size

编码 Element Data Size 不需要 marker

typescript 复制代码
function encodeDataSize(value: number) {
  if (value < 0) {
    const buffer = new ArrayBuffer(8)
    const dataView = new DataView(buffer)
    dataView.setUint32(0, 0x1ff_ffff)
    dataView.setUint32(4, 0xffffffff)
    return buffer
  } else {
    for (let n = 1; n <= 8; n++) {
      if (value <= 2 ** (7 * n) - 2) {
        const buffer = new ArrayBuffer(n)
        const dataView = new DataView(buffer)

        for (let i = n - 1; i >= 0; i--) {
          dataView.setUint8(i, (value >> (n - i - 1)) & 0xff)
        }

        dataView.setUint8(0, dataView.getUint8(0) | (0b1000_0000 >> (n - 1)))

        return buffer
      }
    }
    throw Error("value too big")
  }
}

Encode Element

定义拼接 ArrayBuffer 的函数

typescript 复制代码
function concatArrayBuffer(...buffers: ArrayBuffer[]) {
  const newBuffer = new ArrayBuffer(
    buffers.reduce((acc, cur) => acc + cur.byteLength, 0)
  )
  const newBufferUint8Array = new Uint8Array(newBuffer)

  let offset = 0
  for (let i = 0; i < buffers.length; i++) {
    newBufferUint8Array.set(new Uint8Array(buffers[i]), offset)
    offset += buffers[i].byteLength
  }
  return newBuffer
}

Encode EBML Element Tree

typescript 复制代码
function encodeEBMLElementTree(element: EBMLElement) {
  const rawID = encodeElementID(element.id)
  let data: ArrayBuffer
  if (Array.isArray(element.data)) {
    const buffers: ArrayBuffer[] = element.data.map(encodeEBMLElementTree)
    data = concatArrayBuffer(...buffers)
  } else {
    data = element.data
  }

  const dataSize = element.dataSize ?? data.byteLength
  const rawDataSize = encodeDataSize(dataSize)
  return concatArrayBuffer(rawID, rawDataSize, data)
}

拼接 Duration

因为 Duration ID 对应是 0x4489

最终编码Duration的代码如下

typescript 复制代码
export function setWebmDuration(audioBuffer: ArrayBuffer, duration: number) {
  const buffer = audioBuffer

  let offset = 0
  const header = decodeEBMLElement(buffer)
  offset += header.offset

  const segment = decodeEBMLElement(buffer.slice(offset))
  offset += segment.offset

  const segmentSubElements = decodeSegment(segment.data) as EBMLElement[]

  // 找到 info element  的 index
  const infoIndex = segmentSubElements.findIndex((v) => v.id === 0x1549a966)
  if (infoIndex === -1) {
    throw Error("cant' find info element")
  }

  const infos = decodeInfoElement(
    segmentSubElements[infoIndex].data as ArrayBuffer
  ) as EBMLElement[]

  // 写入 duration , 默认的是 8 字节的大小
  const durationBuffer = new ArrayBuffer(8)
  new DataView(durationBuffer).setFloat64(0, duration)

  infos.push({
    id: 0x4489,
    data: durationBuffer,
  })

  // 编码
  const rawInfos = concatArrayBuffer(...infos.map(encodeEBMLElementTree))
  segmentSubElements[infoIndex].data = rawInfos
  segmentSubElements[infoIndex].dataSize = rawInfos.byteLength

  return concatArrayBuffer(
    encodeEBMLElementTree(header),
    encodeEBMLElementTree({
      id: 0x1549a966,
      data: segmentSubElements,
    })
  )
}

尝试一下效果, 编码 audio 的时长为 12:34:56 看一下效果

typescript 复制代码
const newAudioBlob = new Blob([
  setWebmDuration(audioBlob, 45296 * 1000),
])

document.querySelector("audio")!.src = URL.createObjectURL(newAudioBlob)

结果如下

可以看到 duration 被成功的写入了

总结

具体的代码, 我放到 github 上面了, 并且封装成了一个库, 有兴趣的朋友可以去看一下源代码, 欢迎在评论区友好讨论

typescript 复制代码
import { setWebmDuration } from '@ozean/set-webm-duration'

const audioBuffer = await audioBlob.arrayBuffer()
// 写入 audio duration
const newAudioBuffer = setWebmDuration(audioBuffer, 45296 * 1000)
const newAudioBlob = new Blob([newAudioBuffer])
相关推荐
zttbee9 小时前
echarts图表tooltip色块失效
前端·javascript·echarts
虚无境11 小时前
如何用AI帮我写出实用的插件
前端·javascript·ai编程
zimoyin12 小时前
在 C# 里嵌入 JavaScript:用 Jint 做可热更新的自动化脚本(从入门到踩坑)
javascript·c#·自动化
FRing0313 小时前
JavaScript入门到精通二十六(变量提升、函数剩余参数、展开运算符、ES6箭头函数)
开发语言·前端·javascript·es6·箭头函数
OpsEye14 小时前
怎么实现大模型的按用户、按应用配额限流?
javascript·ai编程
云舜言传15 小时前
Bloom 手势演示操作 · 录屏指南
javascript·深度学习·机器学习
Highcharts.js18 小时前
BI可视化图表“维护难”问题的解决方案
开发语言·前端·javascript·编辑器·ecmascript·bi·highcharts
倚窗听花_18 小时前
IIS部署前端Vue项目
前端·javascript·vue.js
yixun_gdas18 小时前
政企网站错敏巡查:错敏都藏在哪些位置?
开发语言·前端·javascript·内容运营
小羊没烦恼!18 小时前
.NET 11 Runtime Async 详解
java·运维·开发语言·javascript·python