Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
5.9 kB · 126 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127#!/usr/bin/env node// sync-whistle.mjs — lock the whistle to cornerfive's grid AND key, with// rubberband (pitch-preserving). For each detected note we:// • stretch it (rubberband --time) so its onset→next-onset span lands// exactly on the 16th-note grid → tight sync, no drift// • pitch-shift it (rubberband --pitch) onto the nearest B-major tone// rubberband preserves timbre/formants, so it stays true to the whistle// while being synced + tuned — no WORLD resynth, no choppy reglue.//// Output: src/whistle-synced.wav + src/whistle-synced.notes.json (the grid// onsets + pitches, so the remix renderer can chop on phrase boundaries).//// Usage: node bin/sync-whistle.mjs [--quantize 16|8]
import { spawnSync } from "node:child_process";import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";import { dirname, resolve } from "node:path";import { fileURLToPath } from "node:url";import { readWavMono } from "../../lib/wav.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));const ROOT = resolve(HERE, "..");const ANALYSIS = resolve(ROOT, "src/whistle.analysis.json");const IN_WAV = resolve(ROOT, "src/whistle.wav");const OUT_WAV = resolve(ROOT, "src/whistle-synced.wav"); // beat-aligned + shiftedconst OUT_NOTES = resolve(ROOT, "src/whistle-synced.notes.json");const TMP = resolve(ROOT, "src/.slices");
const BPM = 73.2;const STEP = (4 * 60 / BPM) / 16;
const flags = {};for (let i = 2; i < process.argv.length; i++) if (process.argv[i].startsWith("--")) flags[process.argv[i].slice(2)] = process.argv[i + 1];const QGRID = STEP * (flags.quantize === "8" ? 2 : 1); // quantize unit
// Autotune target = the whistle's OWN observed melody (each note locked to// the nearest semitone of what it actually whistled — chromatic, no key).const NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];const nameOf = (m) => `${NAMES[((m % 12) + 12) % 12]}${Math.floor(m / 12) - 1}`;
function writeWav(path, samples, sr) { const n = samples.length, buf = Buffer.alloc(44 + n * 2); buf.write("RIFF", 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write("WAVE", 8); buf.write("fmt ", 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22); buf.writeUInt32LE(sr, 24); buf.writeUInt32LE(sr * 2, 28); buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34); buf.write("data", 36); buf.writeUInt32LE(n * 2, 40); for (let i = 0; i < n; i++) buf.writeInt16LE(Math.round(Math.max(-1, Math.min(1, samples[i])) * 32767), 44 + i * 2); writeFileSync(path, buf);}
// ── merge adjacent same-pitch notes (keep the ORIGINAL melody) ──────────const { melody } = JSON.parse(readFileSync(ANALYSIS, "utf8"));const notes = [];for (const n of melody) { const tgt = n.midi; // its OWN observed pitch const last = notes[notes.length - 1]; if (last && last.tgt === tgt) continue; notes.push({ tgt, src: n.startSec, cents: n.centsOff || 0 });}
// grid-quantize onsets, strictly increasinglet prev = -Infinity;for (const n of notes) { let q = Math.round(n.src / QGRID) * QGRID; if (q <= prev) q = prev + QGRID; n.dst = q; prev = q;}
const { samples: x, sampleRate: SR } = readWavMono(IN_WAV);const srcDur = x.length / SR;const lastSeg = Math.max(STEP, Math.round((srcDur - notes[notes.length - 1].src) / QGRID) * QGRID);const dstDurTotal = notes[notes.length - 1].dst + lastSeg;
rmSync(TMP, { recursive: true, force: true });mkdirSync(TMP, { recursive: true });
const synced = new Float32Array(Math.ceil(dstDurTotal * SR) + SR);const xf = Math.floor(0.008 * SR); // 8ms join crossfade
// ── rubberband each note: beat-align (stretch to grid) + shift (tune to// the semitone center via cents). Keeps the ORIGINAL melody/contour — no// WORLD f0-replace, no flattening. ────────────────────────────────────────console.log(`→ beat-aligning + shifting ${notes.length} notes (rubberband, original melody)…`);for (let i = 0; i < notes.length; i++) { const srcStart = notes[i].src; const srcEnd = i + 1 < notes.length ? notes[i + 1].src : srcDur; const dstStart = notes[i].dst; const dstEnd = i + 1 < notes.length ? notes[i + 1].dst : dstDurTotal; const sLen = Math.max(0.02, srcEnd - srcStart); const dLen = Math.max(0.02, dstEnd - dstStart);
const a = Math.floor(srcStart * SR), b = Math.min(x.length, Math.floor(srcEnd * SR)); const slice = x.subarray(a, b); const inP = resolve(TMP, `in-${i}.wav`), outP = resolve(TMP, `out-${i}.wav`); writeWav(inP, slice, SR);
const ratio = Math.max(0.25, Math.min(4, dLen / sLen)); // beat-align const shift = (-notes[i].cents / 100).toFixed(3); // nudge into tune const r = spawnSync("rubberband", [ "--time", ratio.toFixed(4), "--pitch", shift, "-q", inP, outP, ], { encoding: "utf8" }); let piece; if (r.status === 0) piece = readWavMono(outP).samples; else { console.warn(` ⚠ rubberband note ${i} failed, using dry slice`); piece = slice; }
const off = Math.floor(dstStart * SR); for (let k = 0; k < piece.length; k++) { let g = 1; if (k < xf) g = k / xf; if (piece.length - k < xf) g = (piece.length - k) / xf; const di = off + k; if (di >= synced.length) break; synced[di] += piece[k] * g; }}rmSync(TMP, { recursive: true, force: true });writeWav(OUT_WAV, synced, SR);
writeFileSync(OUT_NOTES, JSON.stringify({ tuning: "original melody (beat-aligned + cents-shifted)", bpm: BPM, quantize: flags.quantize === "8" ? 8 : 16, notes: notes.map((n) => ({ note: nameOf(n.tgt), midi: n.tgt, startSec: +n.dst.toFixed(3) })),}, null, 2) + "\n");console.log(`✓ beat-aligned + shifted → ${OUT_WAV.replace(process.env.HOME, "~")} (${dstDurTotal.toFixed(1)}s)`);console.log(` ${notes.map((n) => nameOf(n.tgt)).join(" ")}`);