浏览器内录制无总时长? 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 IDData 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, 去掉 widthmarker 得到 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 ElementWebm

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

解码 EBML Element

解码 V_INT

根据上面的内容,得到解码的代码如下所示,当 isElementIDtrue 的时候我们需要保留 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 ID0x1a45dfa3, 看看上面的 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, 如下图

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

找到 ID 表 里面的 DocType 对应的 id0x4282, 打印出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 ElementInfo Element , 将 Duration Element 编码进入 Info Element, 重新编码 SegmentWebm 文件

解码 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 出来的 segementData 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 ElementID0x1549A966

Duration ElementID0x4489

找到 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])
相关推荐
海盐泡泡龟20 分钟前
deepSeek浅谈对vue的mixin的理解,用于什么应用场景?
开发语言·前端·javascript·vue.js
不思念一个荒废的名字20 分钟前
【黑马JavaWeb+AI知识梳理】前端Web基础02 - JS+Vue+Ajax
前端·javascript·vue.js
程序猿John21 分钟前
uniapp跳转和获取参数方式
前端·javascript·uni-app
yuanyxh27 分钟前
过去一年的工作总结
前端·javascript·html
codingandsleeping30 分钟前
在monaco-editor中给第三方库添加TS类型提示
前端·javascript
蓝莓味柯基42 分钟前
React:什么是Hook?通俗易懂的讲讲
前端·javascript·react.js
好_快1 小时前
Lodash源码阅读-sortedUniq
前端·javascript·源码阅读
好_快1 小时前
Lodash源码阅读-baseSortedUniq
前端·javascript·源码阅读
姜太小白2 小时前
【前端】jQuery 对数据进行正序排列
前端·javascript·jquery
艾小逗5 小时前
vue3中的effectScope有什么作用,如何使用?如何自动清理
前端·javascript·vue.js