import { nextPow2, realFftMagnitude } from "./yin.js"; const A4_FREQ = 440; const A4_MIDI = 69; /** * Compute a 12-bin pitch-class chromagram for a single frame using the * vendored real FFT. Energy below 30 Hz or above the Nyquist is ignored. */ export function chromagramFromFrame( frame: Float32Array, sampleRate: number, minFreq = 30 ): Float32Array { const out = new Float32Array(12); if (frame.length < 8) return out; const mag = realFftMagnitude(frame); const fftSize = nextPow2(frame.length); const nyquist = sampleRate / 2; for (let k = 1; k < mag.length; k++) { const freq = (k * sampleRate) / fftSize; if (freq < minFreq || freq >= nyquist) continue; // k starts at 1 with sampleRate > 0, so freq > 0 and the log is finite. const midi = A4_MIDI + 12 * Math.log2(freq / A4_FREQ); const pc = ((Math.round(midi) % 12) + 12) % 12; const m = mag[k]!; out[pc] = out[pc]! + m; } return out; } /** Normalise a chromagram in-place so its max value is 1 (or zero remains zero). */ export function normaliseChroma(chroma: Float32Array): Float32Array { let max = 0; for (let i = 0; i < chroma.length; i++) { const v = chroma[i]!; if (v > max) max = v; } if (max === 0) return chroma; for (let i = 0; i < chroma.length; i++) { chroma[i] = chroma[i]! / max; } return chroma; } /** Sum two same-length chromagrams in-place into `acc`. */ export function accumulateChroma( acc: Float32Array, add: Float32Array ): Float32Array { const len = Math.min(acc.length, add.length); for (let i = 0; i < len; i++) { acc[i] = acc[i]! + add[i]!; } return acc; }