audio
Version:
Audio loading, editing, and rendering for JavaScript
256 lines (221 loc) • 9.88 kB
JavaScript
/**
* Stats engine — block-level stat computation + unified stat query.
* Self-registers on import — exposes statSession on audio, adds fn.stat.
*/
import audio, { parseTime, LOAD } from './core.js'
import { buildPlan, streamPlan, ensurePlan } from './plan.js'
// ── Stat descriptor registry ────────────────────────────────────
let statDefs = {}
/** Register/query stat: audio.stat(), audio.stat(name), audio.stat(name, descriptor|blockFn) */
audio.stat = function(name, desc) {
if (!arguments.length) return statDefs
if (arguments.length === 1) return statDefs[name]
if (typeof desc === 'function') desc = { block: desc }
statDefs[name] = desc
}
/** Create a stat computation session. ch inferred from first .page() call. */
function statSession(sr) {
let fns, acc, ch, last = 0, rem = null, remLen = 0
function init(c) {
ch = c
fns = Object.entries(audio.stat())
.filter(([_, d]) => d.block)
.map(([name, d]) => ({ name, fn: d.block, ctx: { sampleRate: sr } }))
acc = Object.create(null)
for (let { name } of fns) acc[name] = Array.from({ length: ch }, () => [])
}
function processBlock(block) {
for (let { name, fn, ctx } of fns) {
let v = fn(block, ctx)
if (typeof v === 'number') for (let c = 0; c < ch; c++) acc[name][c].push(v)
else for (let c = 0; c < ch; c++) acc[name][c].push(v[c])
}
}
return {
page(page) {
if (!acc) init(page.length)
let BS = audio.BLOCK_SIZE, off = 0, len = page[0].length
// Complete partial remainder from previous push
if (remLen > 0) {
let need = BS - remLen
if (len >= need) {
for (let c = 0; c < ch; c++) rem[c].set(page[c].subarray(0, need), remLen)
processBlock(rem)
off = need
remLen = 0
} else {
for (let c = 0; c < ch; c++) rem[c].set(page[c].subarray(0, len), remLen)
remLen += len
return this
}
}
// Process full blocks
while (off + BS <= len) {
processBlock(Array.from({ length: ch }, (_, c) => page[c].subarray(off, off + BS)))
off += BS
}
// Buffer remainder
if (off < len) {
if (!rem) rem = Array.from({ length: ch }, () => new Float32Array(BS))
for (let c = 0; c < ch; c++) rem[c].set(page[c].subarray(off))
remLen = len - off
}
return this
},
/** Flush any buffered partial block as a short final block. */
flush() {
if (remLen > 0) {
processBlock(Array.from({ length: ch }, (_, c) => rem[c].subarray(0, remLen)))
remLen = 0
}
},
delta() {
if (!acc) return
let firstKey = Object.keys(acc)[0]
if (!firstKey) return
let cur = acc[firstKey][0].length
if (cur <= last) return
let d = { fromBlock: last }
for (let name in acc) d[name] = acc[name].map(a => new Float32Array(a.slice(last)))
last = cur
return d
},
done() {
this.flush()
let out = { blockSize: audio.BLOCK_SIZE }
if (acc) for (let name in acc) out[name] = acc[name].map(a => new Float32Array(a))
return out
}
}
}
// ── Bin reduction ────────────────────────────────────────────────
function binReduce(src, from, to, bins, reduce) {
if (bins <= 0 || to <= from) return new Float32Array(Math.max(0, bins))
from = Math.max(0, from); to = Math.min(to, src.length)
if (to <= from) return new Float32Array(bins)
let out = new Float32Array(bins), bpp = (to - from) / bins
for (let i = 0; i < bins; i++) {
let a = from + Math.floor(i * bpp), b = Math.min(from + Math.floor((i + 1) * bpp), to)
if (b <= a) b = a + 1
out[i] = reduce(src, a, b)
}
return out
}
/** Remap source stats by segment layout (plan-only edits, no sample pipeline).
* Falls back to null if segments are too complex to remap cheaply. */
function remapStats(srcStats, plan, sr) {
let bs = srcStats.blockSize, segs = plan.segs, totalLen = plan.totalLen
// Check feasibility: only self-ref (undefined) and silence (null), rate ±1
for (let s of segs) {
let rate = s[3] || 1, ref = s[4]
if (ref !== undefined && ref !== null) return null // external ref
if (Math.abs(rate) !== 1) return null // resampled
if (s[0] % bs !== 0 || s[2] % bs !== 0) return null // unaligned — force recompute
}
let outBlocks = Math.ceil(totalLen / bs)
let fields = Object.keys(srcStats).filter(k => k !== 'blockSize' && Array.isArray(srcStats[k]))
let ch = srcStats[fields[0]]?.length || 1
let out = { blockSize: bs }
for (let f of fields) out[f] = Array.from({ length: ch }, () => new Float32Array(outBlocks))
for (let s of segs) {
let srcOff = s[0], count = s[1], dstOff = s[2], rate = s[3] || 1, ref = s[4]
let dstBlockStart = Math.floor(dstOff / bs)
let dstBlockEnd = Math.ceil((dstOff + count) / bs)
if (ref === null) continue // silence — Float32Array already zeroed
let srcBlockStart = Math.floor(srcOff / bs)
let srcBlocks = srcStats[fields[0]][0].length
let rev = rate < 0
for (let i = dstBlockStart; i < dstBlockEnd && i < outBlocks; i++) {
let si = rev ? srcBlockStart + (dstBlockEnd - 1 - i) : srcBlockStart + (i - dstBlockStart)
if (si < 0 || si >= srcBlocks) continue
for (let f of fields) for (let c = 0; c < ch; c++) out[f][c][i] = srcStats[f][c][si]
}
}
return out
}
// ── Self-register ────────────────────────────────────────────────
audio.statSession = statSession
/** Resolve block range from opts. Recomputes stats if edits are dirty. */
export async function queryRange(inst, opts) {
await inst[LOAD]()
let at = parseTime(opts?.at), dur = parseTime(opts?.duration)
let hasRange = at != null || dur != null
if (inst.edits?.length && inst._.statsV !== inst.version) {
if (!inst._.srcStats) inst._.srcStats = inst.stats
// Range query on dirty edits — compute stats for just the requested range
if (hasRange) {
let plan = buildPlan(inst)
await ensurePlan(inst, plan, at || 0, dur)
let s = statSession(inst.sampleRate)
for (let chunk of streamPlan(inst, plan, at || 0, dur)) s.page(chunk)
let stats = s.done()
let first = Object.values(stats).find(v => v?.[0]?.length)
let blocks = first?.[0]?.length || 0
return { stats, ch: inst.channels, sr: inst.sampleRate, from: 0, to: blocks }
}
// Plan-only edits (no sample pipeline) — remap source stats by segments
let plan = buildPlan(inst)
if (!plan.pipeline.length && inst._.srcStats?.blockSize) {
let remapped = remapStats(inst._.srcStats, plan, inst.sampleRate)
if (remapped) { inst.stats = remapped; inst._.statsV = inst.version }
else {
let s = statSession(inst.sampleRate); await ensurePlan(inst, plan); for (let chunk of streamPlan(inst, plan)) s.page(chunk); inst.stats = s.done()
inst._.statsV = inst.version
}
} else {
// Full recompute — has sample-level ops
let s = statSession(inst.sampleRate); await ensurePlan(inst, plan); for (let chunk of streamPlan(inst, plan)) s.page(chunk); inst.stats = s.done()
inst._.statsV = inst.version
}
}
let sr = inst.sampleRate, bs = inst.stats?.blockSize
if (!bs) return { stats: inst.stats, ch: inst.channels, sr, from: 0, to: 0 }
let first = Object.values(inst.stats).find(v => v?.[0]?.length)
let blocks = first?.[0]?.length || 0
let atN = at != null && at < 0 ? inst.duration + at : at
let from = atN != null ? Math.floor(atN * sr / bs) : 0
let to = dur != null ? Math.ceil(((atN || 0) + dur) * sr / bs) : blocks
from = Math.max(0, Math.min(from, blocks))
to = Math.max(from, Math.min(to, blocks))
return { stats: inst.stats, ch: inst.channels, sr, from, to }
}
audio.fn.stat = async function(name, opts) {
// Array of stat names — parallel query, positional result
if (Array.isArray(name)) return Promise.all(name.map(n => this.stat(n, opts)))
// Instance methods (spectrum, cepstrum, etc.)
if (typeof this[name] === 'function' && !audio.stat(name)) return this[name](opts)
let { stats, ch, sr, from, to } = await queryRange(this, opts)
let bins = opts?.bins
// Resolve channel selection once
let chSel = opts?.channel
let perCh = Array.isArray(chSel)
let chs = chSel != null ? (perCh ? chSel : [chSel]) : Array.from({ length: ch }, (_, i) => i)
let desc = audio.stat(name)
// Derived stats — custom query (skip if bins requested on block stat)
if (desc?.query && bins == null) return desc.query(stats, chs, from, to, sr)
// Raw block stats
let src = stats[name], reduce = desc?.reduce
if (!src) throw new Error(`Unknown stat: '${name}'`)
if (!reduce) throw new Error(`No reducer for stat: '${name}'`)
// Binned mode
if (bins != null) {
let n = bins ?? (to - from)
let reduce1 = (c) => binReduce(src[c], from, to, n, reduce)
if (perCh) return chs.map(reduce1)
if (chs.length === 1) return reduce1(chs[0])
let out = new Float32Array(n), bpp = (to - from) / n
for (let i = 0; i < n; i++) {
let a = from + Math.floor(i * bpp), b = Math.min(from + Math.floor((i + 1) * bpp), to)
if (b <= a) b = a + 1
let sum = 0
for (let c of chs) sum += reduce(src[c], a, b)
out[i] = sum / chs.length
}
return out
}
// Scalar mode
if (perCh) return chs.map(c => reduce(src[c], from, to))
if (chs.length === 1) return reduce(src[chs[0]], from, to)
let vals = chs.map(c => reduce(src[c], from, to))
return vals.reduce((a, b) => a + b, 0) / vals.length
}