浏览器内录制无总时长? 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])
相关推荐
有梦想的刺儿7 分钟前
webWorker基本用法
前端·javascript·vue.js
清灵xmf1 小时前
TypeScript 类型进阶指南
javascript·typescript·泛型·t·infer
小白学大数据1 小时前
JavaScript重定向对网络爬虫的影响及处理
开发语言·javascript·数据库·爬虫
qq_390161771 小时前
防抖函数--应用场景及示例
前端·javascript
334554322 小时前
element动态表头合并表格
开发语言·javascript·ecmascript
John.liu_Test2 小时前
js下载excel示例demo
前端·javascript·excel
PleaSure乐事2 小时前
【React.js】AntDesignPro左侧菜单栏栏目名称不显示的解决方案
前端·javascript·react.js·前端框架·webstorm·antdesignpro
哟哟耶耶2 小时前
js-将JavaScript对象或值转换为JSON字符串 JSON.stringify(this.SelectDataListCourse)
前端·javascript·json
理想不理想v2 小时前
vue种ref跟reactive的区别?
前端·javascript·vue.js·webpack·前端框架·node.js·ecmascript
栈老师不回家3 小时前
Vue 计算属性和监听器
前端·javascript·vue.js