#!/usr/bin/env node // Find where a tune REPEATS ITSELF, so it can be shortened without losing anything. // // "Less repeats makes for better use of people's time" -- and several of these // arrangements are a short section played two or three times. Cutting at the first // repeat costs nothing musically and takes a third to a half off the runtime. // // Method: for each candidate period P, ask what fraction of notes have a partner P // seconds later at the SAME pitch. A section repeated exactly scores ~1.0 at its own // length; a near-repeat (same tune, different ending) scores high but not perfect, // which is the interesting case -- it says where the variation starts. // // node find-repeats.mjs [more.json ...] import { readFileSync } from "node:fs"; import path from "node:path"; import { SONG_DATA } from "./paths.mjs"; const TOL = Number(process.env.TOL ?? 0.04); // onset tolerance, seconds const STEP = Number(process.env.STEP ?? 0.02); // period scan resolution const MIN_P = Number(process.env.MIN_P ?? 4); // ignore periods shorter than this for (const file of process.argv.slice(2)) { const p = path.isAbsolute(file) ? file : path.join(SONG_DATA, file); let notes; try { notes = JSON.parse(readFileSync(p, "utf8")); } catch { console.log(`${file}: unreadable`); continue; } if (!Array.isArray(notes) || !notes.length) { console.log(`${file}: not a lead array`); continue; } notes = notes.filter((n) => typeof n.t === "number").sort((a, b) => a.t - b.t); const end = Math.max(...notes.map((n) => n.t + (n.dur ?? 0))); // index onsets for a quick "is there a note at time X with pitch M" test const byBucket = new Map(); const B = 0.25; for (const n of notes) { const k = Math.floor(n.t / B); if (!byBucket.has(k)) byBucket.set(k, []); byBucket.get(k).push(n); } const hasNote = (t, midi) => { for (let k = Math.floor((t - TOL) / B); k <= Math.floor((t + TOL) / B); k += 1) { for (const m of byBucket.get(k) ?? []) if (m.midi === midi && Math.abs(m.t - t) <= TOL) return true; } return false; }; const results = []; for (let P = MIN_P; P <= end / 2 + 0.001; P += STEP) { let tested = 0, hit = 0; for (const n of notes) { if (n.t + P > end + TOL) continue; tested += 1; if (hasNote(n.t + P, n.midi)) hit += 1; } if (tested >= 12) results.push({ P, score: hit / tested, tested }); } results.sort((a, b) => b.score - a.score || a.P - b.P); // keep the best, then the best that is not a multiple of one already kept const kept = []; for (const r of results) { if (r.score < Number(process.env.MIN_SCORE ?? 0.6)) break; if (kept.some((k) => Math.abs(r.P - k.P) < 1 || Math.abs(r.P % k.P) < 0.3 || Math.abs((r.P % k.P) - k.P) < 0.3)) continue; kept.push(r); if (kept.length >= 3) break; } console.log(`\n${path.basename(file)} ${notes.length} notes, ${end.toFixed(1)}s`); if (!kept.length) { console.log(` no repeat at >=60% match -- through-composed`); continue; } for (const k of kept) { const copies = end / k.P; console.log(` period ${k.P.toFixed(2)}s match ${(100 * k.score).toFixed(0)}% ` + `-> ${copies.toFixed(2)} copies; cutting at the first repeat leaves ${k.P.toFixed(1)}s ` + `(${(100 * k.P / end).toFixed(0)}% of the tune)`); } }