起因
最近有个需求要在浏览器里录制音频, 大致代码如下
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])