Whole-track analysis into a frame-indexed FeatureTrack. Nothing reads a live AnalyserNode: realtime preview maps currentTime to a frame index, export counts frames, both read the same rows. - fft.js: radix-2 with precomputed tables, allocated once per track - analyze.js: STFT at hop 1/60s with CENTRED windows (a window that starts at the timestamp reports energy arriving up to 23ms later, which reads as visuals lagging the music). Energy features normalised against the track's own percentiles; absolute stats kept in summary for the look generator. - tempo.js: autocorrelation + grid F-measure, beat grid, downbeats - segment.js: self-similarity novelty, boundaries snapped to the bar grid - FeatureTrack: assembles everything, plus the lookahead fields. buildSlope rises through the bars leading into a higher-energy section, so a build can ramp into the drop rather than react after it lands. - clicktrack.js: mixes clicks onto the detected grid for validation by ear Three real bugs found and fixed by the tests: - 174 BPM read as 87. Mean-energy-per-beat scores a half-tempo grid identically to the true one; only an F-measure penalises the missed onsets via recall. - 90 BPM read as 180. Offbeat hi-hats make a double-tempo grid score perfectly on both precision and recall, so the grid is now interpreted metrically afterwards: a systematic strong/weak alternation means the real beat is every other grid point. - Beat grid drifted ~30ms over 30s from integer-frame offsets. Onset peaks are now parabolically interpolated and the grid least-squares fitted. Gates: 11/11 node tests against synthetic ground truth (tempo within 2% across 90-174 BPM, beat alignment under half a frame, segmentation within 2s of a known boundary, graceful on silence, 6-minute analysis in 1.0s); 5/5 browser checks including audio-driven vs fixed-step frame parity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
9.8 KiB
JavaScript
267 lines
9.8 KiB
JavaScript
// Structural segmentation: where the song changes, and what each part is.
|
||
//
|
||
// Standard approach — self-similarity plus a checkerboard novelty kernel — kept
|
||
// deliberately simple. This is the fuzziest stage in the pipeline, and a wrong
|
||
// boundary costs a scene change in a slightly odd place, not a broken video. An
|
||
// ML detour here would buy very little.
|
||
//
|
||
// The one domain-specific trick that earns its keep: boundaries are SNAPPED to
|
||
// the bar grid, preferring 4- and 8-bar multiples. Electronic music changes on
|
||
// phrase lines, so snapping converts a boundary that is roughly right into one
|
||
// that is exactly right.
|
||
|
||
export const SECTION_KINDS = ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro'];
|
||
|
||
const ANALYSIS_HZ = 4; // coarse grid for the similarity matrix
|
||
const KERNEL_SECONDS = 6; // half-width of the checkerboard kernel
|
||
const MIN_SECTION_SECONDS = 12;
|
||
|
||
function median(values) {
|
||
if (!values.length) return 0;
|
||
const s = Float64Array.from(values).sort();
|
||
return s[Math.floor(s.length / 2)];
|
||
}
|
||
|
||
/** Coarse, L2-normalised feature vectors — the rows of the similarity matrix. */
|
||
function buildCoarseVectors(raw, frameCount, fps) {
|
||
const step = Math.max(1, Math.round(fps / ANALYSIS_HZ));
|
||
const count = Math.max(1, Math.floor(frameCount / step));
|
||
const dims = ['bandSub', 'bandLow', 'bandMid', 'bandHigh', 'bandAir', 'centroid', 'flatness'];
|
||
const vectors = [];
|
||
|
||
for (let c = 0; c < count; c++) {
|
||
const start = c * step;
|
||
const end = Math.min(frameCount, start + step);
|
||
const v = new Float32Array(dims.length);
|
||
for (let d = 0; d < dims.length; d++) {
|
||
let sum = 0;
|
||
for (let f = start; f < end; f++) sum += raw[dims[d]][f];
|
||
v[d] = sum / Math.max(1, end - start);
|
||
}
|
||
let norm = 0;
|
||
for (let d = 0; d < v.length; d++) norm += v[d] * v[d];
|
||
norm = Math.sqrt(norm) + 1e-9;
|
||
for (let d = 0; d < v.length; d++) v[d] /= norm;
|
||
vectors.push(v);
|
||
}
|
||
return { vectors, step };
|
||
}
|
||
|
||
function cosine(a, b) {
|
||
let dot = 0;
|
||
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
|
||
return dot;
|
||
}
|
||
|
||
/**
|
||
* Novelty curve. Only the band around the diagonal is needed, so this never
|
||
* materialises the full N×N matrix.
|
||
*/
|
||
function noveltyCurve(vectors, kernelHalf) {
|
||
const n = vectors.length;
|
||
const novelty = new Float32Array(n);
|
||
|
||
// Gaussian-tapered checkerboard weights.
|
||
const size = kernelHalf * 2;
|
||
const weights = new Float32Array(size * size);
|
||
const sigma = kernelHalf / 1.5;
|
||
for (let a = 0; a < size; a++) {
|
||
for (let b = 0; b < size; b++) {
|
||
const da = a - kernelHalf + 0.5;
|
||
const db = b - kernelHalf + 0.5;
|
||
const taper = Math.exp(-(da * da + db * db) / (2 * sigma * sigma));
|
||
const sign = (da * db) > 0 ? 1 : -1;
|
||
weights[a * size + b] = sign * taper;
|
||
}
|
||
}
|
||
|
||
for (let i = kernelHalf; i < n - kernelHalf; i++) {
|
||
let sum = 0;
|
||
for (let a = 0; a < size; a++) {
|
||
const ia = i - kernelHalf + a;
|
||
for (let b = 0; b < size; b++) {
|
||
const ib = i - kernelHalf + b;
|
||
sum += weights[a * size + b] * cosine(vectors[ia], vectors[ib]);
|
||
}
|
||
}
|
||
novelty[i] = Math.max(0, sum);
|
||
}
|
||
|
||
let peak = 0;
|
||
for (let i = 0; i < n; i++) if (novelty[i] > peak) peak = novelty[i];
|
||
if (peak > 1e-9) for (let i = 0; i < n; i++) novelty[i] /= peak;
|
||
return novelty;
|
||
}
|
||
|
||
function pickPeaks(novelty, minDistance, threshold) {
|
||
const peaks = [];
|
||
const n = novelty.length;
|
||
for (let i = 1; i < n - 1; i++) {
|
||
if (novelty[i] < threshold) continue;
|
||
if (novelty[i] < novelty[i - 1] || novelty[i] < novelty[i + 1]) continue;
|
||
// Local maximum within the exclusion window.
|
||
let isMax = true;
|
||
const lo = Math.max(0, i - minDistance);
|
||
const hi = Math.min(n - 1, i + minDistance);
|
||
for (let j = lo; j <= hi; j++) if (novelty[j] > novelty[i]) { isMax = false; break; }
|
||
if (!isMax) continue;
|
||
if (peaks.length && i - peaks[peaks.length - 1] < minDistance) {
|
||
if (novelty[i] > novelty[peaks[peaks.length - 1]]) peaks[peaks.length - 1] = i;
|
||
continue;
|
||
}
|
||
peaks.push(i);
|
||
}
|
||
return peaks;
|
||
}
|
||
|
||
/**
|
||
* Snap a time to the musical grid. Prefers, in order: an 8-bar line, a 4-bar
|
||
* line, then any downbeat — but only if one is close enough to be plausibly the
|
||
* same boundary.
|
||
*/
|
||
function snapToGrid(time, downbeats, barsPerPhrase = 8) {
|
||
if (!downbeats.length) return time;
|
||
const tolerance = 2.0;
|
||
|
||
const tryLevels = [barsPerPhrase, 4, 1];
|
||
for (const level of tryLevels) {
|
||
let best = null;
|
||
let bestDist = Infinity;
|
||
for (let i = 0; i < downbeats.length; i += level) {
|
||
const d = Math.abs(downbeats[i] - time);
|
||
if (d < bestDist) { bestDist = d; best = downbeats[i]; }
|
||
}
|
||
if (best !== null && bestDist <= tolerance) return best;
|
||
}
|
||
return time;
|
||
}
|
||
|
||
function linearSlope(values) {
|
||
const n = values.length;
|
||
if (n < 2) return 0;
|
||
let sx = 0, sy = 0, sxy = 0, sxx = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
sx += i; sy += values[i]; sxy += i * values[i]; sxx += i * i;
|
||
}
|
||
const denom = n * sxx - sx * sx;
|
||
if (Math.abs(denom) < 1e-12) return 0;
|
||
return ((n * sxy - sx * sy) / denom) * n; // normalised to "change across the section"
|
||
}
|
||
|
||
/**
|
||
* Label a section from its own statistics plus where it sits in the track.
|
||
* Thresholds are relative to the track, never absolute, so a quiet ambient piece
|
||
* still gets a full set of labels rather than being classified as one long intro.
|
||
*/
|
||
function classify(section, context) {
|
||
const { energy, slope, flux, index, count, endEnergy } = section;
|
||
const { medianEnergy, isFirst, isLast } = context;
|
||
|
||
const low = energy < medianEnergy * 0.72;
|
||
const high = energy > medianEnergy * 1.12;
|
||
|
||
if (isFirst && energy < medianEnergy) return 'intro';
|
||
if (isLast && (low || slope < -0.15)) return 'outro';
|
||
if (slope > 0.18 && endEnergy > medianEnergy * 0.95) return 'build';
|
||
if (high && flux > context.medianFlux * 0.95) return 'drop';
|
||
if (low) return 'breakdown';
|
||
if (index === 0) return 'intro';
|
||
if (index === count - 1) return 'outro';
|
||
return 'sustain';
|
||
}
|
||
|
||
/**
|
||
* @returns {Array<{index,start,end,startFrame,endFrame,kind,energy,slope,flux,centroid}>}
|
||
*/
|
||
export function segment(raw, frameCount, fps, tempo) {
|
||
const { vectors, step } = buildCoarseVectors(raw, frameCount, fps);
|
||
const kernelHalf = Math.max(4, Math.round(KERNEL_SECONDS * ANALYSIS_HZ));
|
||
|
||
let boundaryTimes = [];
|
||
if (vectors.length > kernelHalf * 2 + 4) {
|
||
const novelty = noveltyCurve(vectors, kernelHalf);
|
||
const minDistance = Math.round(MIN_SECTION_SECONDS * ANALYSIS_HZ);
|
||
|
||
// Adaptive threshold: mean + a fraction of the spread, so a track with
|
||
// gentle transitions still yields boundaries and a busy one isn't shredded.
|
||
let mean = 0;
|
||
for (let i = 0; i < novelty.length; i++) mean += novelty[i];
|
||
mean /= novelty.length;
|
||
let variance = 0;
|
||
for (let i = 0; i < novelty.length; i++) variance += (novelty[i] - mean) ** 2;
|
||
const std = Math.sqrt(variance / novelty.length);
|
||
const threshold = mean + std * 0.6;
|
||
|
||
boundaryTimes = pickPeaks(novelty, minDistance, threshold)
|
||
.map((i) => (i * step) / fps);
|
||
}
|
||
|
||
// Snap to the bar grid, dedupe, and drop anything too close to the edges.
|
||
const duration = frameCount / fps;
|
||
const snapped = boundaryTimes
|
||
.map((t) => snapToGrid(t, tempo.downbeats))
|
||
.filter((t) => t > MIN_SECTION_SECONDS * 0.5 && t < duration - MIN_SECTION_SECONDS * 0.5)
|
||
.sort((a, b) => a - b);
|
||
|
||
const bounds = [0];
|
||
for (const t of snapped) {
|
||
if (t - bounds[bounds.length - 1] >= MIN_SECTION_SECONDS) bounds.push(t);
|
||
}
|
||
bounds.push(duration);
|
||
|
||
// Build sections and gather their statistics.
|
||
const sections = [];
|
||
for (let i = 0; i < bounds.length - 1; i++) {
|
||
const start = bounds[i];
|
||
const end = bounds[i + 1];
|
||
const startFrame = Math.round(start * fps);
|
||
const endFrame = Math.min(frameCount, Math.round(end * fps));
|
||
const n = Math.max(1, endFrame - startFrame);
|
||
|
||
let energySum = 0, fluxSum = 0, centroidSum = 0, flatnessSum = 0, widthSum = 0;
|
||
const coarseEnergy = [];
|
||
const coarseStep = Math.max(1, Math.floor(n / 24));
|
||
for (let f = startFrame; f < endFrame; f++) {
|
||
energySum += raw.loudness[f];
|
||
fluxSum += raw.flux[f];
|
||
centroidSum += raw.centroid[f];
|
||
flatnessSum += raw.flatness[f];
|
||
widthSum += raw.width[f];
|
||
if ((f - startFrame) % coarseStep === 0) coarseEnergy.push(raw.loudness[f]);
|
||
}
|
||
|
||
const tailStart = Math.max(startFrame, endFrame - Math.round(fps * 4));
|
||
let endEnergy = 0;
|
||
for (let f = tailStart; f < endFrame; f++) endEnergy += raw.loudness[f];
|
||
endEnergy /= Math.max(1, endFrame - tailStart);
|
||
|
||
sections.push({
|
||
index: i,
|
||
start, end,
|
||
startFrame, endFrame,
|
||
duration: end - start,
|
||
energy: energySum / n,
|
||
flux: fluxSum / n,
|
||
centroid: centroidSum / n,
|
||
flatness: flatnessSum / n,
|
||
width: widthSum / n,
|
||
slope: linearSlope(coarseEnergy),
|
||
endEnergy,
|
||
kind: 'sustain',
|
||
});
|
||
}
|
||
|
||
const context = {
|
||
medianEnergy: median(sections.map((s) => s.energy)) || 1e-6,
|
||
medianFlux: median(sections.map((s) => s.flux)) || 1e-6,
|
||
};
|
||
sections.forEach((s, i) => {
|
||
s.kind = classify(
|
||
{ ...s, index: i, count: sections.length },
|
||
{ ...context, isFirst: i === 0, isLast: i === sections.length - 1 },
|
||
);
|
||
});
|
||
|
||
return sections;
|
||
}
|