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/README.md +647 -44
- package/audio.d.ts +232 -0
- package/audio.js +54 -0
- package/bin/cli.js +983 -0
- package/cache.js +89 -0
- package/core.js +602 -0
- package/dist/audio.all.js +105369 -0
- package/dist/audio.js +3490 -0
- package/dist/audio.min.js +14 -0
- package/fn/cepstrum.js +55 -0
- package/fn/clip.js +11 -0
- package/fn/crop.js +19 -0
- package/fn/fade.js +47 -0
- package/fn/filter.js +67 -0
- package/fn/gain.js +16 -0
- package/fn/insert.js +31 -0
- package/fn/loudness.js +102 -0
- package/fn/mix.js +21 -0
- package/fn/normalize.js +80 -0
- package/fn/pad.js +19 -0
- package/fn/pan.js +35 -0
- package/fn/play.js +112 -0
- package/fn/remix.js +25 -0
- package/fn/remove.js +25 -0
- package/fn/repeat.js +35 -0
- package/fn/reverse.js +25 -0
- package/fn/save.js +55 -0
- package/fn/silence.js +34 -0
- package/fn/spectrum.js +104 -0
- package/fn/speed.js +23 -0
- package/fn/split.js +9 -0
- package/fn/stat.js +77 -0
- package/fn/transform.js +6 -0
- package/fn/trim.js +68 -0
- package/fn/write.js +15 -0
- package/{LICENSE → license.md} +21 -21
- package/package.json +64 -19
- package/plan.js +456 -0
- package/stats.js +255 -0
- package/index.js +0 -107
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
|
+
}
|