Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345#!/usr/bin/env node// vocal.mjs — AC-native formant-synthesized singing voice. EXPERIMENTAL.//// Status (2026-05-03): smoke-tested, dropped from the big-pictures// vocal pipeline. The 3-formant synth at this fidelity reads as// "melodic tones," not voice — getting real vocoder/talkbox character// would need glottal pulse + F4/F5 + pitch jitter + breath +// consonant articulation, a real research lane. Big-pictures vocal// now routes through jeffrey-pvc via /api/say (see pop/RESEARCH-DIRECTION.md).//// This file stays in the repo as an experimental lane — may resurface// as a *melodic instrument* layer (formant-shaped lead) rather than// as a vocal substitute.//// Bottom-up vocal: same primitive bag as percussion.mjs. A sawtooth// "glottal" source (rich harmonics, 1/n falloff) is shaped through 3// parallel 2-pole resonant filters tuned to a vowel's formant// frequencies (F1/F2/F3), gated by a per-syllable envelope. Renders// deterministically into a Float32 buffer and ffmpeg → mp3.//// Input: a phrase as [{ syl, vowel, pitch, dur, level? }, ...]// vowel: key of VOWELS map (ah / eh / ih / ee / oh / oo / uh)// pitch: Hz, MIDI number, or note string ("C3", "Eb3", "F#4")// dur: seconds//// Smoke run (no args): renders "the music is real" on a 5-note pitch// curve to recap/out/vocal.mp3.//// Score mode: --score <path>.np reads a notepat-format score (matches// papers/arxiv-folk-songs/folk-songs.tex §3) and renders the named// section (default "hook"). Per-syllable vowels are inferred from the// syllable text. Per-syllable durations are computed by splitting a// bar's worth of time across each line, with the line-final syllable// getting 1.5× weight.//// Usage:// node bin/vocal.mjs # smoke phrase// node bin/vocal.mjs --out ~/Desktop/vocal.mp3// node bin/vocal.mjs --over out/trap.mp3 # mix over trap bed// node bin/vocal.mjs --score ../pop/big-pictures/plork.np \// --section hook --bpm 140 --octave 3 --over out/trap.mp3
import { writeFileSync, readFileSync, mkdirSync, unlinkSync, existsSync,} from "node:fs";import { resolve, dirname } from "node:path";import { fileURLToPath } from "node:url";import { spawnSync } from "node:child_process";import { homedir } from "node:os";
const HERE = dirname(fileURLToPath(import.meta.url));const ROOT = resolve(HERE, "..");
const SAMPLE_RATE = 48_000;
// ── Vowel formants (Hz) — F1, F2, F3 with bandwidths and amps ────────// Approximate male/neutral speaker. Tweakable per syllable via override.const VOWELS = { ah: { F: [730, 1090, 2440], BW: [60, 90, 150], A: [1.0, 0.6, 0.3] }, // father, ah eh: { F: [530, 1840, 2480], BW: [60, 90, 150], A: [1.0, 0.7, 0.3] }, // bet, eh ih: { F: [390, 1990, 2550], BW: [50, 90, 150], A: [1.0, 0.7, 0.4] }, // bit, ih ee: { F: [270, 2290, 3010], BW: [50, 100, 150], A: [1.0, 0.8, 0.4] }, // beat, real oh: { F: [570, 840, 2410], BW: [60, 80, 150], A: [1.0, 0.5, 0.2] }, // boat oo: { F: [300, 870, 2240], BW: [50, 80, 150], A: [1.0, 0.4, 0.2] }, // boot, music uh: { F: [500, 1500, 2500], BW: [60, 90, 150], A: [1.0, 0.5, 0.25] }, // schwa, the};
// ── Smoke phrase: "the music is real" ────────────────────────────────const SMOKE = [ { syl: "the", vowel: "uh", pitch: "C3", dur: 0.18 }, { syl: "mu", vowel: "oo", pitch: "D3", dur: 0.18 }, { syl: "sic", vowel: "ih", pitch: "Eb3", dur: 0.22 }, { syl: "is", vowel: "ih", pitch: "D3", dur: 0.20 }, { syl: "real", vowel: "ee", pitch: "C3", dur: 0.55 },];
// ── Pitch parsing: Hz / MIDI / note name ──────────────────────────────const NOTE_TO_SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
function parsePitch(p) { if (typeof p === "number") return p > 20 ? p : midiToFreq(p); if (typeof p !== "string") return 130.81; const m = p.trim().toLowerCase().match(/^([a-g])([#b]?)(-?\d+)$/); if (!m) { const n = Number(p); return Number.isFinite(n) ? (n > 20 ? n : midiToFreq(n)) : 130.81; } let semi = NOTE_TO_SEMI[m[1]]; if (m[2] === "#") semi += 1; if (m[2] === "b") semi -= 1; const oct = parseInt(m[3], 10); const midi = 12 * (oct + 1) + semi; return midiToFreq(midi);}
// ── Render one syllable ───────────────────────────────────────────────// Sawtooth source → 3 parallel 2-pole resonant filters → sum.// Filter: y[n] = gain*x[n] + a*y[n-1] + b*y[n-2]// r = exp(-π*BW/sr), a = 2*r*cos(2π*fc/sr), b = -r²// gain = 1 - r² to normalize peak.// Envelope: 12ms attack, hold, 30ms release.function renderSyllable(syl, out, startSec) { const vowel = VOWELS[syl.vowel] || VOWELS.uh; const freq = parsePitch(syl.pitch); const dur = syl.dur ?? 0.2; const level = syl.level ?? 1.0;
const startIdx = Math.floor(startSec * SAMPLE_RATE); const totalSamples = Math.ceil(dur * SAMPLE_RATE); const attackSamples = Math.min(Math.floor(totalSamples / 3), Math.floor(0.012 * SAMPLE_RATE)); const releaseSamples = Math.min(Math.floor(totalSamples / 3), Math.floor(0.030 * SAMPLE_RATE)); const sustainEnd = totalSamples - releaseSamples;
const phaseInc = freq / SAMPLE_RATE; let phase = 0;
const filters = vowel.F.map((fc, i) => { const bw = vowel.BW[i]; const r = Math.exp(-Math.PI * bw / SAMPLE_RATE); const a = 2 * r * Math.cos(2 * Math.PI * fc / SAMPLE_RATE); const b = -r * r; const gain = 1 - r * r; return { a, b, gain, amp: vowel.A[i], y1: 0, y2: 0 }; });
for (let i = 0; i < totalSamples; i++) { const dst = startIdx + i; if (dst < 0 || dst >= out.length) continue;
phase += phaseInc; if (phase >= 1) phase -= 1; const src = 2 * phase - 1;
let s = 0; for (const f of filters) { const y = f.gain * src + f.a * f.y1 + f.b * f.y2; f.y2 = f.y1; f.y1 = y; s += y * f.amp; }
let env; if (i < attackSamples) env = i / attackSamples; else if (i >= sustainEnd) env = Math.max(0, (totalSamples - i) / releaseSamples); else env = 1;
out[dst] += s * env * level; }}
function renderPhrase(phrase) { const total = phrase.reduce((t, s) => t + (s.dur ?? 0.2), 0); const samples = Math.ceil((total + 0.3) * SAMPLE_RATE); const out = new Float32Array(samples); let t = 0; for (const syl of phrase) { renderSyllable(syl, out, t); t += syl.dur ?? 0.2; } return out;}
// ── notepat (.np) score parser ────────────────────────────────────────// Format (folk-songs paper §3): NOTE:syllable whitespace-separated.// Hyphens mark syllable continuation. Section headers are lowercase// lines without colons. Comments start with '#'.function parseNp(text) { const sections = { _order: [] }; let current = null; const lines = text.split("\n"); for (const raw of lines) { const line = raw.trim(); if (!line || line.startsWith("#")) continue; // Section header: lowercase, no colon, words/digits/spaces only if (!line.includes(":") && /^[a-z][a-z0-9 ]*$/.test(line)) { current = line; if (!sections[current]) { sections[current] = []; sections._order.push(current); } continue; } if (!current) { current = "default"; if (!sections[current]) { sections[current] = []; sections._order.push(current); } } const tokens = line.split(/\s+/).filter(Boolean); const lineSyllables = []; for (const tok of tokens) { const m = tok.match(/^([A-Ga-g][#b]?\d?):(.+)$/); if (!m) continue; const note = m[1].charAt(0).toUpperCase() + m[1].slice(1); lineSyllables.push({ pitch: note, syl: m[2] }); } if (lineSyllables.length) sections[current].push(lineSyllables); } return sections;}
// Vowel heuristic: pick the most plausible vowel for a syllable's// orthography. Order matters — check digraphs before single letters.function pickVowel(rawSyl) { const s = rawSyl.toLowerCase().replace(/^-+|-+$/g, "").replace(/[^a-z]/g, ""); if (!s) return "uh"; if (/ee|ea|ie$|y$/.test(s)) return "ee"; if (/oo|ui|ue/.test(s)) return "oo"; if (/oa|ow$|o[^aeiouy]?$/.test(s)) return "oh"; if (/i/.test(s)) return "ih"; if (/a/.test(s)) return "ah"; if (/e/.test(s)) return "eh"; if (/u/.test(s)) return "uh"; if (/o/.test(s)) return "oh"; return "uh";}
// Time a single line: split lineSec across N syllables, line-final gets 1.5x.function timeLine(line, lineSec, octave) { const n = line.length; if (!n) return []; const units = (n - 1) + 1.5; const unitSec = lineSec / units; return line.map((tok, i) => ({ syl: tok.syl, pitch: /\d/.test(tok.pitch) ? tok.pitch : tok.pitch + String(octave), vowel: pickVowel(tok.syl), dur: i === n - 1 ? unitSec * 1.5 : unitSec, }));}
// Build a phrase from a section by stretching each line to one bar.function phraseFromSection(section, barSec, octave, gapSec = 0) { const phrase = []; for (let li = 0; li < section.length; li++) { const timed = timeLine(section[li], barSec, octave); phrase.push(...timed); if (gapSec > 0 && li < section.length - 1) { phrase.push({ syl: "", pitch: "C0", vowel: "uh", dur: gapSec, level: 0 }); } } return phrase;}
// ── arg parse + main ──────────────────────────────────────────────────const argv = process.argv.slice(2);const flags = {};for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith("--")) { const key = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith("--")) { flags[key] = next; i++; } else flags[key] = true; }}
function expandHome(p) { if (!p || typeof p !== "string") return p; if (p === "~") return homedir(); if (p.startsWith("~/")) return resolve(homedir(), p.slice(2)); return p;}
const OUT_PATH = expandHome(flags.out) || `${ROOT}/out/vocal.mp3`;
let phrase;if (flags.score) { const scorePath = expandHome(flags.score); if (!existsSync(scorePath)) { console.error(`✗ --score file not found: ${scorePath}`); process.exit(1); } const sections = parseNp(readFileSync(scorePath, "utf8")); const sectionName = flags.section || "hook"; if (!sections[sectionName]) { console.error(`✗ section '${sectionName}' not in score. available: ${sections._order.join(", ")}`); process.exit(1); } const bpm = Number(flags.bpm) || 140; const barSec = (60 / bpm) * 4; const octave = Number(flags.octave) || 3; const gapSec = Number(flags.gap) || 0; phrase = phraseFromSection(sections[sectionName], barSec, octave, gapSec); console.log(`→ score · ${scorePath} · section=${sectionName} · ${sections[sectionName].length} lines @ ${bpm} BPM`);} else { phrase = SMOKE;}
console.log(`→ vocal · ${phrase.length} syllables · ${phrase.reduce((t,s)=>t+s.dur,0).toFixed(2)}s`);console.log(" " + phrase.filter(s => s.syl).map(s => `${s.syl}[${s.vowel}/${s.pitch}]`).join(" "));
const out = renderPhrase(phrase);
let peak = 0;for (let i = 0; i < out.length; i++) { const a = Math.abs(out[i]); if (a > peak) peak = a;}if (peak > 0) { const target = 0.7; const norm = target / peak; for (let i = 0; i < out.length; i++) out[i] *= norm;}
const outDir = dirname(OUT_PATH);mkdirSync(outDir, { recursive: true });const rawPath = `${outDir}/.vocal-smoke.f32.raw`;const buf = Buffer.alloc(out.length * 4);for (let i = 0; i < out.length; i++) buf.writeFloatLE(out[i], i * 4);writeFileSync(rawPath, buf);
const ff = spawnSync("ffmpeg", [ "-hide_banner", "-y", "-loglevel", "error", "-f", "f32le", "-ar", String(SAMPLE_RATE), "-ac", "1", "-i", rawPath, "-c:a", "libmp3lame", "-q:a", "3", OUT_PATH,], { stdio: "inherit" });if (ff.status !== 0) { console.error("✗ ffmpeg failed"); process.exit(1); }try { unlinkSync(rawPath); } catch {}console.log(`✓ ${OUT_PATH}`);
// Optional: mix over a trap bed (or any audio file).if (flags.over) { const overPath = expandHome(flags.over); if (!existsSync(overPath)) { console.error(`✗ --over file not found: ${overPath}`); process.exit(1); } const mixOut = OUT_PATH.replace(/\.mp3$/, "-with-bed.mp3"); console.log(`→ mixing vocal over ${overPath} → ${mixOut}`); const mix = spawnSync("ffmpeg", [ "-hide_banner", "-y", "-loglevel", "error", "-i", overPath, "-i", OUT_PATH, "-filter_complex", "[0:a]volume=0.55[bed];[1:a]volume=1.0[voc];[bed][voc]amix=inputs=2:duration=longest:dropout_transition=0[a]", "-map", "[a]", "-c:a", "libmp3lame", "-q:a", "3", mixOut, ], { stdio: "inherit" }); if (mix.status !== 0) { console.error("✗ ffmpeg mix failed"); process.exit(1); } console.log(`✓ ${mixOut}`);}