Switch the video guide narrator from espeak-ng to Piper (neural TTS)

espeak-ng's formant synthesis was reported as barely intelligible. Piper is a small
local neural TTS with the same offline, no-cloud-service properties but far more
natural speech; its default WAV output format already matched what the sync pipeline
assumed, so only the synthesis call itself needed to change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-11 17:47:31 +02:00
parent 788319d098
commit a17054d8d0
5 changed files with 34 additions and 14 deletions

View File

@@ -4,23 +4,28 @@
// ever touches a real installation's data.
//
// Each clip is a list of steps ({ text, action }). For every step, the narration line's
// audio is synthesized up front (espeak-ng) so its real duration is known, the on-screen
// action runs, and the recording pauses for exactly that duration before moving to the
// next line - the actual wall-clock time each action took is recorded too, so the final
// narration track is built from real measured offsets (via ffmpeg's adelay/amix), not an
// idealized schedule that would drift out of sync with page-load/network latency.
// audio is synthesized up front (Piper, a small local neural TTS - far more intelligible
// than espeak-ng's formant synthesis, which is what this used before) so its real duration
// is known, the on-screen action runs, and the recording pauses for exactly that duration
// before moving to the next line - the actual wall-clock time each action took is recorded
// too, so the final narration track is built from real measured offsets (via ffmpeg's
// adelay/amix), not an idealized schedule that would drift out of sync with page-load/
// network latency.
//
// Usage: node scripts/record-video-guides.mjs [clip-name ...]
// With no arguments, records every clip below. Pass one or more names (e.g.
// "settings-tabs") to record only those. Output lands in video-guides/<name>.mkv
// (gitignored - these are sizeable binaries, not something to commit by default).
//
// Requires ffmpeg and espeak-ng on PATH.
// Requires ffmpeg and Piper (`pipx install piper-tts`) on PATH, plus a downloaded voice
// model - by default ~/.local/share/piper-voices/en_US-lessac-medium.onnx (get it from
// https://huggingface.co/rhasspy/piper-voices, the .onnx and its matching .onnx.json).
// Override the model with the PIPER_MODEL env var.
import { spawn, execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { homedir, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -28,10 +33,10 @@ const projectDir = dirname(dirname(fileURLToPath(import.meta.url)));
const outputDir = join(projectDir, "video-guides");
const chrome = [process.env.CHROME_PATH, "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"].find((p) => p && existsSync(p));
if (!chrome) throw new Error("Chrome/Chromium not found. Set CHROME_PATH to the browser executable.");
try { execFileSync("espeak-ng", ["--version"], { stdio: "ignore" }); } catch { throw new Error("espeak-ng not found on PATH (used for narration audio)."); }
try { execFileSync("piper", ["--help"], { stdio: "ignore" }); } catch { throw new Error("piper not found on PATH (used for narration audio). Install with: pipx install piper-tts"); }
const PIPER_MODEL = process.env.PIPER_MODEL || join(homedir(), ".local", "share", "piper-voices", "en_US-lessac-medium.onnx");
if (!existsSync(PIPER_MODEL)) throw new Error(`Piper voice model not found at ${PIPER_MODEL}. Download it from https://huggingface.co/rhasspy/piper-voices (both the .onnx and .onnx.json files), or set PIPER_MODEL.`);
const NARRATION_VOICE = "en-us";
const NARRATION_RATE = 160;
const LEAD_MS = 600; // silence before the first line, so the opening frame settles first
const GAP_MS = 450; // silence between narration lines
const TAIL_MS = 1400; // hold on the final frame after the last line finishes
@@ -63,7 +68,7 @@ function wavDurationMs(wavPath) {
function synthesizeNarration(steps, narrationDir) {
return steps.map((step, index) => {
const wavPath = join(narrationDir, `line_${String(index).padStart(2, "0")}.wav`);
execFileSync("espeak-ng", ["-v", NARRATION_VOICE, "-s", String(NARRATION_RATE), "-w", wavPath, step.text]);
execFileSync("piper", ["-m", PIPER_MODEL, "-f", wavPath], { input: step.text, stdio: ["pipe", "ignore", "ignore"] });
return { ...step, wavPath, durationMs: wavDurationMs(wavPath) };
});
}