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:
@@ -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) };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user