Blog

纯JS解析合并HLS流中的AAC音频

2023-07-09

#HLS
#AAC
#JavaScript
#水

拿到一个 m3u8 文件后,该怎么下载内容?反正我第一时间想到的是 FFmpeg……

最近在拆分 Twitter Monitor 的媒体下载页面,这里面其中一个功能就是下载 Spaces 的音频,我并不想用 FFmpeg 这种重量级工具,并且 ffmpeg.js 还是有不少限制的,于是我开始在网上寻找有没有纯 JavaScript 的方案,结果还真找到了:HLS中的AAC如何合并

这个AAC是由ID3标签和ADTS组成的格式。

ADTS是AAC的一种编码方式,与另一种编码方式ADIF不同,ADTS可以在任意帧解码。因此我们只需要把AAC中的ID3标签去掉,然后在拼接起来就能得到正常的AAC文件了。

实际上 FFmpeg 就是这样干的,直接删掉所有的 ID3 标签然后合并所有的块,那么就很容易得到第一种方案:

删掉全部 ID3 标签

简单粗暴,不用管内容有什么,直接全删了。

适配 ArrayBuffer

写原型时我用了 Node.js,自然习惯性地用 Buffer,但浏览器没这玩意,所以得想办法处理:

// Like Buffer.indexOf('494433', cursor, "hex") // ID3

const arrayBufferUint8HexIndexOf = (content, find = [], cursor = 0) => {
    if (find.length < 0 || find.length > content.byteLength || cursor > content.byteLength) {
        return -1
    }
    while (true) {
        const firstWord = content.indexOf(find[0], cursor)
        if (firstWord === -1) { return -1 }
        else if (!find.slice(1).some((findItem, index) => content.indexOf(findItem, cursor) !== (firstWord + index + 1))) {
            return firstWord
        } else if (cursor > content.byteLength) {
            return -1
        } else {
            cursor++
        }
    }
}

function toDataView(buffer) {
    const arrayBuffer = new ArrayBuffer(buffer.length);
    const view = new Uint8Array(arrayBuffer);
    for (let i = 0; i < buffer.length; ++i) {
        view[i] = buffer[i];
    }
    return new DataView(arrayBuffer);
}

Buffer 一行解决的事情到这边就要写一大串,特麻烦。然后就是挨个处理。

const bufferList = []
for (const index in fileList) { // fileList: ArrayBuffer[] | Buffer[]
    const file = fileList[index]
    let tmpBuffer = toDataView(file)
    let cursor = 0
    let offset = 0

    while (true) {
        cursor = arrayBufferUint8HexIndexOf(new Uint8Array(tmpBuffer.buffer), [0x49, 0x44, 0x33], cursor)//tmpBuffer.indexOf('494433', cursor, "hex")

        if (cursor === -1) {
            bufferList.push(tmpBuffer.buffer.slice(offset))
            break
        } else if (cursor > offset) {
            bufferList.push(tmpBuffer.buffer.slice(offset, cursor))
        }
        let size = 0
        for (let sizeOffset = 0; sizeOffset < 4; sizeOffset++) {
            size = (size << 7) + new Uint8Array(tmpBuffer.buffer.slice(cursor + 6 + sizeOffset, cursor + 7 + sizeOffset))[0]
        }
        // wrong id3
        if (size < 0 || size > tmpBuffer.byteLength) {
            offset = cursor
            cursor++
            continue
        }
        size += 10
        offset = cursor += size

        //--> 只删第一个 ID3 标签就取消注释这两行 <--
        //bufferList.push(tmpBuffer.buffer.slice(offset))
        //break
    }
}
const bufferByteLength = bufferList.reduce((a, b) => a + b.byteLength, 0)

const aacArrayBuffer = bufferList.reduce((a, b) => {
    a[0].set(new Uint8Array(b), a[1])
    return [a[0], a[1] + b.byteLength || 0]
}, [new Uint8Array(bufferByteLength), 0])[0]

aacArrayBuffer 就是最终的内容了,跟 FFmpeg 生成的一模一样

名称: test_pure_js.aac
大小: 51613989 字节 (49 MiB)
SHA1: 5ee78601527b263cfdf79dfe32e1abf76cb81b49

名称: test_ffmpeg.aac
大小: 51613989 字节 (49 MiB)
SHA1: 5ee78601527b263cfdf79dfe32e1abf76cb81b49

只删第一个 ID3 标签

实际上只有第一个 ID3 标签因为记录了时间信息(准确点来说应该是帧信息?com.apple.streaming.transportStreamTimestamp)必须删掉,其他标签都是不是必须删掉的,所以只需要删掉第一个 ID3 标签就能在正常播放,这样还能同时节省时间和资源,做法也很简单,就是把上面的脚本有标记的那处注释去掉。

取得帧内容

Spaces 会在往帧里塞音频信息和发言者的信息,所以还需要提取内容。

经过前期的研究,Spaces的文件只会有三种帧类型:PRIVTIT3TXXX,而 TXXX 类型只会是 UTF-8, 因此只需要处理这三种标签类型。如果要提取其他音频流的 ID3 标签可能还需要处理其他类型的标签

const id3FrameParser = (content) => {
    if (content.byteLength <= 0) { return [] }
    const realContent = content.slice(10)
    let offset = 0
    const frameList = []

    while (offset <= realContent.byteLength) {
        const type = [...(new Uint8Array(realContent.slice(offset, offset + 4)))].map(x => String.fromCharCode(x)).join('')
        const size = [...(new Uint8Array(realContent.slice(offset + 4, offset + 8)))].reduce((a, b) => (a << 7) + b, 0)
        const flags = realContent.slice(offset + 8, offset + 10)
        const encodeType = new Uint8Array(realContent)[10]
        const isText = ['TXXX', 'TIT3'].includes(type)
        const tmpFrameContent = realContent.slice(offset + 10 + (isText !== false ? 1 : 0), offset + 10 + size)
        //PRIV->ArrayBuffer, TIT3->String, TXXX->String|Object
        const frameContent = isText ? [...(new Uint8Array(tmpFrameContent))].map(x => String.fromCharCode(x)).join('').slice(0, -1) : tmpFrameContent

        if (type) {
            if (['TXXX'].includes(type)) {

                let tmpFrameContent = frameContent.split("\x00")
                try {
                    tmpFrameContent[1] = JSON.parse(tmpFrameContent[1])
                } catch (e) { }

                frameList.push([type, size, flags, tmpFrameContent])
            } else {
                frameList.push([type, size, flags, frameContent])
            }
        }

        offset += (10 + size)

    }
    return frameList
}

其他

最初的时候我合并了半天都没成功,最后检查发现几个月前研究时我是以字符串写入文件的,于是所有二进制内容都受到了破坏……看到一串的 EF BF BD 时属实是无语了……

你问我下载页面去哪了?不好意思目前进度还没新建文件夹……

体验地址:https://twmedia.nest.moe/,输入框填 Space 链接或者 ID 即可

写文章时精神我的状态极差……以至于用着支离破碎的语言在写不知道什么玩意

参考


评论区