Files
Live_RC/tests/piper_speech.test.js
larssand e8a2799a92 feat(audio): add offline local neural voice via Piper (sv + en)
Announcements can use Piper, a free local neural TTS, instead of the browser's system
voice. No account, no paid service, and it keeps working with no internet at the track.

- npm run tts:install (or Settings -> Audio -> Install) downloads the standalone Piper
  release for Linux/Windows/macOS plus Swedish and English voices into piper/ once
- server/tts.js generates each line once, one at a time, and caches it as WAV under
  data/tts/; a new line takes ~0.3 s with the standalone build
- src/piper_speech.js plays it and falls back to the browser voice if Piper is missing,
  errors, or takes over 2.5 s, so no announcement is lost and timing is never held up
- "Pre-generate phrases and driver names" renders every fixed phrase and name in both
  languages before race day

Browser voice stays the default. Generation routes fall under the admin PIN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:28:15 +02:00

82 lines
3.3 KiB
JavaScript

import test, { mock } from "node:test";
import assert from "node:assert/strict";
// Minimal browser stand-ins: Audio plays instantly and reports onended.
class FakeAudio {
constructor(url) { this.url = url; FakeAudio.last = this; }
play() { setTimeout(() => this.onended?.(), 0); return Promise.resolve(); }
pause() { this.paused = true; }
}
// run.js loads every test file into one process, so the real globals must come back
// after each test here - the integration tests later use the real fetch.
const originalFetch = globalThis.fetch;
const originalAudio = globalThis.Audio;
function useStubs(fetchStub) {
globalThis.Audio = FakeAudio;
globalThis.fetch = fetchStub;
}
function restoreStubs() {
globalThis.fetch = originalFetch;
globalThis.Audio = originalAudio;
}
const { speakWithPiper, isPiperSpeaking, stopPiperSpeech } = await import("../src/piper_speech.js");
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
test("when Piper is unavailable the browser voice takes over", async () => {
useStubs(async () => new Response("{}", { status: 503 }));
try {
let fallbackCalls = 0;
assert.equal(speakWithPiper("Hej", { language: "sv", fallback: () => { fallbackCalls += 1; return true; } }), true);
await flush();
assert.equal(fallbackCalls, 1);
assert.equal(isPiperSpeaking(), false);
} finally { restoreStubs(); }
});
test("a slow generation is abandoned after the timeout and falls back", async () => {
mock.timers.enable({ apis: ["setTimeout"] });
try {
useStubs((_url, { signal }) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")))));
let fellBack = false;
speakWithPiper("Hej", { language: "sv", fallback: () => { fellBack = true; return true; } });
mock.timers.tick(2499);
await Promise.resolve();
assert.equal(fellBack, false, "still waiting just before the timeout");
mock.timers.tick(1);
for (let i = 0; i < 5; i += 1) await Promise.resolve();
assert.equal(fellBack, true);
} finally {
mock.timers.reset();
stopPiperSpeech();
restoreStubs();
}
});
test("a generated line plays and reports onEnd, and a non-interrupting line is refused while busy", async () => {
useStubs(async () => new Response(new Blob(["RIFF"], { type: "audio/wav" }), { status: 200 }));
try {
let ended = false;
assert.equal(speakWithPiper("Heat ett", { language: "sv", volume: 0.4, onEnd: () => { ended = true; }, fallback: () => true }), true);
assert.equal(speakWithPiper("Queued", { language: "sv", fallback: () => true }), false, "busy and not interrupting");
await flush();
assert.equal(FakeAudio.last.volume, 0.4);
assert.equal(ended, true);
assert.equal(isPiperSpeaking(), false);
} finally { restoreStubs(); }
});
test("an interrupting line stops the one in progress", async () => {
useStubs(() => new Promise(() => {}));
try {
let firstFellBack = false;
speakWithPiper("First", { language: "sv", fallback: () => { firstFellBack = true; return true; } });
assert.equal(speakWithPiper("Second", { language: "sv", interrupt: true, fallback: () => true }), true);
await flush();
assert.equal(firstFellBack, false, "a cancelled line must not fall back and talk over the new one");
} finally {
stopPiperSpeech();
restoreStubs();
}
});