Docs/Skills and code/skill video-script-production
.claude/skills/video-script-production/scripts/tts-trim-lead-in.js
What the file says about itself
Cuts the spoken lead-in off a recording, using word times from script-audio-align's alignment.json. Usage: node tts-trim-lead-in.js --wav <raw.wav> --alignment <alignment.json> --out <final.wav> [--min-gap 0.05] The cut is the midpoint of…
#!/usr/bin/env node
// Cuts the spoken lead-in off a recording, using word times from script-audio-align's alignment.json.
// Usage: node tts-trim-lead-in.js --wav <raw.wav> --alignment <alignment.json> --out <final.wav> [--min-gap 0.05]
// The cut is the midpoint of the silence between the last word of paragraph 1 (the lead-in) and the first word of paragraph 2.
// Both words must be "matched" in the transcript (not "interpolated"), or the script exits 1 because the cut would be a guess.
'use strict';
const fs = require('node:fs');
const path = require('node:path');
function fail(msg) {
console.error(`tts-trim-lead-in: ${msg}`);
process.exit(1);
}
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith('--')) fail(`unexpected argument: ${a}`);
const val = argv[i + 1];
if (val === undefined || val.startsWith('--')) fail(`missing value for ${a}`);
out[a.slice(2)] = val;
i++;
}
return out;
}
// Walks the RIFF chunks; returns the fmt fields and the data chunk location.
function parseWav(buf) {
if (buf.length < 12 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') {
fail('input is not a RIFF/WAVE file');
}
let fmt = null;
let data = null;
let off = 12;
while (off + 8 <= buf.length) {
const id = buf.toString('ascii', off, off + 4);
const size = buf.readUInt32LE(off + 4);
const body = off + 8;
if (id === 'fmt ') {
fmt = {
audioFormat: buf.readUInt16LE(body),
channels: buf.readUInt16LE(body + 2),
sampleRate: buf.readUInt32LE(body + 4),
blockAlign: buf.readUInt16LE(body + 12),
bits: buf.readUInt16LE(body + 14),
};
} else if (id === 'data') {
data = { start: body, size: Math.min(size, buf.length - body) };
}
off = body + size + (size % 2);
}
if (!fmt) fail('no fmt chunk in WAV');
if (!data) fail('no data chunk in WAV');
if (fmt.audioFormat !== 1) fail(`WAV format ${fmt.audioFormat} is not PCM (1)`);
if (fmt.bits !== 16) fail(`WAV bit depth ${fmt.bits} is not 16`);
return { fmt, data };
}
function main() {
const args = parseArgs(process.argv.slice(2));
for (const req of ['wav', 'alignment', 'out']) {
if (!args[req]) fail('usage: node tts-trim-lead-in.js --wav <raw.wav> --alignment <alignment.json> --out <final.wav> [--min-gap 0.05]');
}
const minGap = args['min-gap'] !== undefined ? parseFloat(args['min-gap']) : 0.05;
if (!Number.isFinite(minGap) || minGap < 0) fail('--min-gap must be a non-negative number');
const align = JSON.parse(fs.readFileSync(path.resolve(args.alignment), 'utf8'));
const words = Array.isArray(align.words) ? align.words : [];
const paras = Array.isArray(align.paragraphs) ? align.paragraphs : [];
const p1 = paras.find((p) => p.para === 1);
if (!p1) fail('alignment has no paragraph 1 (the lead-in)');
const p1Words = words.filter((w) => w.para === 1);
const p2Words = words.filter((w) => w.para === 2);
if (p1Words.length === 0) fail('paragraph 1 has no words in the alignment');
if (p2Words.length === 0) fail('paragraph 2 has no words in the alignment');
const lastLead = p1Words[p1Words.length - 1];
const firstScript = p2Words[0];
const firstLead = p1Words[0];
if (lastLead.source !== 'matched') {
fail(`last word of paragraph 1 ("${lastLead.text}") has source "${lastLead.source}", not "matched"; the cut would be a guess`);
}
if (firstScript.source !== 'matched') {
fail(`first word of paragraph 2 ("${firstScript.text}") has source "${firstScript.source}", not "matched"; the cut would be a guess`);
}
const gap = firstScript.start - lastLead.end;
if (gap < minGap) {
fail(`silence between the lead-in and the first script word is ${gap.toFixed(3)} s, below --min-gap ${minGap} s`);
}
const cut = lastLead.end + gap / 2;
const leadEnd = p1.end !== undefined ? p1.end : lastLead.end;
const leadDuration = leadEnd - firstLead.start;
const wavPath = path.resolve(args.wav);
const buf = fs.readFileSync(wavPath);
const { fmt, data } = parseWav(buf);
const frameBytes = fmt.blockAlign;
const totalFrames = Math.floor(data.size / frameBytes);
const startFrame = Math.floor(cut * fmt.sampleRate);
if (startFrame >= totalFrames) fail('cut point is past the end of the recording');
const sliceBytes = (totalFrames - startFrame) * frameBytes;
const pcm = Buffer.from(buf.subarray(data.start + startFrame * frameBytes, data.start + startFrame * frameBytes + sliceBytes));
// 10 ms linear fade-in on every channel, to avoid a click at the cut.
const fadeFrames = Math.min(totalFrames - startFrame, Math.round(0.01 * fmt.sampleRate));
for (let f = 0; f < fadeFrames; f++) {
const gain = f / fadeFrames;
for (let c = 0; c < fmt.channels; c++) {
const pos = f * frameBytes + c * 2;
pcm.writeInt16LE(Math.round(pcm.readInt16LE(pos) * gain), pos);
}
}
const header = Buffer.alloc(44);
const byteRate = fmt.sampleRate * frameBytes;
header.write('RIFF', 0, 'ascii');
header.writeUInt32LE(36 + pcm.length, 4);
header.write('WAVE', 8, 'ascii');
header.write('fmt ', 12, 'ascii');
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(fmt.channels, 22);
header.writeUInt32LE(fmt.sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(frameBytes, 32);
header.writeUInt16LE(16, 34);
header.write('data', 36, 'ascii');
header.writeUInt32LE(pcm.length, 40);
const outPath = path.resolve(args.out);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, Buffer.concat([header, pcm]));
const outDuration = pcm.length / byteRate;
console.log(`cut: ${cut.toFixed(3)} s`);
console.log(`lead-in duration: ${leadDuration.toFixed(3)} s`);
console.log(`output duration: ${outDuration.toFixed(3)} s`);
console.log(`output: ${outPath}`);
}
main();
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.