// Build a VISUAL-ONLY voice for the chopped third voice. // // The chopped voice has no clip per note -- it is slices of a few sustained ums -- so // there is nothing for render-poly's usual panel logic to show, and it shipped as // audio with no face while every other note in the video has one. // // A panel cut to the actual 78ms slices would strobe unwatchably. So the run is // chunked into ~0.6s cells (about one donor's length), each cell showing the donor // that MOST of its notes actually came from -- read from chop-voice.mjs's EMIT_NOTES, // not guessed, so the face on screen is the voice being heard. // // The voice carries gain 0. render-poly computes (TARGET_RMS * v.gain) / rms with no // falsy fallback, so 0 is genuinely silent -- the audio still comes from the wav that // chop-voice.mjs rendered. // // node chop-panels.mjs import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { SONG_DATA } from "./paths.mjs"; const [notesF, planIn, planOut] = process.argv.slice(2); const CELL = Number(process.env.CELL ?? 0.6); const GAP = Number(process.env.GAP ?? 0.35); // a silence this long ends a run const notes = JSON.parse(readFileSync(notesF, "utf8")); const man = JSON.parse(readFileSync(path.join(SONG_DATA, "um-manifest.json"), "utf8")).items; const midiOf = (hz) => 69 + 12 * Math.log2(hz / 440); // group into runs, then chop each run into cells const runs = []; for (const n of notes) { const r = runs[runs.length - 1]; if (r && n.t - (r.end) <= GAP) { r.end = Math.max(r.end, n.t + n.dur); r.notes.push(n); } else runs.push({ start: n.t, end: n.t + n.dur, notes: [n] }); } const cells = []; for (const r of runs) { for (let a = r.start; a < r.end - 0.05; a += CELL) { const b = Math.min(a + CELL, r.end); const inCell = r.notes.filter((n) => n.t >= a - 1e-6 && n.t < b); if (!inCell.length) continue; const tally = {}; for (const n of inCell) tally[n.donor] = (tally[n.donor] ?? 0) + 1; const donor = Object.entries(tally).sort((x, y) => y[1] - x[1])[0][0]; cells.push({ start: +a.toFixed(4), dur: +(b - a).toFixed(4), donor, n: inCell.length }); } } const plan = JSON.parse(readFileSync(planIn, "utf8")); const entries = []; for (const c of cells) { const d = man.find((e) => e.k === c.donor); if (!d) throw new Error(`donor ${c.donor} not in the manifest`); const srcStart = +(d.clipStart + d.selA).toFixed(3); const srcEnd = +(d.clipStart + d.selB).toFixed(3); const mid = midiOf(d.f0); entries.push({ note: Math.round(mid), name: "chop", targetMidi: +mid.toFixed(2), slotStart: c.start, slotDur: c.dur, noteDur: c.dur, phraseStart: c.start, phraseEnd: +(c.start + c.dur).toFixed(4), video: d.v, date: d.d ?? "", srcStart, srcEnd, srcDur: +(srcEnd - srcStart).toFixed(3), keepSide: 0, headOffset: 0, srcF0: d.f0, srcMidi: +mid.toFixed(2), shift: 0, spreadCents: 0, noTrim: true, }); } plan.voices = plan.voices.filter((v) => v.name !== "chop"); plan.voices.push({ name: "chop", gain: 0, role: "pip2", plan: entries }); writeFileSync(planOut, JSON.stringify(plan, null, 1)); const byDonor = {}; for (const c of cells) byDonor[c.donor] = (byDonor[c.donor] ?? 0) + 1; console.log(`${runs.length} runs -> ${cells.length} panel cells of <=${CELL}s`); console.log("cells per donor: " + Object.entries(byDonor).map(([k, v]) => `${k} ${v}`).join(", ")); console.log(`panel covers ${cells.reduce((s, c) => s + c.dur, 0).toFixed(1)}s of the song`); console.log(`wrote ${planOut} with voices: ${plan.voices.map((v) => `${v.name}(${v.plan.length})`).join(", ")}`);