// Find stretches of the wild-battle theme in the 12h37m longplay. // // v1 correlated a 4s chroma SEQUENCE against reference windows. That fails: the // per-frame score is sharply peaked (it only matches when the candidate is at the // same phase of the loop), so smoothing it over +-2s buries the real matches and // nothing clears any sensible threshold - including the reference region itself, // which is the tell that the method, not the threshold, was wrong. // // This uses a PHASE-INVARIANT profile instead: the mean 12-bin chroma over a window, // which is the music's pitch-class distribution and does not care where in the loop // the window starts. Battle music in its own key scores high wherever it appears. // // Chroma is cached to disk - it takes ~3 minutes and the thresholding needs iterating. import { readFileSync, writeFileSync, existsSync } from "node:fs"; const SR = 4000, N = 1024, HOP = SR / 4; const RAW = process.argv[2]; const CACHE = "pk-chroma.f32"; const WIN_S = Number(process.env.WIN_S ?? 6); const THR = Number(process.env.THR ?? 0.8); const MIN_RUN = Number(process.argv[3] ?? 66); function fft(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), wi = Math.sin(ang * k); const ur = re[i + k], 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; } } } let chroma, nFrames; if (existsSync(CACHE)) { const b = readFileSync(CACHE); chroma = new Float32Array(b.buffer, b.byteOffset, b.length / 4); nFrames = chroma.length / 12; console.log(`chroma from cache: ${nFrames} frames`); } else { const buf = readFileSync(RAW); const total = Math.floor(buf.length / 2); nFrames = Math.floor((total - N) / HOP); console.log(`track ${(total / SR / 3600).toFixed(2)}h -> ${nFrames} frames`); chroma = new Float32Array(nFrames * 12); const win = new Float64Array(N); for (let i = 0; i < N; i += 1) win[i] = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / (N - 1)); const binPc = new Int8Array(N / 2).fill(-1); for (let k = 1; k < N / 2; k += 1) { const hz = k * SR / N; if (hz < 65 || hz > 1900) continue; binPc[k] = ((Math.round(12 * Math.log2(hz / 440) + 69) % 12) + 12) % 12; } const re = new Float64Array(N), im = new Float64Array(N); for (let f = 0; f < nFrames; f += 1) { const off = f * HOP; for (let i = 0; i < N; i += 1) { re[i] = buf.readInt16LE((off + i) * 2) / 32768 * win[i]; im[i] = 0; } fft(re, im); const c = new Float64Array(12); for (let k = 1; k < N / 2; k += 1) { const pc = binPc[k]; if (pc >= 0) c[pc] += re[k] * re[k] + im[k] * im[k]; } let s = 0; for (let p = 0; p < 12; p += 1) s += c[p]; s = Math.sqrt(s) || 1; for (let p = 0; p < 12; p += 1) chroma[f * 12 + p] = Math.sqrt(c[p]) / s; if (f % 20000 === 0) process.stderr.write(` chroma ${f}/${nFrames}\r`); } writeFileSync(CACHE, Buffer.from(chroma.buffer, chroma.byteOffset, chroma.length * 4)); console.log(`chroma computed and cached`); } const WF = WIN_S * 4; // mean chroma profile over [f, f+WF) function profile(f) { const p = new Float64Array(12); for (let i = 0; i < WF; i += 1) for (let k = 0; k < 12; k += 1) p[k] += chroma[(f + i) * 12 + k]; let m = 0; for (const x of p) m += x; m /= 12; for (let k = 0; k < 12; k += 1) p[k] -= m; let e = 0; for (const x of p) e += x * x; return { p, e: Math.sqrt(e) || 1 }; } // reference = the known battle, 2117.5-2133.0 const REF_A = Math.round(2117.5 * 4), REF_B = Math.round(2133.0 * 4); const ref = new Float64Array(12); for (let f = REF_A; f + WF <= REF_B; f += 1) { const { p } = profile(f); for (let k = 0; k < 12; k += 1) ref[k] += p[k]; } let rm = 0; for (const x of ref) rm += x; rm /= 12; for (let k = 0; k < 12; k += 1) ref[k] -= rm; let re2 = 0; for (const x of ref) re2 += x * x; const refE = Math.sqrt(re2) || 1; const score = new Float32Array(nFrames); for (let f = 0; f + WF <= nFrames; f += 1) { const { p, e } = profile(f); let d = 0; for (let k = 0; k < 12; k += 1) d += p[k] * ref[k]; score[f] = d / (e * refE); } // diagnostics: does the reference region actually score high? const at = (t) => score[Math.round(t * 4)].toFixed(3); console.log(`self-check - score inside the reference battle: 2120s=${at(2120)} 2125s=${at(2125)} 2130s=${at(2130)}`); console.log(` score on route music before it: 2095s=${at(2095)} 2100s=${at(2100)} 2105s=${at(2105)}`); const sorted = Float32Array.from(score.subarray(0, nFrames - WF)).sort(); const q = (p) => sorted[Math.floor(p * (sorted.length - 1))].toFixed(3); console.log(`score distribution: p10=${q(0.1)} p50=${q(0.5)} p90=${q(0.9)} p99=${q(0.99)} max=${q(1)}`); const runs = []; let run = null; for (let f = 0; f + WF <= nFrames; f += 1) { if (score[f] >= THR) { if (!run) run = { a: f, b: f }; else run.b = f; } else if (run) { runs.push(run); run = null; } } if (run) runs.push(run); const fmt = (s) => `${Math.floor(s / 60)}:${(s % 60).toFixed(1).padStart(4, "0")}`; // a run of window-starts [a,b] covers audio [a/4, b/4 + WIN_S] const list = runs.map((r) => ({ start: r.a / 4, len: (r.b - r.a) / 4 + WIN_S })).filter((r) => r.len >= 20).sort((a, b) => b.len - a.len); console.log(`\n${runs.length} runs over ${THR}; ${list.length} of them >=20s. Longest 20:`); for (const r of list.slice(0, 20)) { console.log(` ${r.start.toFixed(1)}s (${fmt(r.start)}) len ${r.len.toFixed(1)}s${r.len >= MIN_RUN ? " <-- long enough" : ""}`); } console.log(`\n${list.filter((r) => r.len >= MIN_RUN).length} runs at least ${MIN_RUN}s long`);