UNPKG

audio

Version:

Audio loading, editing, and rendering for JavaScript

650 lines (589 loc) 26.3 kB
/** * audio core — paged audio container with plugin architecture. * * audio.fn — instance prototype (like $.fn) * audio.stat — stat descriptor registration/query (block, reduce, query) * audio.use — plugin registration */ import decode from 'audio-decode' import getType from 'audio-type' import encode from 'encode-audio' import convert, { parse as parseFmt } from 'pcm-convert' import parseDuration from 'parse-duration' audio.version = '2.2.0' /** Parse time value: number passthrough, string via parse-duration or timecode. */ export function parseTime(v) { if (v == null) return v if (typeof v === 'number') { if (!Number.isFinite(v)) throw new Error(`Invalid time: ${v}`); return v } // Timecode: HH:MM:SS.mmm, MM:SS.mmm, or MM:SS let tc = v.match(/^(\d+):(\d{1,2})(?::(\d{1,2}))?(?:\.(\d+))?$/) if (tc) { let [, a, b, c, frac] = tc let s = c != null ? +a * 3600 + +b * 60 + +c : +a * 60 + +b if (frac) s += +('0.' + frac) return s } let s = parseDuration(v, 's') if (s != null && isFinite(s)) return s throw new Error(`Invalid time: ${v}`) } // ── Entry Points ───────────────────────────────────────────────────────── /** Create audio from any source. Sync — returns instance immediately. * Thenable: `await audio('file.mp3')` waits for full decode. * Edits can be chained before decode completes. */ export default function audio(source, opts = {}) { // No source → pushable instance (tape recorder — push, record, stop) if (source == null) { let sr = opts.sampleRate || 44100, ch = opts.channels || 1 let waiters = [] let notify = () => { for (let w of waiters.splice(0)) w() } let a = create([], sr, ch, 0, opts, null) a.decoded = false a.recording = false a._.acc = pageAccumulator({ pages: a.pages, notify, ondata: (...args) => emit(a, 'data', ...args) }) a._.waiters = waiters return a } // Restore from serialized document if (source && typeof source === 'object' && !Array.isArray(source) && source.edits) { if (!source.source) throw new TypeError('audio: cannot restore document without source reference') let a = audio(source.source, opts) if (a.run) for (let e of source.edits) a.run(e) return a } // Concat from array of sources if (Array.isArray(source) && source.length && !(source[0] instanceof Float32Array)) { let instances = source.map(s => s?.pages ? s : audio(s, opts)) let first = instances[0].clip ? instances[0].clip() : audio.from(instances[0]) if (!first.insert) throw new Error('audio([...]): concat requires insert plugin — import "audio" instead of "audio/core.js"') for (let i = 1; i < instances.length; i++) first.insert(instances[i]) let loading = instances.filter(s => !s.decoded) if (loading.length) { first.ready = Promise.all(loading.map(s => s.ready)).then(() => { delete first.then; delete first.catch; return true }) first.ready.catch(() => {}) makeThenable(first) } return first } // From AudioBuffer if (source?.getChannelData && source?.numberOfChannels) return audio.from(source, opts) // From PCM arrays or silence duration if (Array.isArray(source) && source[0] instanceof Float32Array || typeof source === 'number') { let a = audio.from(source, opts) if (audio.evict && a.cache && a.budget !== Infinity) { a.ready = audio.evict(a).then(() => { delete a.then; delete a.catch; return true }) a.ready.catch(() => {}) makeThenable(a) } return a } // From encoded source (file, URL, buffer) let ref = typeof source === 'string' ? source : source instanceof URL ? source.href : null let pages = [], waiters = [] let notify = () => { for (let w of waiters.splice(0)) w() } let a = create(pages, 0, 0, 0, { ...opts, source: ref }, null) a._.waiters = waiters a.decoded = false let readyResolve, readyReject a._.ready = new Promise((r, j) => { readyResolve = r; readyReject = j }) a._.ready.catch(() => {}) // suppress unhandled rejection a.ready = (async () => { try { if (opts.storage === 'persistent') { if (!audio.opfsCache) throw new Error('Persistent storage requires cache module (import "./cache.js")') try { opts = { ...opts, cache: await audio.opfsCache(), budget: opts.budget ?? audio.DEFAULT_BUDGET ?? Infinity } } catch { throw new Error('OPFS not available (required by storage: "persistent")') } a.cache = opts.cache a.budget = opts.budget } let result = await decodeSource(source, { pages, notify, ondata: (...args) => emit(a, 'data', ...args) }) a.sampleRate = result.sampleRate a._.ch = result.channels a._.chV = -1 // invalidate cached channels if (result.acc) a._.acc = result.acc if (result.estDuration) a._.estDur = result.estDuration emit(a, 'metadata', { sampleRate: result.sampleRate, channels: result.channels, estDuration: result.estDuration }) readyResolve() let final = await result.decoding a._.len = final.length a._.lenV = -1 a.stats = final.stats a.decoded = true notify() audio.evict?.(a) delete a.then; delete a.catch // clear thenable before resolve to prevent unwrap loop return true } catch (e) { readyReject(e) throw e } })() a.ready.catch(() => {}) // suppress unhandled rejection; errors surface through LOAD or await makeThenable(a) return a } /** Make instance thenable — await resolves after full decode. Self-removing to prevent infinite unwrap. */ function makeThenable(a) { a.then = function(resolve, reject) { return a.ready.then(() => { delete a.then; delete a.catch; return a }).then(resolve, reject) } a.catch = function(reject) { return a.then(null, reject) } } /** Sync creation from PCM data, AudioBuffer, audio instance, function, or seconds of silence. */ audio.from = function(source, opts = {}) { if (Array.isArray(source) && source[0] instanceof Float32Array) return fromChannels(source, opts) if (typeof source === 'number') return fromSilence(source, opts) if (typeof source === 'function') return fromFunction(source, opts) if (source?.pages) { return create(source.pages, opts.sampleRate ?? source.sampleRate, opts.channels ?? source._.ch, source._.len, { source: source.source, storage: source.storage, cache: source.cache, budget: opts.budget ?? source.budget }, source.stats) } if (source?.getChannelData) { let chs = Array.from({ length: source.numberOfChannels }, (_, i) => new Float32Array(source.getChannelData(i))) return fromChannels(chs, { sampleRate: source.sampleRate, ...opts }) } // Typed array with format conversion if (ArrayBuffer.isView(source) && opts.format) { let fmt = parseFmt(opts.format) let ch = fmt.channels || opts.channels || 1 let sr = fmt.sampleRate || opts.sampleRate || 44100 let src = { ...fmt, channels: ch } if (ch > 1 && src.interleaved == null) src.interleaved = true let pcm = convert(source, src, { dtype: 'float32', interleaved: false, channels: ch }) let perCh = pcm.length / ch let chs = Array.from({ length: ch }, (_, c) => pcm.subarray(c * perCh, (c + 1) * perCh)) return fromChannels(chs, { sampleRate: sr }) } throw new TypeError('audio.from: expected Float32Array[], AudioBuffer, audio instance, function, or number') } // ── Plugin Architecture ───────────────────────────────────────────────── const fn = {} audio.fn = fn // instance prototype (like $.fn) audio.BLOCK_SIZE = 1024 audio.PAGE_SIZE = 1024 * audio.BLOCK_SIZE /** Internal protocol symbols for plugin overrides. */ export const LOAD = Symbol('load') export const READ = Symbol('read') /** Emit event on instance. */ export function emit(a, event, ...args) { let arr = a._.ev[event] if (arr) for (let cb of arr) cb(...args) } fn.on = function(event, cb) { (this._.ev[event] ??= []).push(cb); return this } fn.off = function(event, cb) { if (!event) { this._.ev = {}; return this } if (!cb) { delete this._.ev[event]; return this } let arr = this._.ev[event] if (arr) { let i = arr.indexOf(cb); if (i >= 0) arr.splice(i, 1) } return this } fn.dispose = function() { this.stop() this._.ev = {} this._.pcm = null this._.plan = null this.pages.length = 0 this.stats = null this._.waiters = null this._.acc = null } if (Symbol.dispose) fn[Symbol.dispose] = fn.dispose /** Register plugins. Each receives audio. */ audio.use = function(...plugins) { for (let p of plugins) p(audio) } // ── Instance ───────────────────────────────────────────────────────────── function create(pages, sampleRate, ch, length, opts = {}, stats) { let a = Object.create(fn) a.pages = pages a.sampleRate = sampleRate a.source = opts.source ?? null a.storage = opts.storage || 'memory' a.cache = opts.cache || null a.budget = opts.budget ?? Infinity a.stats = stats a.decoded = true a.ready = Promise.resolve(true) a._ = { ch, // source channel count len: length, // source sample length waiters: null, // decode notify queue (null when not streaming) ev: {}, // instance event listeners ct: 0, ctStamp: 0, // currentTime wall-clock interpolation vol: 1, muted: false, // volume 0..1 linear with change events rate: 1, // playbackRate } // History (edit pipeline) a.edits = [] a.version = 0 a._.pcm = null; a._.pcmV = -1 a._.plan = null; a._.planV = -1 a._.statsV = -1 a._.lenC = a._.len; a._.lenV = 0 a._.chC = a._.ch; a._.chV = 0 // Playback (getter/setter for interpolation & events) Object.defineProperties(a, { currentTime: { get() { if (this.playing && !this.paused) { let t = this._.ct + (performance.now() - this._.ctStamp) / 1000 * (this._.rate || 1) let d = this.duration return d > 0 ? Math.min(t, d) : t } return this._.ct }, set(v) { this._.ct = v; this._.ctStamp = performance.now() }, enumerable: true, configurable: true }, volume: { get() { return this._.vol }, set(v) { v = Math.max(0, Math.min(1, +v || 0)); if (this._.vol !== v) { this._.vol = v; emit(this, 'volumechange') } }, enumerable: true, configurable: true }, muted: { get() { return this._.muted }, set(v) { v = !!v; if (this._.muted !== v) { this._.muted = v; emit(this, 'volumechange') } }, enumerable: true, configurable: true }, playbackRate: { get() { return this._.rate }, set(v) { v = Math.max(0.0625, Math.min(16, +v || 1)); if (this._.rate !== v) { this._.rate = v; emit(this, 'ratechange') } }, enumerable: true, configurable: true }, }) a.playing = false; a.paused = false a.ended = false; a.seeking = false a.loop = false; a.block = null // Cache a._.lru = new Set() return a } function fromChannels(channelData, opts = {}) { let sr = opts.sampleRate || 44100 return create(paginate(channelData), sr, channelData.length, channelData[0].length, opts, audio.statSession?.(sr).page(channelData).done()) } function fromSilence(seconds, opts = {}) { let sr = opts.sampleRate || 44100, ch = opts.channels || 1 return fromChannels(Array.from({ length: ch }, () => new Float32Array(Math.round(seconds * sr))), { ...opts, sampleRate: sr }) } function fromFunction(fn, opts = {}) { let sr = opts.sampleRate || 44100, ch = opts.channels || 1 let dur = opts.duration if (dur == null) throw new TypeError('audio.from(fn): duration required') let len = Math.round(dur * sr) let chs = Array.from({ length: ch }, () => new Float32Array(len)) for (let i = 0; i < len; i++) { let v = fn(i / sr, i) if (typeof v === 'number') for (let c = 0; c < ch; c++) chs[c][i] = v else for (let c = 0; c < ch; c++) chs[c][i] = v[c] ?? 0 } return fromChannels(chs, { sampleRate: sr }) } Object.defineProperties(fn, { length: { get() { return this._.len }, configurable: true }, duration: { get() { return this.length / this.sampleRate }, configurable: true }, channels: { get() { return this._.ch }, configurable: true }, }) fn[LOAD] = async function() { if (this._.ready) await this._.ready; this._.acc?.drain() } fn[READ] = function(offset, duration) { return readPages(this, offset, duration) } /** Push PCM data into a pushable instance. Accepts Float32Array[], Float32Array, or typed array with format. */ fn.push = function(data, fmt) { let acc = this._.acc if (!acc) throw new Error('push: instance is not pushable — create with audio()') let ch = this._.ch, sr = this.sampleRate let chData if (Array.isArray(data) && data[0] instanceof Float32Array) chData = data else if (data instanceof Float32Array) chData = [data] else if (ArrayBuffer.isView(data)) { let f = fmt || {} let srcFmt = typeof f === 'string' ? f : f.format || 'int16' let nch = f.channels || ch let src = { dtype: srcFmt, channels: nch } if (nch > 1) src.interleaved = true let pcm = convert(data, src, { dtype: 'float32', interleaved: false, channels: nch }) let perCh = pcm.length / nch chData = Array.from({ length: nch }, (_, c) => pcm.subarray(c * perCh, (c + 1) * perCh)) } else throw new TypeError('push: expected Float32Array[], Float32Array, or typed array') // Sync channel count on first push, validate on subsequent if (!this._.ch) { this._.ch = chData.length; this._.chV = -1 } else if (chData.length !== this._.ch) throw new TypeError(`push: expected ${this._.ch} channels, got ${chData.length}`) acc.push(chData, (fmt && fmt.sampleRate) || sr) this._.len = acc.length this._.lenV = -1 return this } /** Stop recording and/or finalize pushable stream. Drain partial page, signal EOF to waiting streams. No-op on non-pushable. */ fn.stop = function() { this.playing = false; this.paused = false; this.seeking = false if (this._._wake) this._._wake() if (this.recording) { this.recording = false if (this._._mic) { this._._mic(null); this._._mic = null } } if (this._.acc && !this.decoded) { this._.acc.drain() this.decoded = true if (this._.waiters) for (let w of this._.waiters.splice(0)) w() } return this } /** Start recording from mic. Pushes PCM chunks until .stop(). Requires audio-mic (npm i audio-mic). */ fn.record = function(opts = {}) { if (!this._.acc) throw new Error('record: instance is not pushable — create with audio()') if (this.recording) return this this.recording = true this.decoded = false let self = this, sr = this.sampleRate, ch = this._.ch let _rec = (async () => { try { let { default: mic } = await import('audio-mic') let read = mic({ sampleRate: sr, channels: ch, bitDepth: 16, ...opts }) self._._mic = read read((err, buf) => { if (!self.recording) return if (err || !buf) return self.push(new Int16Array(buf.buffer, buf.byteOffset, buf.byteLength / 2), 'int16') }) } catch (e) { self.recording = false self.decoded = true if (self._.waiters) for (let w of self._.waiters.splice(0)) w() throw e.code === 'ERR_MODULE_NOT_FOUND' ? new Error('record: audio-mic not installed — npm i audio-mic') : e } })() _rec.catch(() => {}) // suppress unhandled rejection; surfaces through .ready/.stop return this } fn.seek = function(t) { t = Math.max(0, t) this.seeking = true this.currentTime = t if (this.cache) { let page = Math.floor(t * this.sampleRate / audio.PAGE_SIZE) ;(async () => { for (let i = Math.max(0, page - 1); i <= Math.min(page + 2, this.pages.length - 1); i++) if (this.pages[i] === null && await this.cache.has(i)) this.pages[i] = await this.cache.read(i) })() } if (this.playing) { this._._seekTo = t; if (this._._wake) this._._wake() } else this.seeking = false return this } fn.read = async function(opts) { if (typeof opts !== 'object' || opts === null) opts = {} let { at, duration, format, channel, meta } = opts at = parseTime(at); duration = parseTime(duration) await this[LOAD]() let pcm = await this[READ](at, duration) if (channel != null) pcm = [pcm[channel]] if (!format) return channel != null ? pcm[0] : pcm let converted = encode[format] ? await encode[format](pcm, { sampleRate: this.sampleRate, ...meta }) : pcm.map(ch => convert(ch, 'float32', format)) return channel != null ? (Array.isArray(converted) ? converted[0] : converted) : converted } // ── Pages ──────────────────────────────────────────────────────────────── /** Split channels into pages of PAGE_SIZE samples. */ function paginate(channelData) { let len = channelData[0].length, pages = [] for (let off = 0; off < len; off += audio.PAGE_SIZE) pages.push(channelData.map(ch => ch.subarray(off, Math.min(off + audio.PAGE_SIZE, len)))) return pages } /** Walk pages of instance a, calling visitor(page, channel, start, end) for each overlapping page. */ export function walkPages(a, c, srcOff, len, visitor) { let p0 = Math.floor(srcOff / audio.PAGE_SIZE), pos = p0 * audio.PAGE_SIZE for (let p = p0; p < a.pages.length && pos < srcOff + len; p++) { let pg = a.pages[p], pLen = pg ? pg[0].length : audio.PAGE_SIZE if (pos + pLen > srcOff && pg) { let s = Math.max(srcOff - pos, 0), e = Math.min(srcOff + len - pos, pLen) if (a._.lru) { a._.lru.delete(p); a._.lru.add(p) } visitor(pg, c, s, e, Math.max(pos - srcOff, 0)) } pos += pLen } } /** Copy channel c from a's pages into target buffer. */ export function copyPages(a, c, srcOff, len, target, tOff) { walkPages(a, c, srcOff, len, (pg, ch, s, e, off) => target.set(pg[ch].subarray(s, e), tOff + off)) } /** Read range from source pages (no edits). */ export function readPages(a, offset, duration) { let sr = a.sampleRate, ch = a._.ch let s = offset != null ? Math.min(Math.max(Math.round(offset * sr), 0), a._.len) : 0 let len = duration != null ? Math.round(duration * sr) : a._.len - s len = Math.min(Math.max(len, 0), a._.len - s) let out = Array.from({ length: ch }, () => new Float32Array(len)) for (let c = 0; c < ch; c++) copyPages(a, c, s, len, out[c], 0) return out } // ── Decode ─────────────────────────────────────────────────────────────── /** Resolve source to ArrayBuffer. */ async function resolveSource(source) { if (source instanceof ArrayBuffer) return source if (source instanceof Uint8Array) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength) if (source instanceof URL) return resolveSource(source.href) if (typeof source === 'string') { if (/^(https?|data|blob):/.test(source) || typeof window !== 'undefined') return (await fetch(source)).arrayBuffer() if (source.startsWith('file:')) { let { fileURLToPath } = await import('url') source = fileURLToPath(source) } let { readFile } = await import('fs/promises') let buf = await readFile(source) return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) } throw new TypeError('audio: unsupported source type') } /** Detect format + prepare source. */ async function detectSource(source) { if (source instanceof ArrayBuffer || source instanceof Uint8Array) { let bytes = source instanceof ArrayBuffer ? new Uint8Array(source) : source.byteOffset || source.byteLength !== source.buffer.byteLength ? new Uint8Array(source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength)) : new Uint8Array(source.buffer) return { format: getType(bytes), bytes } } if (typeof source === 'string' && !/^(https?|data|blob):/.test(source) && typeof window === 'undefined') { let path = source if (source.startsWith('file:')) { let { fileURLToPath } = await import('url'); path = fileURLToPath(source) } let { open, stat } = await import('fs/promises') let fh = await open(path, 'r') let hdr = new Uint8Array(12) await fh.read(hdr, 0, 12, 0) await fh.close() let format = getType(new Uint8Array(hdr)) let fileSize = (await stat(path)).size let { createReadStream } = await import('fs') return { format, reader: createReadStream(path), fileSize } } let buf = await resolveSource(source) let bytes = new Uint8Array(buf) return { format: getType(bytes), bytes } } /** Universal page accumulator — push(chData, sampleRate) interface. * Used by decodeSource and audio() push instances. This IS the universal source adapter. */ function pageAccumulator(opts = {}) { let { pages = [], notify, ondata } = opts let sr = 0, ch = 0, totalLen = 0, pagePos = 0 let pageBuf = null, session function emit(page) { pages.push(page) totalLen += page[0].length notify?.() } return { pages, get sampleRate() { return sr }, get channels() { return ch }, get length() { return totalLen + pagePos }, get partial() { return pagePos > 0 ? pageBuf.map(c => c.subarray(0, pagePos)) : null }, get partialLen() { return pagePos }, push(chData, sampleRate) { if (!pageBuf) { sr = sampleRate; ch = chData.length pageBuf = Array.from({ length: ch }, () => new Float32Array(audio.PAGE_SIZE)) session = audio.statSession?.(sr) } session?.page(chData) let srcPos = 0, chunkLen = chData[0].length while (srcPos < chunkLen) { let n = Math.min(chunkLen - srcPos, audio.PAGE_SIZE - pagePos) for (let c = 0; c < ch; c++) pageBuf[c].set(chData[c].subarray(srcPos, srcPos + n), pagePos) srcPos += n; pagePos += n if (pagePos === audio.PAGE_SIZE) { emit(pageBuf) pageBuf = Array.from({ length: ch }, () => new Float32Array(audio.PAGE_SIZE)) pagePos = 0 } } if (ondata) { let delta = session?.delta() if (delta) ondata({ delta, offset: (totalLen + pagePos) / sr, sampleRate: sr, channels: ch, pages }) } notify?.() }, /** Flush partial page into pages array. Non-destructive — accumulator stays open. */ drain() { if (pagePos > 0) { emit(pageBuf.map(c => c.slice(0, pagePos))) pageBuf = Array.from({ length: ch }, () => new Float32Array(audio.PAGE_SIZE)) pagePos = 0 } }, done() { if (pagePos > 0) emit(pageBuf.map(c => c.slice(0, pagePos))) session?.flush() if (ondata && session) { let delta = session.delta() if (delta) ondata({ delta, offset: totalLen / sr, sampleRate: sr, channels: ch, pages }) } return { stats: session?.done(), length: totalLen } } } } /** Estimate duration from file size, format, sampleRate, channels. */ function estimateDuration(fileSize, format, sampleRate, channels) { if (!fileSize || !sampleRate || !channels) return null if (format === 'wav') return Math.max(0, (fileSize - 44) / (sampleRate * channels * 2)) // 16-bit PCM if (format === 'flac') return fileSize / (sampleRate * channels * 0.7) // ~56% compression typical if (format === 'mp3') return fileSize / (128000 / 8) // assume 128kbps if (format === 'ogg' || format === 'opus') return fileSize / (96000 / 8) // assume 96kbps return null } /** Decode any source into pages + stats. Pages fill progressively. */ async function decodeSource(source, opts = {}) { let { format, bytes, reader, fileSize } = await detectSource(source) // Non-streaming fallback if (!format || !decode[format]) { if (!bytes) bytes = new Uint8Array(await resolveSource(source)) let { channelData, sampleRate } = await decode(bytes.buffer || bytes) let pages = opts.pages || [] let ps = paginate(channelData) for (let p of ps) { pages.push(p); opts.notify?.() } let stats = audio.statSession?.(sampleRate)?.page(channelData)?.done() ?? null return { pages, sampleRate, channels: channelData.length, decoding: Promise.resolve({ stats, length: channelData[0].length }) } } // Streaming decode let dec = await decode[format]() let yieldLoop = () => new Promise(r => setTimeout(r, 0)) let firstResolve let origNotify = opts.notify let firstReady = new Promise(r => { firstResolve = r }) let acc = pageAccumulator({ pages: opts.pages, ondata: opts.ondata, notify: () => { origNotify?.(); if (firstResolve) { let f = firstResolve; firstResolve = null; f() } } }) let decoding = (async () => { try { if (reader) { for await (let chunk of reader) { let buf = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk) let r = await dec(buf) if (r.channelData.length) acc.push(r.channelData, r.sampleRate) await yieldLoop() } } else { let FEED = 64 * 1024 for (let off = 0; off < bytes.length; off += FEED) { let r = await dec(bytes.subarray(off, Math.min(off + FEED, bytes.length))) if (r.channelData.length) acc.push(r.channelData, r.sampleRate) await yieldLoop() } } let flushed = await dec() if (flushed.channelData.length) acc.push(flushed.channelData, flushed.sampleRate) let final = acc.done() return final } catch (e) { if (firstResolve) { let f = firstResolve; firstResolve = null; f() }; throw e } })() await firstReady if (!acc.sampleRate) throw new Error('audio: decoded no audio data') let estDuration = estimateDuration(fileSize || bytes?.length, format, acc.sampleRate, acc.channels) return { pages: acc.pages, sampleRate: acc.sampleRate, channels: acc.channels, decoding, acc, estDuration } }