浏览器内录制无总时长? 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])
相关推荐
云白冰3 分钟前
hiprint结合vue2项目实现静默打印详细使用步骤
前端·javascript·vue.js
匹马夕阳1 小时前
详细对比JS中XMLHttpRequest和fetch的使用
开发语言·javascript·ecmascript
长风清留扬2 小时前
小程序开发实战项目:构建简易待办事项列表
javascript·css·微信小程序·小程序·apache
程序员_三木2 小时前
从 0 到 1 实现鼠标联动粒子动画
javascript·计算机外设·webgl·three.js
点点滴滴的记录2 小时前
Java的CompletableFuture实现原理
java·开发语言·javascript
web Rookie2 小时前
React 中 createContext 和 useContext 的深度应用与优化实战
前端·javascript·react.js
男孩122 小时前
react高阶组件及hooks
前端·javascript·react.js
hhzz3 小时前
vue前端项目中实现电子签名功能(附完整源码)
前端·javascript·vue.js
秋雨凉人心3 小时前
上传npm包加强
开发语言·前端·javascript·webpack·npm·node.js
JoeChen.4 小时前
PostCSS插件——postcss-pxtorem结合动态调整rem实现字体自适应
javascript·ecmascript·postcss