#!/usr/bin/env node /** * Apply a voiceover carve to a composition, from the command line. * * The carve is an analysis: it listens to a voice track, finds the bands it * occupies, and writes a chain of dips into the music bed plus a level match. In * Studio a panel runs it. This is the same analysis for an agent that has no * panel to click — identical functions from `@hyperframes/core`, identical * output, so a composition carved here and one carved in Studio are the same * three attributes. * * node carve.mjs --comp index.html * node carve.mjs --comp index.html --bed music-bed --voice narration \ * --voice interview-guest --strength 0.45 * * With no --bed/--voice it works out the tracks itself: the bed, and every voice * playing over it. `--voice` may be repeated to name them instead. Every named * voice is analysed together, so a bed running under a narrator and an answer makes * room for both. * * Needs `ffmpeg` on PATH (to decode the audio) and `@hyperframes/core` resolvable * from the composition's project (`npm i -D @hyperframes/core`) — the CLI bundles * core inline rather than shipping it as a package, so it cannot be borrowed from * there. */ import { execFileSync } from "node:child_process"; import { createRequire } from "node:module"; import { readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; /** Sample rate the analysis runs at. Matches Studio's own decode rate, so the * bands and envelopes come out the same either way. */ const SAMPLE_RATE = 48000; const usage = `carve.mjs --comp [--bed ] [--voice ...] [--strength 0..1] [--dry-run] [--core ] --bed id of the music track that gets carved (detected if omitted) --voice id of a voice to make room for; repeatable (detected if omitted) --strength how hard to carve, 0..1 (default 0.25) --dry-run report what it would write, touch nothing --core directory to resolve @hyperframes/core from (default: the comp's)`; function parseArgs(argv) { const args = { strength: 0.25, dryRun: false, voices: [] }; for (let i = 0; i < argv.length; i += 1) { const flag = argv[i]; const next = () => { const value = argv[i + 1]; if (value === undefined) fail(`${flag} needs a value`); i += 1; return value; }; if (flag === "--comp") args.comp = next(); else if (flag === "--bed") args.bed = next(); else if (flag === "--voice") args.voices.push(next()); else if (flag === "--strength") args.strength = Number(next()); else if (flag === "--core") args.core = next(); else if (flag === "--dry-run") args.dryRun = true; else if (flag === "-h" || flag === "--help") fail(usage, 0); else fail(`unknown flag: ${flag}\n\n${usage}`); } if (!args.comp) fail(`--comp is required\n\n${usage}`); if (!Number.isFinite(args.strength) || args.strength < 0 || args.strength > 1) { fail("--strength must be a number from 0 to 1"); } return args; } function fail(message, code = 1) { process.stderr.write(`${message}\n`); process.exit(code); } /** * Load the carve analysis out of `@hyperframes/core`. * * Resolved from the project rather than from this script, which lives wherever * the skill was installed — a sibling of the composition is what has the * dependency. */ async function loadCore(fromDir) { const require = createRequire(pathToFileURL(resolve(fromDir, "package.json"))); const load = (subpath) => { const file = require.resolve(`@hyperframes/core/${subpath}`); return import(pathToFileURL(file).href); }; try { return { carve: await load("audio-carve"), fx: await load("audio-fx"), }; } catch (error) { fail( `cannot resolve @hyperframes/core from ${fromDir}\n` + ` install or update it: npm i -D @hyperframes/core\n` + ` (the audio-carve export needs a version that ships the carve analysis)\n` + ` or point at one: --core \n` + ` (${error.message})`, ); } } /** Mono float PCM for one media file, via ffmpeg. */ function decode(path) { let raw; try { raw = execFileSync( "ffmpeg", [ "-v", "error", "-i", path, "-vn", "-ac", "1", "-ar", String(SAMPLE_RATE), "-f", "f32le", "-", ], { maxBuffer: 1 << 30 }, ); } catch (error) { fail(`could not decode ${path}\n ${error.message.split("\n")[0]}`); } if (raw.length === 0) fail(`no audio in ${path}`); return new Float32Array(raw.buffer, raw.byteOffset, raw.length / 4); } const attrOf = (tag, name) => tag.match(new RegExp(`\\s${name}="([^"]*)"`, "i"))?.[1] ?? null; const unescapeAttr = (value) => value .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, "&"); const escapeAttr = (value) => value.replace(/&/g, "&").replace(/"/g, """); /** Every media element with a src, as {id, tag, kind}. */ function mediaElements(html) { const found = []; for (const match of html.matchAll(/<(audio|video)\b[^>]*>/gi)) { const tag = match[0]; // `\sid=` and not `id=`: `data-hf-id` would match first. const id = tag.match(/\sid="([^"]+)"/)?.[1]; if (id && attrOf(tag, "src")) found.push({ id, tag, kind: match[1].toLowerCase() }); } return found; } /** * Work out which track is the bed and which tracks are its voices. * * Names first, because they are what the author already told us and the answer is * explainable: a track whose id or filename looks like music is the bed, ones that * look like speech are voices, SFX-shaped names are neither. `classifyAudioName` * comes from core so Studio's own picker and this cannot disagree. * * EVERY voice over the bed, not one of them. A bed usually runs under a whole * sequence, and they are analysed together — so there is nothing to disambiguate, * which is why this no longer refuses when several tracks look like speech. * * Only tracks that actually play while the bed does: one somewhere else on the * timeline cannot mask it. It still refuses when it cannot find a bed at all, or * finds no voice to make room for. */ function detectTracks(html, given, classify, overlaps) { const all = mediaElements(html); const kindOf = (el) => classify(el.id, unescapeAttr(attrOf(el.tag, "src") ?? "")); const spanOf = (el) => { const raw = attrOf(el.tag, "data-duration"); const n = raw === null ? Number.NaN : Number(raw); return { start: startOf(el.tag), duration: Number.isFinite(n) ? n : null, }; }; const pick = (id, what) => { const found = all.find((el) => el.id === id); if (!found) fail(`no