import test from "node:test"; import assert from "node:assert/strict"; import { createRequire } from "node:module"; import { chmodSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; const require = createRequire(import.meta.url); const { createTts, listVoices, pickVoice, resolvePiperBinary } = require("../server/tts.js"); // Stand-in for execFile: records each call and writes the requested output file, so the // module's queueing/caching can be tested without a real Piper install. function fakeRun(calls, { fail = false, delayMs = 5 } = {}) { let active = 0; return (binary, args, _options, callback) => { active += 1; calls.push({ binary, args, maxParallel: active }); let text = ""; const stdin = { end: (value) => { text = value; } }; setTimeout(() => { active -= 1; if (fail) return callback(new Error("piper crashed")); writeFileSync(args[args.indexOf("-f") + 1], `RIFF${text}`); return callback(null); }, delayMs); return { stdin }; }; } function setup() { const root = mkdtempSync(join(tmpdir(), "live-rc-tts-")); const voices = join(root, "voices"); mkdirSync(voices); for (const name of ["sv_SE-nst-medium", "sv_SE-lisa-low", "en_US-lessac-medium", "de_DE-thorsten-medium"]) { writeFileSync(join(voices, `${name}.onnx`), ""); writeFileSync(join(voices, `${name}.onnx.json`), "{}"); } writeFileSync(join(voices, "orphan-medium.onnx"), ""); // no .json config -> not a usable voice mkdirSync(join(root, "piper")); const binary = join(root, "piper", process.platform === "win32" ? "piper.exe" : "piper"); writeFileSync(binary, ""); chmodSync(binary, 0o755); return { root, voices, binary, cleanup: () => rmSync(root, { recursive: true, force: true }) }; } test("voices need a matching .onnx.json and are picked per language, preferring medium quality", () => { const env = setup(); try { const voices = listVoices([env.voices, join(env.root, "missing-dir")]); assert.deepEqual(voices.map((voice) => voice.name), ["de_DE-thorsten-medium", "en_US-lessac-medium", "sv_SE-lisa-low", "sv_SE-nst-medium"]); assert.equal(pickVoice(voices, "sv").name, "sv_SE-nst-medium"); assert.equal(pickVoice(voices, "en-US").name, "en_US-lessac-medium"); assert.equal(pickVoice(voices, "fi"), null); } finally { env.cleanup(); } }); test("the Piper copy installed into the project is preferred over one on PATH", () => { const env = setup(); try { assert.equal(resolvePiperBinary("", env.root), env.binary); assert.equal(resolvePiperBinary(env.binary, "/nonexistent"), env.binary, "an explicit path wins"); } finally { env.cleanup(); } }); test("each line is generated once, cached, deduplicated, and generated one at a time", async () => { const env = setup(); const calls = []; try { const tts = createTts({ dataDir: join(env.root, "data"), projectDir: env.root, voiceDirs: [env.voices], run: fakeRun(calls) }); const [a, b] = await Promise.all([ tts.speech({ text: "Heat ett startar.", language: "sv", rate: 1 }), tts.speech({ text: "Heat ett startar.", language: "sv", rate: 1 }), ]); assert.equal(a, b, "whitespace-normalized duplicates share one file"); assert.equal(calls.length, 1, "concurrent identical requests only run Piper once"); await tts.speech({ text: "Heat ett startar.", language: "sv", rate: 1 }); assert.equal(calls.length, 1, "a cached line never runs Piper again"); await Promise.all([tts.speech({ text: "One", language: "en" }), tts.speech({ text: "Two", language: "en" }), tts.speech({ text: "Tre", language: "sv", rate: 0.5 })]); assert.equal(calls.length, 4); assert.ok(calls.every((call) => call.maxParallel === 1), "generations never overlap"); assert.ok(calls[1].args.includes(join(env.voices, "en_US-lessac-medium.onnx")), "the English voice is used for English text"); assert.equal(calls[3].args[calls[3].args.indexOf("--length_scale") + 1], "1.6", "slow speech rate maps to a longer (clamped) length scale"); const status = tts.status(); assert.equal(status.available, true); assert.equal(status.cachedLines, 4); assert.equal(readdirSync(join(env.root, "data", "tts")).filter((file) => file.endsWith(".tmp")).length, 0, "no temp files are left behind"); } finally { env.cleanup(); } }); test("missing voice or failing Piper reports clear errors, and pre-generation counts failures", async () => { const env = setup(); const calls = []; try { const tts = createTts({ dataDir: join(env.root, "data"), projectDir: env.root, voiceDirs: [env.voices], run: fakeRun(calls, { fail: true }) }); await assert.rejects(tts.speech({ text: "Moi", language: "fi" }), (error) => error.code === "unavailable"); await assert.rejects(tts.speech({ text: " ", language: "sv" }), (error) => error.code === "bad_request"); await assert.rejects(tts.speech({ text: "Hej", language: "sv" }), /piper crashed/); const result = await tts.pregenerate([{ text: "Hej", language: "sv" }, { text: "Hi", language: "en" }, { text: "", language: "sv" }]); assert.deepEqual(result, { running: false, total: 2, done: 2, failed: 2 }); } finally { env.cleanup(); } });