#!/usr/bin/env node // Exaggerate a line's melodic contour, so it survives being played on a DIFFERENT // clip per note. // // The run voice's targets follow the written chromatic contour almost exactly (97% // of rising steps preserved, 4% of intervals broken) and it still reads as "flat // rapid beats". Nothing is wrong with the notes: a 1-semitone step is simply not // audible when every note is a different um. Each clip brings its own vowel, // formants and loudness, and that timbral jump is far larger than a semitone, so the // pitch relationship between neighbours is lost. // // Chopping one um kept the timbre constant, which is why the climb read there. The // unique-clip-per-note gimmick gives that up by construction, so the contour has to // be made bigger to survive it: scaling intervals about the line's centre turns a // chromatic run into a whole-tone or wider one, which IS audible across timbres. // // It costs pitch-shift, which is the trade the user offered for this voice // specifically -- the line is already unrealistically high, so its shifts are // cheaper than the melody's would be. // // node contour-gain.mjs import { readFileSync, writeFileSync } from "node:fs"; const [inF, outF, gainArg] = process.argv.slice(2); const G = Number(gainArg ?? 2); const notes = JSON.parse(readFileSync(inF, "utf8")); const sorted = [...notes.map((n) => n.midi)].sort((a, b) => a - b); const centre = sorted[Math.floor(sorted.length / 2)]; const NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; const nm = (m) => `${NAMES[((m % 12) + 12) % 12]}${Math.floor(m / 12) - 1}`; const out = notes.map((n) => { const midi = Math.round(centre + (n.midi - centre) * G); return { ...n, midi, name: nm(midi) }; }); const before = [Math.min(...notes.map((n) => n.midi)), Math.max(...notes.map((n) => n.midi))]; const after = [Math.min(...out.map((n) => n.midi)), Math.max(...out.map((n) => n.midi))]; // the step sizes are the whole point, so report them const stepsOf = (a) => { const s = a.slice(1).map((n, i) => Math.abs(n.midi - a[i].midi)).filter((x) => x > 0); return s.sort((x, y) => x - y)[Math.floor(s.length / 2)]; }; console.log(`gain ${G} about MIDI ${centre} (${nm(centre)})`); console.log(` range ${before[0]}-${before[1]} (${before[1] - before[0]} st) -> ${after[0]}-${after[1]} (${after[1] - after[0]} st)`); console.log(` median non-zero step ${stepsOf(notes)} -> ${stepsOf(out)} semitones`); writeFileSync(outF, JSON.stringify(out, null, 1)); console.log(`wrote ${outF}`);