// Spectral measurement, in ONE place. // // This existed twice before this file did -- once in plan-qa.mjs and once in // swap-clip.mjs -- because it was written twice in one afternoon. Two copies of // a measure is worse than two copies of a helper: when they drift, one tool // says a clip is hissy and the other says it is fine, and there is no way to // tell which is lying. Same rule that keeps clipwindow.mjs shared between the // page and the renderer. // // Everything here is pure and takes samples, not paths, except readWavMono -- // so the Next app can hand it a window it already read and never touch the disk // twice. /** * In-place radix-2 FFT. `re`/`im` must be the same power-of-two length. * Decimation-in-time, so the bit-reversal permutation comes first. */ export function fftInPlace(re, im) { const n = re.length; for (let i = 1, j = 0; i < n; i += 1) { let bit = n >> 1; for (; j & bit; bit >>= 1) j ^= bit; j ^= bit; if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; } } for (let len = 2; len <= n; len <<= 1) { const ang = (-2 * Math.PI) / len; for (let i = 0; i < n; i += len) { for (let k = 0; k < len / 2; k += 1) { const wr = Math.cos(ang * k); const wi = Math.sin(ang * k); const ur = re[i + k]; const ui = im[i + k]; const vr = re[i + k + len / 2] * wr - im[i + k + len / 2] * wi; const vi = re[i + k + len / 2] * wi + im[i + k + len / 2] * wr; re[i + k] = ur + vr; im[i + k] = ui + vi; re[i + k + len / 2] = ur - vr; im[i + k + len / 2] = ui - vi; } } } } /** Periodic-ish Hann window of length n. Cached: the same few lengths recur. */ const windows = new Map(); export function hann(n) { let w = windows.get(n); if (!w) { w = new Float32Array(n); for (let i = 0; i < n; i += 1) w[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (n - 1)); windows.set(n, w); } return w; } /** Power spectrum of one frame, bins 0..N/2. Allocates; callers frame-loop. */ export function powerSpectrum(x, off, N) { const re = new Float64Array(N); const im = new Float64Array(N); const w = hann(N); for (let i = 0; i < N; i += 1) re[i] = (x[off + i] ?? 0) * w[i]; fftInPlace(re, im); const p = new Float64Array(N / 2); for (let i = 0; i < N / 2; i += 1) p[i] = re[i] * re[i] + im[i] * im[i]; return p; } export const FLATNESS_DEFAULTS = { N: 1024, hop: 512, /** 100 Hz .. 5 kHz, where voice lives. */ loHz: 100, hiHz: 5000, }; /** * Spectral flatness: geometric over arithmetic mean of the power spectrum. * 0 is a pure tone, 1 is white noise. A voiced um is strongly harmonic and * reads low; the hiss that got amplified into a finished Yoshi render read * 0.10 at source and was normalised up x4.29 on top of that. * * The MEDIAN frame is returned, not the mean: one plosive frame should not * condemn a clip, and one clean frame should not save a hissy one. */ export function spectralFlatness(x, sr, opts = {}) { const { N, hop, loHz, hiHz } = { ...FLATNESS_DEFAULTS, ...opts }; if (!x || x.length < N) return 0; const lo = Math.max(1, Math.floor((loHz * N) / sr)); const hi = Math.min(N / 2, Math.floor((hiHz * N) / sr)); if (hi <= lo) return 0; const vals = []; for (let off = 0; off + N <= x.length; off += hop) { const p = powerSpectrum(x, off, N); let sLog = 0; let sLin = 0; let n = 0; for (let i = lo; i < hi; i += 1) { const v = p[i] + 1e-12; sLog += Math.log(v); sLin += v; n += 1; } if (n) vals.push(Math.exp(sLog / n) / (sLin / n)); } if (!vals.length) return 0; vals.sort((a, b) => a - b); return vals[vals.length >> 1]; } /** * Per-frame brightness and onset strength, for FINDING A MOMENT in a mixed * track rather than grading a clip. * * This is the measure that located the Pokemon background's encounter alarm. * Its route music and its alarm have almost the same loudness, so an envelope * says nothing -- but the alarm is a much higher figure, so the high/low band * ratio steps up and stays up. Reading that by eye off a curve is the whole * point of putting it on screen: the alarm onset had been eyeballed 1.14s late * from a pitch contour, and the render handed over after the game's alarm * instead of on top of it. * * Returns arrays sampled every `hop` samples: * centroid spectral centroid in Hz * ratio energy in [splitHz, hiHz] over energy below splitHz * flux positive spectral flux -- an onset detector * rms plain level, so a curve can be read against the sound */ export function brightnessCurve(x, sr, opts = {}) { const N = opts.N ?? 512; const hop = opts.hop ?? Math.round(sr * 0.02); const splitHz = opts.splitHz ?? 900; const hiHz = opts.hiHz ?? 4000; const t = []; const centroid = []; const ratio = []; const flux = []; const rms = []; if (!x || x.length < N) return { t, centroid, ratio, flux, rms, hop, sr }; const loBin = 1; const splitBin = Math.max(2, Math.floor((splitHz * N) / sr)); const hiBin = Math.min(N / 2, Math.floor((hiHz * N) / sr)); let prev = null; for (let off = 0; off + N <= x.length; off += hop) { const p = powerSpectrum(x, off, N); let cen = 0; let tot = 0; let hi = 0; let lo = 0; let f = 0; for (let i = loBin; i < N / 2; i += 1) { const m = Math.sqrt(p[i]); cen += (i * sr) / N * m; tot += m; if (i >= splitBin && i < hiBin) hi += m; else if (i < splitBin) lo += m; if (prev) { const d = m - prev[i]; if (d > 0) f += d; } } let s2 = 0; for (let i = 0; i < N; i += 1) s2 += x[off + i] * x[off + i]; t.push(off / sr); centroid.push(tot ? cen / tot : 0); ratio.push(lo > 1e-9 ? hi / lo : 0); flux.push(f); rms.push(Math.sqrt(s2 / N)); prev = new Float64Array(N / 2); for (let i = 0; i < N / 2; i += 1) prev[i] = Math.sqrt(p[i]); } return { t, centroid, ratio, flux, rms, hop, sr }; } /** * Where a sustained brightness STEP begins -- the shape an alarm, a stinger or * a music cue has, as opposed to a single bright transient. * * A frame qualifies when the band ratio over the next `hold` seconds averages * `rise`x the average over the preceding `hold`, and the frame itself is at * least as bright as that later average. Returned strongest first. */ export function brightnessSteps(curve, opts = {}) { const hold = opts.hold ?? 0.4; const rise = opts.rise ?? 1.8; const { ratio, t, hop, sr } = curve; const frames = Math.max(1, Math.round((hold * sr) / hop)); const out = []; const mean = (a, b) => { let s = 0; let n = 0; for (let i = Math.max(0, a); i < Math.min(ratio.length, b); i += 1) { s += ratio[i]; n += 1; } return n ? s / n : 0; }; for (let i = frames; i + frames < ratio.length; i += 1) { const before = mean(i - frames, i); const after = mean(i, i + frames); if (before < 1e-6) continue; const gain = after / before; if (gain < rise) continue; if (ratio[i] < after * 0.6) continue; out.push({ at: +t[i].toFixed(3), gain: +gain.toFixed(2), before: +before.toFixed(3), after: +after.toFixed(3) }); } // One step per event: keep the first frame of each run, not all of them. const kept = []; for (const s of out) { if (kept.length && s.at - kept[kept.length - 1].at < hold) { if (s.gain > kept[kept.length - 1].gain) kept[kept.length - 1] = s; continue; } kept.push(s); } return kept.sort((a, b) => b.gain - a.gain); } /** * Read a 16-bit RIFF/WAVE file whole, as mono float. For the CLI scripts, which * hold one episode at a time; the app reads windows instead (lib/wav.ts). */ export function readWavMono(file, readFileSync) { const b = readFileSync(file); let p = 12; let fmt = null; let data = null; while (p + 8 <= b.length) { const id = b.toString("latin1", p, p + 4); const sz = b.readUInt32LE(p + 4); const body = p + 8; if (id === "fmt ") fmt = { ch: b.readUInt16LE(body + 2), sr: b.readUInt32LE(body + 4) }; if (id === "data") { data = b.subarray(body, body + sz); break; } p = body + sz + (sz & 1); } if (!fmt || !data) throw new Error(`not a readable wav: ${file}`); const n = Math.floor(data.length / 2 / fmt.ch); const x = new Float32Array(n); for (let i = 0; i < n; i += 1) { let s = 0; for (let c = 0; c < fmt.ch; c += 1) s += data.readInt16LE((i * fmt.ch + c) * 2) / 32768; x[i] = s / fmt.ch; } return { x, sr: fmt.sr }; }