Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263#!/usr/bin/env node// say.mjs — POST a lyric file to /api/say, cache the vocal stem.//// Mirrors `recap/bin/tts.mjs` but reads a plain lyric file (the// pop/big-pictures/<slug>.txt format) instead of an audience config:// - strips section headers (hook / verse 1 / verse 2 / outro)// - expands "hook" repeats inline (when a header appears alone it// repeats the named section's body)// - drops blank lines// - replaces em dashes with commas for cleaner TTS prosody//// Voice: jeffrey-pvc — same config as the 24h recap pipeline// (provider="jeffrey", voice="neutral:0"). Endpoint is the production// /api/say proxy, which costs real money via ElevenLabs — caches by// content-hash. Pass --force to bypass.//// Usage:// node bin/say.mjs ../big-pictures/plork.txt// node bin/say.mjs ../big-pictures/plork.txt --section hook// node bin/say.mjs ../big-pictures/plork.txt --out ../big-pictures/out/plork-vocal.mp3// node bin/say.mjs ../big-pictures/plork.txt --provider jeffrey --voice neutral:0
import { writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";import { resolve, dirname, basename } from "node:path";import { fileURLToPath } from "node:url";import { createHash } from "node:crypto";import { homedir } from "node:os";
const HERE = dirname(fileURLToPath(import.meta.url));const ROOT = resolve(HERE, "..");
const argv = process.argv.slice(2);const flags = {};const positional = [];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; } else positional.push(a);}
if (!positional[0]) { console.error("usage: node bin/say.mjs <lyric-file.txt> [--section hook] [--out path.mp3] [--force]"); process.exit(1);}
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 lyricPath = resolve(process.cwd(), positional[0]);if (!existsSync(lyricPath)) { console.error(`✗ lyric file not found: ${lyricPath}`); process.exit(1);}
const slug = basename(lyricPath).replace(/\.[^.]+$/, "");const SECTION = flags.section || null; // null = full trackconst PROVIDER = flags.provider || "jeffrey";const VOICE_ID = flags.voice || "neutral:0";const SPEED = Number(flags.speed ?? 1.0); // 0.7-1.2 for ElevenLabs jeffrey providerconst STYLE = flags.style !== undefined ? Number(flags.style) : null; // 0-1 style exaggerationconst STABILITY = flags.stability !== undefined ? Number(flags.stability) : null; // 0-1const SIMILARITY = flags.similarity !== undefined ? Number(flags.similarity) : null; // 0-1const FORCE = flags.force === true;// `--timestamps` opts into ElevenLabs `/with-timestamps` endpoint, which// returns per-character alignment alongside the audio. Lossless, exact,// free — the source-of-truth replacement for whisper STT word boundaries.// When set, writes `${OUT_PATH}.alignment.json` next to the mp3.const TIMESTAMPS = flags.timestamps === true;const OUT_PATH = expandHome(flags.out) || `${ROOT}/big-pictures/out/${slug}${SECTION ? `-${SECTION.replace(/\s+/g, "_")}` : ""}-vocal.mp3`;
// ── Parse lyric file ───────────────────────────────────────────────────// Sections are headers (lowercase, no colon, alphabetic). Body lines// follow until the next header or EOF. A header line with no body// before the next header is a repeat marker.function parseLyrics(text) { const HEADER_RE = /^(hook|verse \d+|outro|bridge|chorus|intro)$/i; const sections = {}; // name → first-seen body lines const order = []; // array of section names (with repeats) let current = null; let buffer = [];
const flush = () => { if (current === null) return; if (buffer.length === 0) { // Repeat marker — body comes from first occurrence. order.push(current); } else { if (!sections[current]) sections[current] = buffer.slice(); order.push(current); } buffer = []; };
for (const raw of text.split("\n")) { const line = raw.trim(); if (!line) continue; if (HEADER_RE.test(line)) { flush(); current = line.toLowerCase(); continue; } if (current === null) { // Lyrics before any header — treat as 'intro' current = "intro"; } buffer.push(line); } flush();
return { sections, order };}
const { sections, order } = parseLyrics(readFileSync(lyricPath, "utf8"));
if (SECTION && !sections[SECTION.toLowerCase()]) { console.error(`✗ section '${SECTION}' not found. available: ${Object.keys(sections).join(", ")}`); process.exit(1);}
// ── Build narration text ───────────────────────────────────────────────function cleanLine(line) { return line .replace(/—/g, ",") // em dash → comma for TTS pacing .replace(/–/g, ",") // en dash too .replace(/\s+/g, " ") .trim();}
let narration;if (SECTION) { const body = sections[SECTION.toLowerCase()]; narration = body.map(cleanLine).join("\n");} else { const lines = []; for (const name of order) { const body = sections[name]; if (!body) continue; for (const l of body) lines.push(cleanLine(l)); lines.push(""); // blank line between sections } narration = lines.filter((l, i, a) => !(l === "" && a[i + 1] === "")).join("\n").trim();}
if (!narration) { console.error(`✗ no narration content extracted from ${lyricPath}`); process.exit(1);}
// ── Build request + cache key ──────────────────────────────────────────const body = { from: narration, provider: PROVIDER, voice: VOICE_ID };if (SPEED !== 1.0) body.speed = Math.max(0.7, Math.min(1.2, SPEED));if (STYLE !== null && Number.isFinite(STYLE)) body.style = Math.max(0, Math.min(1, STYLE));if (STABILITY !== null && Number.isFinite(STABILITY)) body.stability = Math.max(0, Math.min(1, STABILITY));if (SIMILARITY !== null && Number.isFinite(SIMILARITY)) body.similarity = Math.max(0, Math.min(1, SIMILARITY));if (TIMESTAMPS) body.withTimestamps = true;const inputHash = createHash("sha256") .update(JSON.stringify(body)) .digest("hex").slice(0, 16);
const hashFile = `${OUT_PATH}.hash`;const ALIGNMENT_PATH = `${OUT_PATH}.alignment.json`;mkdirSync(dirname(OUT_PATH), { recursive: true });
if (!FORCE && existsSync(OUT_PATH) && existsSync(hashFile)) { const cached = readFileSync(hashFile, "utf8").trim(); // When --timestamps was requested we also need the alignment sidecar. // If the mp3 is cached but the alignment isn't, force a re-fetch. const alignmentReady = !TIMESTAMPS || existsSync(ALIGNMENT_PATH); if (cached === inputHash && alignmentReady) { const size = (readFileSync(OUT_PATH).length / 1024).toFixed(0); console.log(`✓ ${OUT_PATH} cached (${size} KB · hash ${inputHash}) — skipping /api/say`); if (TIMESTAMPS) console.log(` alignment: ${ALIGNMENT_PATH}`); process.exit(0); }}
console.log(`→ POST /api/say · ${narration.length} chars · ${PROVIDER}/${VOICE_ID}` + (SPEED !== 1.0 ? ` · speed=${SPEED}` : "") + (STYLE !== null ? ` · style=${STYLE}` : "") + (SECTION ? ` · section=${SECTION}` : "") + (TIMESTAMPS ? " · with-timestamps" : ""));console.log(` preview: ${narration.split("\n")[0].slice(0, 80)}…`);
// SAY_ENDPOINT lets the pipeline target a locally-run say endpoint// (e.g. pop/chillwave/bin/say-local.mjs) when the production host is// unreachable. Defaults to production.const SAY_URL = process.env.SAY_ENDPOINT || "https://aesthetic.computer/api/say";const res = await fetch(SAY_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), redirect: "follow",});
if (!res.ok) { console.error(`✗ /api/say returned ${res.status}: ${await res.text()}`); process.exit(1);}
if (TIMESTAMPS) { // Server returned JSON `{audio, alignment, normalizedAlignment}`. // Write mp3 to OUT_PATH (same as the non-timestamp path) and the // alignment sidecar to OUT_PATH.alignment.json. const ct = res.headers.get("content-type") || ""; if (!ct.includes("application/json")) { console.error(`✗ --timestamps was set but server returned ${ct}; was the server patched?`); process.exit(1); } const json = await res.json(); const buf = Buffer.from(json.audio, "base64"); writeFileSync(OUT_PATH, buf); writeFileSync(hashFile, inputHash + "\n");
// Build word-level boundaries from the per-character alignment. // Words are runs of consecutive non-space characters; word time is // [first-char start, last-char end]. const a = json.alignment || {}; const chars = a.characters || []; const starts = a.character_start_times_seconds || []; const ends = a.character_end_times_seconds || []; const words = []; let i = 0; while (i < chars.length) { if (/\s/.test(chars[i])) { i++; continue; } const wStart = starts[i]; let txt = ""; let lastEnd = ends[i]; while (i < chars.length && !/\s/.test(chars[i])) { txt += chars[i]; lastEnd = ends[i]; i++; } words.push({ text: txt, fromMs: Math.round(wStart * 1000), toMs: Math.round(lastEnd * 1000), }); }
const alignmentDoc = { source: "elevenlabs/with-timestamps", text: narration, voice: json.voice || PROVIDER, characters: chars, char_starts_s: starts, char_ends_s: ends, words, }; writeFileSync(ALIGNMENT_PATH, JSON.stringify(alignmentDoc, null, 2)); console.log(`✓ ${OUT_PATH} (${(buf.length / 1024).toFixed(0)} KB · hash ${inputHash})`); console.log(`✓ ${ALIGNMENT_PATH} (${chars.length} chars · ${words.length} words)`);} else { const buf = Buffer.from(await res.arrayBuffer()); writeFileSync(OUT_PATH, buf); writeFileSync(hashFile, inputHash + "\n"); console.log(`✓ ${OUT_PATH} (${(buf.length / 1024).toFixed(0)} KB · hash ${inputHash})`);}