Docs/Skills and code/skill video-script-production

.claude/skills/video-script-production/scripts/tts-trim-lead-in.js

Code146 lines5.8 KBSkill: video-script-production

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.