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>
This commit is contained in:
81
tests/piper_speech.test.js
Normal file
81
tests/piper_speech.test.js
Normal file
@@ -0,0 +1,81 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user