@audio/webm-decode 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +47 -0
- package/webm-decode.d.ts +16 -0
- package/webm-decode.js +452 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 audiojs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# webm-decode
|
|
2
|
+
|
|
3
|
+
Decode WebM audio (Opus and Vorbis) to PCM float samples. EBML demuxer in pure JS, codec decoding via WASM.
|
|
4
|
+
|
|
5
|
+
Part of [audio-decode](https://github.com/audiojs/audio-decode).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npm i @audio/webm-decode
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import decode from '@audio/webm-decode'
|
|
17
|
+
|
|
18
|
+
let { channelData, sampleRate } = await decode(webmBuffer)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Streaming
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import { decoder } from '@audio/webm-decode'
|
|
25
|
+
|
|
26
|
+
let dec = await decoder()
|
|
27
|
+
let result = await dec.decode(chunk)
|
|
28
|
+
let flushed = await dec.flush()
|
|
29
|
+
dec.free()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## API
|
|
33
|
+
|
|
34
|
+
### `decode(src): Promise<AudioData>`
|
|
35
|
+
|
|
36
|
+
Whole-file decode. Accepts `Uint8Array` or `ArrayBuffer`.
|
|
37
|
+
|
|
38
|
+
### `decoder(): Promise<WebmDecoder>`
|
|
39
|
+
|
|
40
|
+
Creates a decoder instance.
|
|
41
|
+
|
|
42
|
+
- **`dec.decode(data)`** — decode chunk, returns `Promise<AudioData>`
|
|
43
|
+
- **`dec.flush()`** — flush remaining data
|
|
44
|
+
- **`dec.free()`** — release resources
|
|
45
|
+
|
|
46
|
+
Note: `decode()` and `flush()` are async (unlike the sync PCM decoders) because Opus decoding happens in WASM.
|
|
47
|
+
|
|
48
|
+
## Codecs
|
|
49
|
+
|
|
50
|
+
- **Opus** — via [opus-decoder](https://github.com/eshaz/wasm-audio-decoders)
|
|
51
|
+
- **Vorbis** — via [@wasm-audio-decoders/ogg-vorbis](https://github.com/eshaz/wasm-audio-decoders) (raw frames wrapped in OGG pages)
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
|
56
|
+
|
|
57
|
+
<a href="https://github.com/krishnized/license/">ॐ</a>
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/webm-decode",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Decode WebM audio (Opus, Vorbis) to PCM samples",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "webm-decode.js",
|
|
7
|
+
"types": "webm-decode.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./webm-decode.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"webm-decode.js",
|
|
14
|
+
"webm-decode.d.ts",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@wasm-audio-decoders/ogg-vorbis": "^0.1.20",
|
|
19
|
+
"opus-decoder": "^0.7.6"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"webm",
|
|
23
|
+
"matroska",
|
|
24
|
+
"ebml",
|
|
25
|
+
"opus",
|
|
26
|
+
"vorbis",
|
|
27
|
+
"audio",
|
|
28
|
+
"decode",
|
|
29
|
+
"decoder",
|
|
30
|
+
"pcm"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": { "access": "public" },
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"author": "audiojs",
|
|
35
|
+
"homepage": "https://github.com/audiojs/webm-decode#readme",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/audiojs/webm-decode.git"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=16"
|
|
42
|
+
},
|
|
43
|
+
"bugs": "https://github.com/audiojs/webm-decode/issues",
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "node test.js"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/webm-decode.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface AudioData {
|
|
2
|
+
channelData: Float32Array[];
|
|
3
|
+
sampleRate: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface WebmDecoder {
|
|
7
|
+
decode(data: Uint8Array): Promise<AudioData>;
|
|
8
|
+
flush(): Promise<AudioData>;
|
|
9
|
+
free(): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Decode WebM audio buffer (Opus, Vorbis) to PCM samples */
|
|
13
|
+
export default function decode(src: ArrayBuffer | Uint8Array): Promise<AudioData>;
|
|
14
|
+
|
|
15
|
+
/** Create streaming decoder instance */
|
|
16
|
+
export function decoder(): Promise<WebmDecoder>;
|
package/webm-decode.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebM audio decoder — demuxes EBML, decodes Opus/Vorbis to PCM
|
|
3
|
+
*
|
|
4
|
+
* let { channelData, sampleRate } = await decode(webmbuf)
|
|
5
|
+
* let dec = await decoder(); let result = await dec.decode(chunk)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const EMPTY = Object.freeze({ channelData: [], sampleRate: 0 })
|
|
9
|
+
|
|
10
|
+
// EBML element IDs
|
|
11
|
+
const ID_EBML = 0x1A45DFA3
|
|
12
|
+
const ID_SEGMENT = 0x18538067
|
|
13
|
+
const ID_TRACKS = 0x1654AE6B
|
|
14
|
+
const ID_TRACK_ENTRY = 0xAE
|
|
15
|
+
const ID_TRACK_NUMBER = 0xD7
|
|
16
|
+
const ID_TRACK_TYPE = 0x83
|
|
17
|
+
const ID_CODEC_ID = 0x86
|
|
18
|
+
const ID_CODEC_PRIVATE = 0x63A2
|
|
19
|
+
const ID_AUDIO = 0xE1
|
|
20
|
+
const ID_SAMPLE_RATE = 0xB5
|
|
21
|
+
const ID_CHANNELS = 0x9F
|
|
22
|
+
const ID_CODEC_DELAY = 0x56AA
|
|
23
|
+
const ID_SEEK_PRE_ROLL = 0x56BB
|
|
24
|
+
const ID_CLUSTER = 0x1F43B675
|
|
25
|
+
const ID_SIMPLE_BLOCK = 0xA3
|
|
26
|
+
const ID_BLOCK_GROUP = 0xA0
|
|
27
|
+
const ID_BLOCK = 0xA1
|
|
28
|
+
|
|
29
|
+
// Master elements whose children we descend into
|
|
30
|
+
const MASTER = new Set([
|
|
31
|
+
ID_EBML, ID_SEGMENT, ID_TRACKS, ID_AUDIO,
|
|
32
|
+
ID_CLUSTER, ID_BLOCK_GROUP
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
// Unknown-size sentinel values per VINT length (all value bits = 1)
|
|
36
|
+
const UNKNOWN_SIZE = [0x7F, 0x3FFF, 0x1FFFFF, 0x0FFFFFFF, 0x07FFFFFFFF, 0x03FFFFFFFFFF, 0x01FFFFFFFFFFFF, 0x00FFFFFFFFFFFFFF]
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read EBML element ID (VINT with leading 1 retained)
|
|
40
|
+
*/
|
|
41
|
+
function readId(b, o) {
|
|
42
|
+
if (o >= b.length) return null
|
|
43
|
+
let first = b[o], len = 1, mask = 0x80
|
|
44
|
+
while (len <= 4 && !(first & mask)) { len++; mask >>= 1 }
|
|
45
|
+
if (len > 4) return null
|
|
46
|
+
let val = first
|
|
47
|
+
for (let i = 1; i < len; i++) {
|
|
48
|
+
if (o + i >= b.length) return null
|
|
49
|
+
val = val * 256 + b[o + i]
|
|
50
|
+
}
|
|
51
|
+
return { val, len }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read EBML VINT data size (leading 1 masked off)
|
|
56
|
+
* Returns -1 for unknown size
|
|
57
|
+
*/
|
|
58
|
+
function readSize(b, o) {
|
|
59
|
+
if (o >= b.length) return null
|
|
60
|
+
let first = b[o], len = 1, mask = 0x80
|
|
61
|
+
while (len <= 8 && !(first & mask)) { len++; mask >>= 1 }
|
|
62
|
+
if (len > 8) return null
|
|
63
|
+
let val = first & (mask - 1)
|
|
64
|
+
for (let i = 1; i < len; i++) {
|
|
65
|
+
if (o + i >= b.length) return null
|
|
66
|
+
val = val * 256 + b[o + i]
|
|
67
|
+
}
|
|
68
|
+
if (val === UNKNOWN_SIZE[len - 1]) return { val: -1, len }
|
|
69
|
+
return { val, len }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readUint(b, o, n) {
|
|
73
|
+
let v = 0
|
|
74
|
+
for (let i = 0; i < n; i++) v = v * 256 + b[o + i]
|
|
75
|
+
return v
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readFloat(b, o, n) {
|
|
79
|
+
let dv = new DataView(b.buffer, b.byteOffset, b.byteLength)
|
|
80
|
+
if (n === 4) return dv.getFloat32(o)
|
|
81
|
+
if (n === 8) return dv.getFloat64(o)
|
|
82
|
+
return 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readStr(b, o, n) {
|
|
86
|
+
let s = ''
|
|
87
|
+
for (let i = 0; i < n; i++) {
|
|
88
|
+
if (b[o + i] === 0) break
|
|
89
|
+
s += String.fromCharCode(b[o + i])
|
|
90
|
+
}
|
|
91
|
+
return s
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse Opus identification header (CodecPrivate in WebM)
|
|
96
|
+
* RFC 7845: "OpusHead" + version + channels + preSkip(LE16) + sampleRate(LE32) + outputGain(LE16) + mappingFamily...
|
|
97
|
+
*/
|
|
98
|
+
function parseOpusHead(d) {
|
|
99
|
+
if (!d || d.length < 19) return null
|
|
100
|
+
if (readStr(d, 0, 8) !== 'OpusHead') return null
|
|
101
|
+
if (d[8] > 15) return null // unknown major version
|
|
102
|
+
|
|
103
|
+
let channels = d[9]
|
|
104
|
+
let preSkip = d[10] | (d[11] << 8)
|
|
105
|
+
let sampleRate = d[12] | (d[13] << 8) | (d[14] << 16) | (d[15] << 24)
|
|
106
|
+
let mappingFamily = d[18]
|
|
107
|
+
let streamCount = 1, coupledStreamCount = channels > 1 ? 1 : 0
|
|
108
|
+
let channelMappingTable = channels === 1 ? [0] : [0, 1]
|
|
109
|
+
|
|
110
|
+
if (mappingFamily > 0 && d.length >= 21 + channels) {
|
|
111
|
+
streamCount = d[19]
|
|
112
|
+
coupledStreamCount = d[20]
|
|
113
|
+
channelMappingTable = Array.from(d.subarray(21, 21 + channels))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { channels, preSkip, sampleRate, mappingFamily, streamCount, coupledStreamCount, channelMappingTable }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse WebM EBML structure, extract first audio track info + raw codec frames
|
|
121
|
+
*/
|
|
122
|
+
function parseWebm(buf) {
|
|
123
|
+
let b = buf instanceof Uint8Array ? buf : new Uint8Array(buf)
|
|
124
|
+
if (b.length < 4) throw Error('Not a WebM file')
|
|
125
|
+
|
|
126
|
+
let id = readId(b, 0)
|
|
127
|
+
if (!id || id.val !== ID_EBML) throw Error('Not a WebM file')
|
|
128
|
+
|
|
129
|
+
// Collect track entries, then pick the first audio track
|
|
130
|
+
let entries = [] // each: { number, type, codec, sampleRate, channels, codecPrivate, codecDelay, seekPreRoll }
|
|
131
|
+
let curEntry = null // current TrackEntry being parsed
|
|
132
|
+
let audioTrack = null
|
|
133
|
+
let frames = []
|
|
134
|
+
|
|
135
|
+
function walk(start, end) {
|
|
136
|
+
let pos = start
|
|
137
|
+
while (pos < end) {
|
|
138
|
+
let eid = readId(b, pos)
|
|
139
|
+
if (!eid) break
|
|
140
|
+
let siz = readSize(b, pos + eid.len)
|
|
141
|
+
if (!siz) break
|
|
142
|
+
|
|
143
|
+
let dataOff = pos + eid.len + siz.len
|
|
144
|
+
let dataLen = siz.val
|
|
145
|
+
let elemEnd = dataLen < 0 ? end : dataOff + dataLen
|
|
146
|
+
if (elemEnd > end) elemEnd = end
|
|
147
|
+
if (dataOff > end) break
|
|
148
|
+
|
|
149
|
+
let elemId = eid.val
|
|
150
|
+
|
|
151
|
+
if (elemId === ID_TRACK_ENTRY) {
|
|
152
|
+
// Start a new track entry, then descend
|
|
153
|
+
curEntry = { number: 0, type: 0, codec: '', sampleRate: 48000, channels: 2, codecPrivate: null, codecDelay: 0, seekPreRoll: 0 }
|
|
154
|
+
walk(dataOff, elemEnd)
|
|
155
|
+
entries.push(curEntry)
|
|
156
|
+
// Pick first audio track
|
|
157
|
+
if (!audioTrack && curEntry.type === 2 && curEntry.codec) audioTrack = curEntry
|
|
158
|
+
curEntry = null
|
|
159
|
+
} else if (MASTER.has(elemId)) {
|
|
160
|
+
walk(dataOff, elemEnd)
|
|
161
|
+
} else if (curEntry) {
|
|
162
|
+
// Inside a TrackEntry
|
|
163
|
+
if (elemId === ID_TRACK_NUMBER) curEntry.number = readUint(b, dataOff, dataLen)
|
|
164
|
+
else if (elemId === ID_TRACK_TYPE) curEntry.type = readUint(b, dataOff, dataLen)
|
|
165
|
+
else if (elemId === ID_CODEC_ID) curEntry.codec = readStr(b, dataOff, dataLen)
|
|
166
|
+
else if (elemId === ID_CODEC_PRIVATE) curEntry.codecPrivate = b.slice(dataOff, dataOff + dataLen)
|
|
167
|
+
else if (elemId === ID_SAMPLE_RATE && dataLen > 0) curEntry.sampleRate = readFloat(b, dataOff, dataLen)
|
|
168
|
+
else if (elemId === ID_CHANNELS && dataLen > 0) curEntry.channels = readUint(b, dataOff, dataLen)
|
|
169
|
+
else if (elemId === ID_CODEC_DELAY && dataLen > 0) curEntry.codecDelay = readUint(b, dataOff, dataLen)
|
|
170
|
+
else if (elemId === ID_SEEK_PRE_ROLL && dataLen > 0) curEntry.seekPreRoll = readUint(b, dataOff, dataLen)
|
|
171
|
+
} else if ((elemId === ID_SIMPLE_BLOCK || elemId === ID_BLOCK) && audioTrack && dataLen > 0) {
|
|
172
|
+
let bp = dataOff
|
|
173
|
+
let tn = readSize(b, bp)
|
|
174
|
+
if (tn && tn.val === audioTrack.number) {
|
|
175
|
+
bp += tn.len + 3 // skip track VINT + 2 bytes timestamp + 1 byte flags
|
|
176
|
+
if (bp < dataOff + dataLen) {
|
|
177
|
+
frames.push(b.subarray(bp, dataOff + dataLen))
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
pos = elemEnd
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
walk(0, b.length)
|
|
187
|
+
|
|
188
|
+
if (!audioTrack) throw Error('No audio track found in WebM')
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
codec: audioTrack.codec,
|
|
192
|
+
sampleRate: audioTrack.sampleRate,
|
|
193
|
+
channels: audioTrack.channels,
|
|
194
|
+
codecPrivate: audioTrack.codecPrivate,
|
|
195
|
+
codecDelay: audioTrack.codecDelay,
|
|
196
|
+
seekPreRoll: audioTrack.seekPreRoll,
|
|
197
|
+
frames
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Decode raw Opus frames via opus-decoder
|
|
203
|
+
*/
|
|
204
|
+
async function decodeOpus(info) {
|
|
205
|
+
let { OpusDecoder } = await import('opus-decoder')
|
|
206
|
+
|
|
207
|
+
let head = info.codecPrivate ? parseOpusHead(info.codecPrivate) : null
|
|
208
|
+
let channels = head?.channels || info.channels || 2
|
|
209
|
+
let preSkip = head?.preSkip || 0
|
|
210
|
+
|
|
211
|
+
// CodecDelay in WebM is nanoseconds; convert to samples as fallback
|
|
212
|
+
if (!preSkip && info.codecDelay) preSkip = Math.round(info.codecDelay / 1e9 * 48000)
|
|
213
|
+
|
|
214
|
+
let opts = { channels, sampleRate: 48000, preSkip }
|
|
215
|
+
|
|
216
|
+
if (head && head.mappingFamily > 0) {
|
|
217
|
+
opts.streamCount = head.streamCount
|
|
218
|
+
opts.coupledStreamCount = head.coupledStreamCount
|
|
219
|
+
opts.channelMappingTable = head.channelMappingTable
|
|
220
|
+
} else if (channels === 1) {
|
|
221
|
+
opts.streamCount = 1
|
|
222
|
+
opts.coupledStreamCount = 0
|
|
223
|
+
opts.channelMappingTable = [0]
|
|
224
|
+
} else if (channels === 2) {
|
|
225
|
+
opts.streamCount = 1
|
|
226
|
+
opts.coupledStreamCount = 1
|
|
227
|
+
opts.channelMappingTable = [0, 1]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let dec = new OpusDecoder(opts)
|
|
231
|
+
await dec.ready
|
|
232
|
+
|
|
233
|
+
if (!info.frames.length) { dec.free(); return EMPTY }
|
|
234
|
+
|
|
235
|
+
let result = dec.decodeFrames(info.frames)
|
|
236
|
+
dec.free()
|
|
237
|
+
|
|
238
|
+
if (!result?.channelData?.length) return EMPTY
|
|
239
|
+
|
|
240
|
+
let { channelData, samplesDecoded, sampleRate } = result
|
|
241
|
+
if (samplesDecoded != null && samplesDecoded < channelData[0].length)
|
|
242
|
+
channelData = channelData.map(ch => ch.subarray(0, samplesDecoded))
|
|
243
|
+
|
|
244
|
+
return { channelData, sampleRate }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Parse Matroska Vorbis CodecPrivate into 3 header packets.
|
|
249
|
+
* Format: byte 0 = num_packets-1 (2), then Xiph lacing sizes, then concatenated packets.
|
|
250
|
+
*/
|
|
251
|
+
function parseVorbisPrivate(d) {
|
|
252
|
+
if (!d || d.length < 3 || d[0] !== 2) return null
|
|
253
|
+
let pos = 1, sizes = []
|
|
254
|
+
for (let i = 0; i < 2; i++) {
|
|
255
|
+
let sz = 0
|
|
256
|
+
while (pos < d.length && d[pos] === 255) { sz += 255; pos++ }
|
|
257
|
+
if (pos < d.length) { sz += d[pos]; pos++ }
|
|
258
|
+
sizes.push(sz)
|
|
259
|
+
}
|
|
260
|
+
let h1 = d.slice(pos, pos + sizes[0])
|
|
261
|
+
let h2 = d.slice(pos + sizes[0], pos + sizes[0] + sizes[1])
|
|
262
|
+
let h3 = d.slice(pos + sizes[0] + sizes[1])
|
|
263
|
+
if (h1[0] !== 1 || h2[0] !== 3 || h3[0] !== 5) return null
|
|
264
|
+
return [h1, h2, h3]
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// OGG CRC lookup table (polynomial 0x04C11DB7)
|
|
268
|
+
const OGG_CRC = new Uint32Array(256)
|
|
269
|
+
for (let i = 0; i < 256; i++) {
|
|
270
|
+
let r = i << 24
|
|
271
|
+
for (let j = 0; j < 8; j++) r = (r << 1) ^ ((r >>> 31) * 0x04C11DB7)
|
|
272
|
+
OGG_CRC[i] = r >>> 0
|
|
273
|
+
}
|
|
274
|
+
function oggCrc(buf) {
|
|
275
|
+
let c = 0
|
|
276
|
+
for (let i = 0; i < buf.length; i++) c = ((c << 8) ^ OGG_CRC[((c >>> 24) ^ buf[i]) & 0xFF]) >>> 0
|
|
277
|
+
return c
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Build a single OGG page from complete packets.
|
|
282
|
+
*/
|
|
283
|
+
function makeOggPage(packets, granule, serial, seq, flags) {
|
|
284
|
+
// Build segment table: each packet uses ceil(len/255) segments for 255-byte chunks + 1 terminating segment
|
|
285
|
+
let segs = []
|
|
286
|
+
for (let p of packets) {
|
|
287
|
+
let len = p.length
|
|
288
|
+
while (len >= 255) { segs.push(255); len -= 255 }
|
|
289
|
+
segs.push(len) // terminating segment (0 if packet is exact multiple of 255)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let bodyLen = 0
|
|
293
|
+
for (let p of packets) bodyLen += p.length
|
|
294
|
+
let headerLen = 27 + segs.length
|
|
295
|
+
let page = new Uint8Array(headerLen + bodyLen)
|
|
296
|
+
let dv = new DataView(page.buffer)
|
|
297
|
+
|
|
298
|
+
page[0] = 0x4F; page[1] = 0x67; page[2] = 0x67; page[3] = 0x53 // "OggS"
|
|
299
|
+
page[4] = 0 // version
|
|
300
|
+
page[5] = flags
|
|
301
|
+
// Granule position (64-bit LE); -1 = not set (0xFFFFFFFFFFFFFFFF)
|
|
302
|
+
if (granule < 0) { dv.setUint32(6, 0xFFFFFFFF, true); dv.setUint32(10, 0xFFFFFFFF, true) }
|
|
303
|
+
else { dv.setUint32(6, granule >>> 0, true); dv.setUint32(10, (granule / 0x100000000) >>> 0, true) }
|
|
304
|
+
dv.setUint32(14, serial, true)
|
|
305
|
+
dv.setUint32(18, seq, true)
|
|
306
|
+
dv.setUint32(22, 0, true) // CRC placeholder
|
|
307
|
+
page[26] = segs.length
|
|
308
|
+
for (let i = 0; i < segs.length; i++) page[27 + i] = segs[i]
|
|
309
|
+
let off = headerLen
|
|
310
|
+
for (let p of packets) { page.set(p, off); off += p.length }
|
|
311
|
+
dv.setUint32(22, oggCrc(page), true)
|
|
312
|
+
|
|
313
|
+
return page
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Wrap raw Vorbis header packets and audio frames into an OGG bitstream.
|
|
318
|
+
* Max 255 segments per OGG page. Granule on EOS page set high to avoid truncation
|
|
319
|
+
* (exact sample count is unknown without deep Vorbis mode parsing).
|
|
320
|
+
*/
|
|
321
|
+
function vorbisToOgg(headers, frames) {
|
|
322
|
+
let serial = 0x564F5242, pages = [], seq = 0 // "VORB"
|
|
323
|
+
|
|
324
|
+
// Page 0: BOS — identification header only, granule 0
|
|
325
|
+
pages.push(makeOggPage([headers[0]], 0, serial, seq++, 0x02))
|
|
326
|
+
// Page 1: comment + setup headers, granule 0
|
|
327
|
+
pages.push(makeOggPage([headers[1], headers[2]], 0, serial, seq++, 0))
|
|
328
|
+
|
|
329
|
+
// Audio pages — pack frames respecting 255-segment limit
|
|
330
|
+
let i = 0
|
|
331
|
+
while (i < frames.length) {
|
|
332
|
+
let pkt = [], segCount = 0
|
|
333
|
+
while (i < frames.length) {
|
|
334
|
+
let needed = Math.floor(frames[i].length / 255) + 1
|
|
335
|
+
if (segCount + needed > 255) break
|
|
336
|
+
pkt.push(frames[i])
|
|
337
|
+
segCount += needed
|
|
338
|
+
i++
|
|
339
|
+
}
|
|
340
|
+
let isLast = i >= frames.length
|
|
341
|
+
// Granule: -1 (not set) on intermediate pages; max safe int on EOS to avoid truncation
|
|
342
|
+
pages.push(makeOggPage(pkt, isLast ? 0x1FFFFFFFFFFFFF : -1, serial, seq++, isLast ? 0x04 : 0))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let totalLen = 0
|
|
346
|
+
for (let p of pages) totalLen += p.length
|
|
347
|
+
let ogg = new Uint8Array(totalLen)
|
|
348
|
+
let off = 0
|
|
349
|
+
for (let p of pages) { ogg.set(p, off); off += p.length }
|
|
350
|
+
return ogg
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Decode raw Vorbis frames via ogg-vorbis decoder
|
|
355
|
+
*/
|
|
356
|
+
async function decodeVorbis(info) {
|
|
357
|
+
let { OggVorbisDecoder } = await import('@wasm-audio-decoders/ogg-vorbis')
|
|
358
|
+
|
|
359
|
+
let headers = parseVorbisPrivate(info.codecPrivate)
|
|
360
|
+
if (!headers) throw Error('Invalid Vorbis CodecPrivate')
|
|
361
|
+
|
|
362
|
+
if (!info.frames.length) return EMPTY
|
|
363
|
+
|
|
364
|
+
let ogg = vorbisToOgg(headers, info.frames)
|
|
365
|
+
let dec = new OggVorbisDecoder()
|
|
366
|
+
await dec.ready
|
|
367
|
+
|
|
368
|
+
let result = await dec.decodeFile(ogg)
|
|
369
|
+
dec.free()
|
|
370
|
+
|
|
371
|
+
if (!result?.channelData?.length) return EMPTY
|
|
372
|
+
|
|
373
|
+
let { channelData, samplesDecoded, sampleRate } = result
|
|
374
|
+
if (samplesDecoded != null && samplesDecoded < channelData[0].length)
|
|
375
|
+
channelData = channelData.map(ch => ch.subarray(0, samplesDecoded))
|
|
376
|
+
|
|
377
|
+
return { channelData, sampleRate }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Whole-file decode
|
|
382
|
+
* @param {Uint8Array|ArrayBuffer} src
|
|
383
|
+
* @returns {Promise<{channelData: Float32Array[], sampleRate: number}>}
|
|
384
|
+
*/
|
|
385
|
+
export default async function decode(src) {
|
|
386
|
+
if (!src || typeof src === 'string' || !(src.buffer || src.byteLength != null || src.length))
|
|
387
|
+
throw TypeError('Expected ArrayBuffer or Uint8Array')
|
|
388
|
+
let buf = src instanceof Uint8Array ? src : new Uint8Array(src.buffer || src)
|
|
389
|
+
if (!buf.length) throw Error('Not a WebM file')
|
|
390
|
+
let dec = await decoder()
|
|
391
|
+
try {
|
|
392
|
+
let result = await dec.decode(buf)
|
|
393
|
+
let flushed = await dec.flush()
|
|
394
|
+
return merge(result, flushed)
|
|
395
|
+
} finally {
|
|
396
|
+
dec.free()
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Create streaming decoder instance
|
|
402
|
+
* @returns {Promise<{decode(chunk: Uint8Array): Promise<AudioData>, flush(): Promise<AudioData>, free(): void}>}
|
|
403
|
+
*/
|
|
404
|
+
export async function decoder() {
|
|
405
|
+
let freed = false, chunks = [], totalLen = 0
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
async decode(data) {
|
|
409
|
+
if (freed) throw Error('Decoder already freed')
|
|
410
|
+
if (!data?.length) return EMPTY
|
|
411
|
+
chunks.push(data instanceof Uint8Array ? data : new Uint8Array(data))
|
|
412
|
+
totalLen += data.length
|
|
413
|
+
|
|
414
|
+
let buf = chunks.length === 1 ? chunks[0] : concat(chunks, totalLen)
|
|
415
|
+
let info = parseWebm(buf)
|
|
416
|
+
if (!info.frames.length) return EMPTY
|
|
417
|
+
|
|
418
|
+
if (info.codec === 'A_OPUS') return decodeOpus(info)
|
|
419
|
+
if (info.codec === 'A_VORBIS') return decodeVorbis(info)
|
|
420
|
+
throw Error('Unsupported WebM codec: ' + info.codec)
|
|
421
|
+
},
|
|
422
|
+
async flush() {
|
|
423
|
+
if (freed) return EMPTY
|
|
424
|
+
freed = true
|
|
425
|
+
chunks = []; totalLen = 0
|
|
426
|
+
return EMPTY
|
|
427
|
+
},
|
|
428
|
+
free() {
|
|
429
|
+
freed = true
|
|
430
|
+
chunks = []; totalLen = 0
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function concat(parts, totalLen) {
|
|
436
|
+
let buf = new Uint8Array(totalLen), off = 0
|
|
437
|
+
for (let c of parts) { buf.set(c, off); off += c.length }
|
|
438
|
+
return buf
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function merge(a, b) {
|
|
442
|
+
if (!b?.channelData?.length) return a
|
|
443
|
+
if (!a?.channelData?.length) return b
|
|
444
|
+
return {
|
|
445
|
+
channelData: a.channelData.map((ch, i) => {
|
|
446
|
+
let m = new Float32Array(ch.length + b.channelData[i].length)
|
|
447
|
+
m.set(ch); m.set(b.channelData[i], ch.length)
|
|
448
|
+
return m
|
|
449
|
+
}),
|
|
450
|
+
sampleRate: a.sampleRate
|
|
451
|
+
}
|
|
452
|
+
}
|