#!/usr/bin/env node // Lift a voice where ANOTHER voice is resting. // // The run carries the melody's rests -- that is what it is for, and it is where the X // strokes are drawn. But it is mixed at one level throughout, so in the passage it // exists to fill it is no more present than it is underneath the tune. Asked for those // notes to be "more prominent even if straying a little outside of the midi", the // cheapest half of the answer is simply to let them be louder there. // // The lift is a ramped `volume` automation, not a switch: an instant jump on a 90ms // note rate reads as a click at every window edge. RAMP seconds either side turns it // into the voice leaning in as the melody drops out. // // node rest-lift.mjs // GATE_PLAN= GATE_VOICE=melody lift where THAT voice is silent // LIFT=5 decibels // RAMP=0.25 seconds to reach it import { readFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import path from "node:path"; import { SONG_DATA } from "./paths.mjs"; const [IN, OUT] = process.argv.slice(2); if (!OUT) throw new Error("usage: rest-lift.mjs "); const LIFT = Number(process.env.LIFT ?? 5); const RAMP = Number(process.env.RAMP ?? 0.25); const MIN_REST = Number(process.env.MIN_REST ?? 0.6); const abs = (f) => (path.isAbsolute(f) ? f : path.join(SONG_DATA, f)); const gp = JSON.parse(readFileSync(abs(process.env.GATE_PLAN), "utf8")); const gv = gp.voices.find((x) => x.name === (process.env.GATE_VOICE ?? "melody")); if (!gv) throw new Error("gate voice not found"); const spans = gv.plan .map((n) => [n.slotStart, n.slotStart + Math.min(n.slotDur, n.soundedDur ?? n.slotDur)]) .sort((a, b) => a[0] - b[0]); // the REST windows: the gaps between the gate voice's spans, long enough to matter const rests = []; let prev = spans[0][1]; for (const [a, b] of spans.slice(1)) { if (a - prev >= MIN_REST) rests.push([prev, a]); prev = Math.max(prev, b); } if (!rests.length) throw new Error(`no rests of >= ${MIN_REST}s in the gate voice`); const gain = 10 ** (LIFT / 20); console.log(`${rests.length} rests of >=${MIN_REST}s totalling ` + `${rests.reduce((s, [a, b]) => s + (b - a), 0).toFixed(1)}s; lifting ${LIFT}dB (x${gain.toFixed(2)}) ` + `with a ${RAMP}s ramp`); for (const [a, b] of rests) console.log(` ${a.toFixed(2)} -> ${b.toFixed(2)}s`); // One nested expression: 1.0 everywhere, ramping to `gain` inside each rest. const expr = rests.reduce((acc, [a, b]) => `if(between(t,${(a - RAMP).toFixed(3)},${a.toFixed(3)}), 1+(${(gain - 1).toFixed(4)})*(t-${(a - RAMP).toFixed(3)})/${RAMP},` + `if(between(t,${a.toFixed(3)},${b.toFixed(3)}), ${gain.toFixed(4)},` + `if(between(t,${b.toFixed(3)},${(b + RAMP).toFixed(3)}), ${gain.toFixed(4)}-(${(gain - 1).toFixed(4)})*(t-${b.toFixed(3)})/${RAMP}, ${acc})))`, "1.0"); execFileSync("ffmpeg", ["-nostdin", "-v", "error", "-y", "-i", abs(IN), "-af", `volume='${expr}':eval=frame`, "-ar", "48000", "-ac", "2", abs(OUT)], { stdio: ["ignore", "inherit", "inherit"] }); console.log(`wrote ${OUT}`);