audio 2.0.0-1 → 2.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/cache.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Page cache — LRU eviction to OPFS and lazy restore.
3
+ * Self-registers on import — exposes opfsCache/evict/ensurePages on audio.
4
+ */
5
+
6
+ import audio from './core.js'
7
+
8
+ const DEFAULT_BUDGET = 500 * 1024 * 1024 // 500MB
9
+
10
+ /** Evict pages to cache until resident bytes fit within budget. True LRU. */
11
+ async function evict(a) {
12
+ if (!a.cache || a.budget === Infinity) return
13
+ let bytes = p => p ? p.reduce((s, ch) => s + ch.byteLength, 0) : 0
14
+ let current = a.pages.reduce((sum, p) => sum + bytes(p), 0)
15
+ if (current <= a.budget) return
16
+ // Build eviction order: LRU (oldest first) if tracked, else FIFO fallback
17
+ let order = a._.lru && a._.lru.size
18
+ ? [...a._.lru]
19
+ : a.pages.map((_, i) => i)
20
+ for (let i of order) {
21
+ if (current <= a.budget) break
22
+ if (!a.pages[i]) continue
23
+ await a.cache.write(i, a.pages[i])
24
+ current -= bytes(a.pages[i])
25
+ a.pages[i] = null
26
+ if (a._.lru) a._.lru.delete(i)
27
+ }
28
+ }
29
+
30
+ /** Restore evicted pages covering a sample range from cache. */
31
+ async function ensurePages(a, offset, duration) {
32
+ if (!a.cache) return
33
+ let PS = audio.PAGE_SIZE, sr = a.sampleRate
34
+ let s = offset != null ? Math.max(0, Math.round(offset * sr)) : 0
35
+ let len = duration != null ? Math.round(duration * sr) : a._.len - s
36
+ let p0 = Math.floor(s / PS), pEnd = Math.min(Math.ceil((s + len) / PS), a.pages.length)
37
+ for (let i = p0; i < pEnd; i++)
38
+ if (a.pages[i] === null && await a.cache.has(i)) a.pages[i] = await a.cache.read(i)
39
+ }
40
+
41
+ /** Create an OPFS-backed cache backend. Browser only. */
42
+ async function opfsCache(dirName = 'audio-cache') {
43
+ if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory)
44
+ throw new Error('OPFS not available in this environment')
45
+ let root = await navigator.storage.getDirectory()
46
+ let dir = await root.getDirectoryHandle(dirName, { create: true })
47
+
48
+ return {
49
+ async read(i) {
50
+ let handle = await dir.getFileHandle(`p${i}`)
51
+ let file = await handle.getFile()
52
+ let buf = await file.arrayBuffer()
53
+ let view = new Float32Array(buf)
54
+ let ch = view[0] | 0, samplesPerCh = ((view.length - 1) / ch) | 0
55
+ let data = []
56
+ for (let c = 0; c < ch; c++) data.push(view.slice(1 + c * samplesPerCh, 1 + (c + 1) * samplesPerCh))
57
+ return data
58
+ },
59
+ async write(i, data) {
60
+ let handle = await dir.getFileHandle(`p${i}`, { create: true })
61
+ let writable = await handle.createWritable()
62
+ let total = 1 + data.reduce((s, ch) => s + ch.length, 0)
63
+ let packed = new Float32Array(total)
64
+ packed[0] = data.length
65
+ let off = 1
66
+ for (let ch of data) { packed.set(ch, off); off += ch.length }
67
+ await writable.write(packed.buffer)
68
+ await writable.close()
69
+ },
70
+ has(i) {
71
+ return dir.getFileHandle(`p${i}`).then(() => true, () => false)
72
+ },
73
+ async evict(i) {
74
+ try { await dir.removeEntry(`p${i}`) } catch {}
75
+ },
76
+ async clear() {
77
+ for await (let [name] of dir) await dir.removeEntry(name)
78
+ }
79
+ }
80
+ }
81
+
82
+
83
+ // ── Self-register ────────────────────────────────────────────────
84
+
85
+
86
+ audio.opfsCache = opfsCache
87
+ audio.evict = evict
88
+ audio.ensurePages = ensurePages
89
+ audio.DEFAULT_BUDGET = DEFAULT_BUDGET
package/core.js ADDED
@@ -0,0 +1,602 @@
1
+ /**
2
+ * audio core — paged audio container with plugin architecture.
3
+ *
4
+ * audio.fn — instance prototype (like $.fn)
5
+ * audio.stat — stat descriptor registration/query (block, reduce, query)
6
+ * audio.use — plugin registration
7
+ */
8
+
9
+ import decode from 'audio-decode'
10
+ import getType from 'audio-type'
11
+ import encode from 'encode-audio'
12
+ import convert, { parse as parseFmt } from 'pcm-convert'
13
+ import parseDuration from 'parse-duration'
14
+
15
+ audio.version = '2.0.0'
16
+
17
+ /** Parse time value: number passthrough, string via parse-duration or timecode. */
18
+ export function parseTime(v) {
19
+ if (v == null) return v
20
+ if (typeof v === 'number') { if (!Number.isFinite(v)) throw new Error(`Invalid time: ${v}`); return v }
21
+ // Timecode: HH:MM:SS.mmm, MM:SS.mmm, or MM:SS
22
+ let tc = v.match(/^(\d+):(\d{1,2})(?::(\d{1,2}))?(?:\.(\d+))?$/)
23
+ if (tc) {
24
+ let [, a, b, c, frac] = tc
25
+ let s = c != null ? +a * 3600 + +b * 60 + +c : +a * 60 + +b
26
+ if (frac) s += +('0.' + frac)
27
+ return s
28
+ }
29
+ let s = parseDuration(v, 's')
30
+ if (s != null && isFinite(s)) return s
31
+ throw new Error(`Invalid time: ${v}`)
32
+ }
33
+
34
+
35
+ // ── Entry Points ─────────────────────────────────────────────────────────
36
+
37
+ /** Create audio from any source. Sync — returns instance immediately.
38
+ * Thenable: `await audio('file.mp3')` waits for full decode.
39
+ * Edits can be chained before decode completes. */
40
+ export default function audio(source, opts = {}) {
41
+ // No source → pushable instance (tape recorder — push, record, stop)
42
+ if (source == null) {
43
+ let sr = opts.sampleRate || 44100, ch = opts.channels || 1
44
+ let waiters = []
45
+ let notify = () => { for (let w of waiters.splice(0)) w() }
46
+ let a = create([], sr, ch, 0, opts, null)
47
+ a.decoded = false
48
+ a.recording = false
49
+ a._.acc = pageAccumulator({ pages: a.pages, notify, ondata: (...args) => emit(a, 'data', ...args) })
50
+ a._.waiters = waiters
51
+ return a
52
+ }
53
+ // Restore from serialized document
54
+ if (source && typeof source === 'object' && !Array.isArray(source) && source.edits) {
55
+ if (!source.source) throw new TypeError('audio: cannot restore document without source reference')
56
+ let a = audio(source.source, opts)
57
+ if (a.run) for (let e of source.edits) a.run(e)
58
+ return a
59
+ }
60
+ // Concat from array of sources
61
+ if (Array.isArray(source) && source.length && !(source[0] instanceof Float32Array)) {
62
+ let instances = source.map(s => s?.pages ? s : audio(s, opts))
63
+ let first = instances[0].clip ? instances[0].clip() : audio.from(instances[0])
64
+ if (!first.insert) throw new Error('audio([...]): concat requires insert plugin — import "audio" instead of "audio/core.js"')
65
+ for (let i = 1; i < instances.length; i++) first.insert(instances[i])
66
+ let loading = instances.filter(s => !s.decoded)
67
+ if (loading.length) {
68
+ first.ready = Promise.all(loading.map(s => s.ready)).then(() => { delete first.then; delete first.catch; return true })
69
+ first.ready.catch(() => {})
70
+ makeThenable(first)
71
+ }
72
+ return first
73
+ }
74
+ // From AudioBuffer
75
+ if (source?.getChannelData && source?.numberOfChannels) return audio.from(source, opts)
76
+ // From PCM arrays or silence duration
77
+ if (Array.isArray(source) && source[0] instanceof Float32Array || typeof source === 'number') {
78
+ let a = audio.from(source, opts)
79
+ if (audio.evict && a.cache && a.budget !== Infinity) {
80
+ a.ready = audio.evict(a).then(() => { delete a.then; delete a.catch; return true })
81
+ a.ready.catch(() => {})
82
+ makeThenable(a)
83
+ }
84
+ return a
85
+ }
86
+ // From encoded source (file, URL, buffer)
87
+ let ref = typeof source === 'string' ? source : source instanceof URL ? source.href : null
88
+ let pages = [], waiters = []
89
+ let notify = () => { for (let w of waiters.splice(0)) w() }
90
+ let a = create(pages, 0, 0, 0, { ...opts, source: ref }, null)
91
+ a._.waiters = waiters
92
+ a.decoded = false
93
+
94
+ let readyResolve, readyReject
95
+ a._.ready = new Promise((r, j) => { readyResolve = r; readyReject = j })
96
+ a._.ready.catch(() => {}) // suppress unhandled rejection
97
+
98
+ a.ready = (async () => {
99
+ try {
100
+ if (opts.storage === 'persistent') {
101
+ if (!audio.opfsCache) throw new Error('Persistent storage requires cache module (import "./cache.js")')
102
+ try { opts = { ...opts, cache: await audio.opfsCache(), budget: opts.budget ?? audio.DEFAULT_BUDGET ?? Infinity } }
103
+ catch { throw new Error('OPFS not available (required by storage: "persistent")') }
104
+ a.cache = opts.cache
105
+ a.budget = opts.budget
106
+ }
107
+ let result = await decodeSource(source, { pages, notify, ondata: (...args) => emit(a, 'data', ...args) })
108
+ a.sampleRate = result.sampleRate
109
+ a._.ch = result.channels
110
+ a._.chV = -1 // invalidate cached channels
111
+ if (result.acc) a._.acc = result.acc
112
+ emit(a, 'metadata', { sampleRate: result.sampleRate, channels: result.channels })
113
+ readyResolve()
114
+
115
+ let final = await result.decoding
116
+ a._.len = final.length
117
+ a._.lenV = -1
118
+ a.stats = final.stats
119
+ a.decoded = true
120
+ notify()
121
+ audio.evict?.(a)
122
+ delete a.then; delete a.catch // clear thenable before resolve to prevent unwrap loop
123
+ return true
124
+ } catch (e) {
125
+ readyReject(e)
126
+ throw e
127
+ }
128
+ })()
129
+ a.ready.catch(() => {}) // suppress unhandled rejection; errors surface through LOAD or await
130
+ makeThenable(a)
131
+
132
+ return a
133
+ }
134
+
135
+ /** Make instance thenable — await resolves after full decode. Self-removing to prevent infinite unwrap. */
136
+ function makeThenable(a) {
137
+ a.then = function(resolve, reject) {
138
+ return a.ready.then(() => { delete a.then; delete a.catch; return a }).then(resolve, reject)
139
+ }
140
+ a.catch = function(reject) { return a.then(null, reject) }
141
+ }
142
+
143
+ /** Sync creation from PCM data, AudioBuffer, audio instance, function, or seconds of silence. */
144
+ audio.from = function(source, opts = {}) {
145
+ if (Array.isArray(source) && source[0] instanceof Float32Array) return fromChannels(source, opts)
146
+ if (typeof source === 'number') return fromSilence(source, opts)
147
+ if (typeof source === 'function') return fromFunction(source, opts)
148
+ if (source?.pages) {
149
+ return create(source.pages, opts.sampleRate ?? source.sampleRate,
150
+ opts.channels ?? source._.ch, source._.len,
151
+ { source: source.source, storage: source.storage, cache: source.cache, budget: opts.budget ?? source.budget }, source.stats)
152
+ }
153
+ if (source?.getChannelData) {
154
+ let chs = Array.from({ length: source.numberOfChannels }, (_, i) => new Float32Array(source.getChannelData(i)))
155
+ return fromChannels(chs, { sampleRate: source.sampleRate, ...opts })
156
+ }
157
+ // Typed array with format conversion
158
+ if (ArrayBuffer.isView(source) && opts.format) {
159
+ let fmt = parseFmt(opts.format)
160
+ let ch = fmt.channels || opts.channels || 1
161
+ let sr = fmt.sampleRate || opts.sampleRate || 44100
162
+ let src = { ...fmt, channels: ch }
163
+ if (ch > 1 && src.interleaved == null) src.interleaved = true
164
+ let pcm = convert(source, src, { dtype: 'float32', interleaved: false, channels: ch })
165
+ let perCh = pcm.length / ch
166
+ let chs = Array.from({ length: ch }, (_, c) => pcm.subarray(c * perCh, (c + 1) * perCh))
167
+ return fromChannels(chs, { sampleRate: sr })
168
+ }
169
+ throw new TypeError('audio.from: expected Float32Array[], AudioBuffer, audio instance, function, or number')
170
+ }
171
+
172
+
173
+
174
+ // ── Plugin Architecture ─────────────────────────────────────────────────
175
+
176
+ const fn = {}
177
+
178
+ audio.fn = fn // instance prototype (like $.fn)
179
+
180
+ audio.BLOCK_SIZE = 1024
181
+ audio.PAGE_SIZE = 1024 * audio.BLOCK_SIZE
182
+
183
+ /** Internal protocol symbols for plugin overrides. */
184
+ export const LOAD = Symbol('load')
185
+ export const READ = Symbol('read')
186
+
187
+ /** Emit event on instance. */
188
+ export function emit(a, event, ...args) {
189
+ let arr = a._.ev[event]
190
+ if (arr) for (let cb of arr) cb(...args)
191
+ }
192
+ fn.on = function(event, cb) { (this._.ev[event] ??= []).push(cb); return this }
193
+ fn.off = function(event, cb) {
194
+ if (!event) { this._.ev = {}; return this }
195
+ if (!cb) { delete this._.ev[event]; return this }
196
+ let arr = this._.ev[event]
197
+ if (arr) { let i = arr.indexOf(cb); if (i >= 0) arr.splice(i, 1) }
198
+ return this
199
+ }
200
+ fn.dispose = function() {
201
+ this.stop()
202
+ this._.ev = {}
203
+ this._.pcm = null
204
+ this._.plan = null
205
+ this.pages.length = 0
206
+ this.stats = null
207
+ this._.waiters = null
208
+ this._.acc = null
209
+ }
210
+ if (Symbol.dispose) fn[Symbol.dispose] = fn.dispose
211
+
212
+ /** Register plugins. Each receives audio. */
213
+ audio.use = function(...plugins) {
214
+ for (let p of plugins) p(audio)
215
+ }
216
+
217
+
218
+ // ── Instance ─────────────────────────────────────────────────────────────
219
+
220
+ function create(pages, sampleRate, ch, length, opts = {}, stats) {
221
+ let a = Object.create(fn)
222
+ a.pages = pages
223
+ a.sampleRate = sampleRate
224
+ a.source = opts.source ?? null
225
+ a.storage = opts.storage || 'memory'
226
+ a.cache = opts.cache || null
227
+ a.budget = opts.budget ?? Infinity
228
+ a.stats = stats
229
+ a.decoded = true
230
+ a.ready = Promise.resolve(true)
231
+
232
+ a._ = {
233
+ ch, // source channel count
234
+ len: length, // source sample length
235
+ waiters: null, // decode notify queue (null when not streaming)
236
+ ev: {}, // instance event listeners
237
+ }
238
+ a.currentTime = 0
239
+
240
+ // History (edit pipeline)
241
+ a.edits = []
242
+ a.version = 0
243
+ a._.pcm = null; a._.pcmV = -1
244
+ a._.plan = null; a._.planV = -1
245
+ a._.statsV = -1
246
+ a._.lenC = a._.len; a._.lenV = 0
247
+ a._.chC = a._.ch; a._.chV = 0
248
+
249
+ // Playback
250
+ a.playing = false; a.paused = false
251
+ a.volume = 0; a.loop = false; a.block = null
252
+
253
+ // Cache
254
+ a._.lru = new Set()
255
+
256
+ return a
257
+ }
258
+
259
+ function fromChannels(channelData, opts = {}) {
260
+ let sr = opts.sampleRate || 44100
261
+ return create(paginate(channelData), sr, channelData.length, channelData[0].length, opts, audio.statSession?.(sr).page(channelData).done())
262
+ }
263
+
264
+ function fromSilence(seconds, opts = {}) {
265
+ let sr = opts.sampleRate || 44100, ch = opts.channels || 1
266
+ return fromChannels(Array.from({ length: ch }, () => new Float32Array(Math.round(seconds * sr))), { ...opts, sampleRate: sr })
267
+ }
268
+
269
+ function fromFunction(fn, opts = {}) {
270
+ let sr = opts.sampleRate || 44100, ch = opts.channels || 1
271
+ let dur = opts.duration
272
+ if (dur == null) throw new TypeError('audio.from(fn): duration required')
273
+ let len = Math.round(dur * sr)
274
+ let chs = Array.from({ length: ch }, () => new Float32Array(len))
275
+ for (let i = 0; i < len; i++) {
276
+ let v = fn(i / sr, i)
277
+ if (typeof v === 'number') for (let c = 0; c < ch; c++) chs[c][i] = v
278
+ else for (let c = 0; c < ch; c++) chs[c][i] = v[c] ?? 0
279
+ }
280
+ return fromChannels(chs, { sampleRate: sr })
281
+ }
282
+
283
+ Object.defineProperties(fn, {
284
+ length: { get() { return this._.len }, configurable: true },
285
+ duration: { get() { return this.length / this.sampleRate }, configurable: true },
286
+ channels: { get() { return this._.ch }, configurable: true },
287
+
288
+ })
289
+
290
+ fn[LOAD] = async function() {
291
+ if (this._.ready) await this._.ready; this._.acc?.drain()
292
+ }
293
+ fn[READ] = function(offset, duration) { return readPages(this, offset, duration) }
294
+
295
+ /** Push PCM data into a pushable instance. Accepts Float32Array[], Float32Array, or typed array with format. */
296
+ fn.push = function(data, fmt) {
297
+ let acc = this._.acc
298
+ if (!acc) throw new Error('push: instance is not pushable — create with audio()')
299
+ let ch = this._.ch, sr = this.sampleRate
300
+ let chData
301
+ if (Array.isArray(data) && data[0] instanceof Float32Array) chData = data
302
+ else if (data instanceof Float32Array) chData = [data]
303
+ else if (ArrayBuffer.isView(data)) {
304
+ let f = fmt || {}
305
+ let srcFmt = typeof f === 'string' ? f : f.format || 'int16'
306
+ let nch = f.channels || ch
307
+ let src = { dtype: srcFmt, channels: nch }
308
+ if (nch > 1) src.interleaved = true
309
+ let pcm = convert(data, src, { dtype: 'float32', interleaved: false, channels: nch })
310
+ let perCh = pcm.length / nch
311
+ chData = Array.from({ length: nch }, (_, c) => pcm.subarray(c * perCh, (c + 1) * perCh))
312
+ }
313
+ else throw new TypeError('push: expected Float32Array[], Float32Array, or typed array')
314
+ // Sync channel count on first push, validate on subsequent
315
+ if (!this._.ch) { this._.ch = chData.length; this._.chV = -1 }
316
+ else if (chData.length !== this._.ch) throw new TypeError(`push: expected ${this._.ch} channels, got ${chData.length}`)
317
+ acc.push(chData, (fmt && fmt.sampleRate) || sr)
318
+ this._.len = acc.length
319
+ this._.lenV = -1
320
+ return this
321
+ }
322
+
323
+ /** Stop recording and/or finalize pushable stream. Drain partial page, signal EOF to waiting streams. No-op on non-pushable. */
324
+ fn.stop = function() {
325
+ this.playing = false; this.paused = false
326
+ if (this._._wake) this._._wake()
327
+ if (this.recording) {
328
+ this.recording = false
329
+ if (this._._mic) { this._._mic(null); this._._mic = null }
330
+ }
331
+ if (this._.acc && !this.decoded) {
332
+ this._.acc.drain()
333
+ this.decoded = true
334
+ if (this._.waiters) for (let w of this._.waiters.splice(0)) w()
335
+ }
336
+ return this
337
+ }
338
+
339
+ /** Start recording from mic. Pushes PCM chunks until .stop(). Requires audio-mic (npm i audio-mic). */
340
+ fn.record = function(opts = {}) {
341
+ if (!this._.acc) throw new Error('record: instance is not pushable — create with audio()')
342
+ if (this.recording) return this
343
+ this.recording = true
344
+ this.decoded = false
345
+ let self = this, sr = this.sampleRate, ch = this._.ch
346
+ let _rec = (async () => {
347
+ try {
348
+ let { default: mic } = await import('audio-mic')
349
+ let read = mic({ sampleRate: sr, channels: ch, bitDepth: 16, ...opts })
350
+ self._._mic = read
351
+ read((err, buf) => {
352
+ if (!self.recording) return
353
+ if (err || !buf) return
354
+ self.push(new Int16Array(buf.buffer, buf.byteOffset, buf.byteLength / 2), 'int16')
355
+ })
356
+ } catch (e) {
357
+ self.recording = false
358
+ self.decoded = true
359
+ if (self._.waiters) for (let w of self._.waiters.splice(0)) w()
360
+ throw e.code === 'ERR_MODULE_NOT_FOUND' ? new Error('record: audio-mic not installed — npm i audio-mic') : e
361
+ }
362
+ })()
363
+ _rec.catch(() => {}) // suppress unhandled rejection; surfaces through .ready/.stop
364
+ return this
365
+ }
366
+
367
+ fn.seek = function(t) {
368
+ t = Math.max(0, t)
369
+ this.currentTime = t
370
+ if (this.cache) {
371
+ let page = Math.floor(t * this.sampleRate / audio.PAGE_SIZE)
372
+ ;(async () => {
373
+ for (let i = Math.max(0, page - 1); i <= Math.min(page + 2, this.pages.length - 1); i++)
374
+ if (this.pages[i] === null && await this.cache.has(i)) this.pages[i] = await this.cache.read(i)
375
+ })()
376
+ }
377
+ if (this.playing) { this._._seekTo = t; if (this._._wake) this._._wake() }
378
+ return this
379
+ }
380
+
381
+ fn.read = async function(opts) {
382
+ if (typeof opts !== 'object' || opts === null) opts = {}
383
+ let { at, duration, format, channel, meta } = opts
384
+ at = parseTime(at); duration = parseTime(duration)
385
+ await this[LOAD]()
386
+ let pcm = await this[READ](at, duration)
387
+ if (channel != null) pcm = [pcm[channel]]
388
+ if (!format) return channel != null ? pcm[0] : pcm
389
+ let converted = encode[format] ? await encode[format](pcm, { sampleRate: this.sampleRate, ...meta }) : pcm.map(ch => convert(ch, 'float32', format))
390
+ return channel != null ? (Array.isArray(converted) ? converted[0] : converted) : converted
391
+ }
392
+
393
+
394
+ // ── Pages ────────────────────────────────────────────────────────────────
395
+
396
+ /** Split channels into pages of PAGE_SIZE samples. */
397
+ function paginate(channelData) {
398
+ let len = channelData[0].length, pages = []
399
+ for (let off = 0; off < len; off += audio.PAGE_SIZE)
400
+ pages.push(channelData.map(ch => ch.subarray(off, Math.min(off + audio.PAGE_SIZE, len))))
401
+ return pages
402
+ }
403
+
404
+ /** Walk pages of instance a, calling visitor(page, channel, start, end) for each overlapping page. */
405
+ export function walkPages(a, c, srcOff, len, visitor) {
406
+ let p0 = Math.floor(srcOff / audio.PAGE_SIZE), pos = p0 * audio.PAGE_SIZE
407
+ for (let p = p0; p < a.pages.length && pos < srcOff + len; p++) {
408
+ let pg = a.pages[p], pLen = pg ? pg[0].length : audio.PAGE_SIZE
409
+ if (pos + pLen > srcOff && pg) {
410
+ let s = Math.max(srcOff - pos, 0), e = Math.min(srcOff + len - pos, pLen)
411
+ if (a._.lru) { a._.lru.delete(p); a._.lru.add(p) }
412
+ visitor(pg, c, s, e, Math.max(pos - srcOff, 0))
413
+ }
414
+ pos += pLen
415
+ }
416
+ }
417
+
418
+ /** Copy channel c from a's pages into target buffer. */
419
+ export function copyPages(a, c, srcOff, len, target, tOff) {
420
+ walkPages(a, c, srcOff, len, (pg, ch, s, e, off) => target.set(pg[ch].subarray(s, e), tOff + off))
421
+ }
422
+
423
+ /** Read range from source pages (no edits). */
424
+ export function readPages(a, offset, duration) {
425
+ let sr = a.sampleRate, ch = a._.ch
426
+ let s = offset != null ? Math.min(Math.max(Math.round(offset * sr), 0), a._.len) : 0
427
+ let len = duration != null ? Math.round(duration * sr) : a._.len - s
428
+ len = Math.min(Math.max(len, 0), a._.len - s)
429
+ let out = Array.from({ length: ch }, () => new Float32Array(len))
430
+ for (let c = 0; c < ch; c++) copyPages(a, c, s, len, out[c], 0)
431
+ return out
432
+ }
433
+
434
+
435
+ // ── Decode ───────────────────────────────────────────────────────────────
436
+
437
+ /** Resolve source to ArrayBuffer. */
438
+ async function resolveSource(source) {
439
+ if (source instanceof ArrayBuffer) return source
440
+ if (source instanceof Uint8Array) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength)
441
+ if (source instanceof URL) return resolveSource(source.href)
442
+ if (typeof source === 'string') {
443
+ if (/^(https?|data|blob):/.test(source) || typeof window !== 'undefined')
444
+ return (await fetch(source)).arrayBuffer()
445
+ if (source.startsWith('file:')) {
446
+ let { fileURLToPath } = await import('url')
447
+ source = fileURLToPath(source)
448
+ }
449
+ let { readFile } = await import('fs/promises')
450
+ let buf = await readFile(source)
451
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
452
+ }
453
+ throw new TypeError('audio: unsupported source type')
454
+ }
455
+
456
+ /** Detect format + prepare source. */
457
+ async function detectSource(source) {
458
+ if (source instanceof ArrayBuffer || source instanceof Uint8Array) {
459
+ let bytes = source instanceof ArrayBuffer
460
+ ? new Uint8Array(source)
461
+ : source.byteOffset || source.byteLength !== source.buffer.byteLength
462
+ ? new Uint8Array(source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength))
463
+ : new Uint8Array(source.buffer)
464
+ return { format: getType(bytes), bytes }
465
+ }
466
+ if (typeof source === 'string' && !/^(https?|data|blob):/.test(source) && typeof window === 'undefined') {
467
+ let path = source
468
+ if (source.startsWith('file:')) { let { fileURLToPath } = await import('url'); path = fileURLToPath(source) }
469
+ let { open } = await import('fs/promises')
470
+ let fh = await open(path, 'r')
471
+ let hdr = new Uint8Array(12)
472
+ await fh.read(hdr, 0, 12, 0)
473
+ await fh.close()
474
+ let format = getType(new Uint8Array(hdr))
475
+ let { createReadStream } = await import('fs')
476
+ return { format, reader: createReadStream(path) }
477
+ }
478
+ let buf = await resolveSource(source)
479
+ let bytes = new Uint8Array(buf)
480
+ return { format: getType(bytes), bytes }
481
+ }
482
+
483
+ /** Universal page accumulator — push(chData, sampleRate) interface.
484
+ * Used by decodeSource and audio() push instances. This IS the universal source adapter. */
485
+ function pageAccumulator(opts = {}) {
486
+ let { pages = [], notify, ondata } = opts
487
+ let sr = 0, ch = 0, totalLen = 0, pagePos = 0
488
+ let pageBuf = null, session
489
+
490
+ function emit(page) {
491
+ pages.push(page)
492
+ totalLen += page[0].length
493
+ notify?.()
494
+ }
495
+
496
+ return {
497
+ pages,
498
+ get sampleRate() { return sr },
499
+ get channels() { return ch },
500
+ get length() { return totalLen + pagePos },
501
+ get partial() { return pagePos > 0 ? pageBuf.map(c => c.subarray(0, pagePos)) : null },
502
+ get partialLen() { return pagePos },
503
+ push(chData, sampleRate) {
504
+ if (!pageBuf) {
505
+ sr = sampleRate; ch = chData.length
506
+ pageBuf = Array.from({ length: ch }, () => new Float32Array(audio.PAGE_SIZE))
507
+ session = audio.statSession?.(sr)
508
+ }
509
+ session?.page(chData)
510
+ let srcPos = 0, chunkLen = chData[0].length
511
+ while (srcPos < chunkLen) {
512
+ let n = Math.min(chunkLen - srcPos, audio.PAGE_SIZE - pagePos)
513
+ for (let c = 0; c < ch; c++) pageBuf[c].set(chData[c].subarray(srcPos, srcPos + n), pagePos)
514
+ srcPos += n; pagePos += n
515
+ if (pagePos === audio.PAGE_SIZE) {
516
+ emit(pageBuf)
517
+ pageBuf = Array.from({ length: ch }, () => new Float32Array(audio.PAGE_SIZE))
518
+ pagePos = 0
519
+ }
520
+ }
521
+ if (ondata) {
522
+ let delta = session?.delta()
523
+ if (delta) ondata({ delta, offset: (totalLen + pagePos) / sr, sampleRate: sr, channels: ch, pages })
524
+ }
525
+ notify?.()
526
+ },
527
+ /** Flush partial page into pages array. Non-destructive — accumulator stays open. */
528
+ drain() {
529
+ if (pagePos > 0) {
530
+ emit(pageBuf.map(c => c.slice(0, pagePos)))
531
+ pageBuf = Array.from({ length: ch }, () => new Float32Array(audio.PAGE_SIZE))
532
+ pagePos = 0
533
+ }
534
+ },
535
+ done() {
536
+ if (pagePos > 0) emit(pageBuf.map(c => c.slice(0, pagePos)))
537
+ session?.flush()
538
+ if (ondata && session) {
539
+ let delta = session.delta()
540
+ if (delta) ondata({ delta, offset: totalLen / sr, sampleRate: sr, channels: ch, pages })
541
+ }
542
+ return { stats: session?.done(), length: totalLen }
543
+ }
544
+ }
545
+ }
546
+
547
+ /** Decode any source into pages + stats. Pages fill progressively. */
548
+ async function decodeSource(source, opts = {}) {
549
+ let { format, bytes, reader } = await detectSource(source)
550
+
551
+ // Non-streaming fallback
552
+ if (!format || !decode[format]) {
553
+ if (!bytes) bytes = new Uint8Array(await resolveSource(source))
554
+ let { channelData, sampleRate } = await decode(bytes.buffer || bytes)
555
+ let pages = opts.pages || []
556
+ let ps = paginate(channelData)
557
+ for (let p of ps) { pages.push(p); opts.notify?.() }
558
+ let stats = audio.statSession?.(sampleRate)?.page(channelData)?.done() ?? null
559
+ return { pages, sampleRate, channels: channelData.length, decoding: Promise.resolve({ stats, length: channelData[0].length }) }
560
+ }
561
+
562
+ // Streaming decode
563
+ let dec = await decode[format]()
564
+ let yieldLoop = () => new Promise(r => setTimeout(r, 0))
565
+ let firstResolve
566
+ let origNotify = opts.notify
567
+ let firstReady = new Promise(r => { firstResolve = r })
568
+ let acc = pageAccumulator({
569
+ pages: opts.pages,
570
+ ondata: opts.ondata,
571
+ notify: () => { origNotify?.(); if (firstResolve) { let f = firstResolve; firstResolve = null; f() } }
572
+ })
573
+
574
+ let decoding = (async () => {
575
+ try {
576
+ if (reader) {
577
+ for await (let chunk of reader) {
578
+ let buf = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)
579
+ let r = await dec(buf)
580
+ if (r.channelData.length) acc.push(r.channelData, r.sampleRate)
581
+ await yieldLoop()
582
+ }
583
+ } else {
584
+ let FEED = 64 * 1024
585
+ for (let off = 0; off < bytes.length; off += FEED) {
586
+ let r = await dec(bytes.subarray(off, Math.min(off + FEED, bytes.length)))
587
+ if (r.channelData.length) acc.push(r.channelData, r.sampleRate)
588
+ await yieldLoop()
589
+ }
590
+ }
591
+ let flushed = await dec()
592
+ if (flushed.channelData.length) acc.push(flushed.channelData, flushed.sampleRate)
593
+ let final = acc.done()
594
+ return final
595
+ } catch (e) { if (firstResolve) { let f = firstResolve; firstResolve = null; f() }; throw e }
596
+ })()
597
+
598
+ await firstReady
599
+ if (!acc.sampleRate) throw new Error('audio: decoded no audio data')
600
+
601
+ return { pages: acc.pages, sampleRate: acc.sampleRate, channels: acc.channels, decoding, acc }
602
+ }