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:
39
server.js
39
server.js
@@ -9,6 +9,8 @@ const Database = require("better-sqlite3");
|
||||
const publicTrainingProfile = require("./server/public_training_profile");
|
||||
const { createBackupService } = require("./server/backup_service");
|
||||
const { createAuth, COOKIE_NAME: AUTH_COOKIE_NAME, TOKEN_MAX_AGE_MS } = require("./server/auth");
|
||||
const { createTts, resolvePiperBinary } = require("./server/tts");
|
||||
const { installAll: installPiperAll } = require("./server/tts_install");
|
||||
|
||||
const PORT = Number(process.env.PORT || 8081);
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
@@ -16,6 +18,11 @@ const DB_DIR = process.env.RC_TIMING_DATA_DIR ? path.resolve(process.env.RC_TIMI
|
||||
const DB_PATH = path.join(DB_DIR, "rc_timing.sqlite");
|
||||
const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json");
|
||||
const auth = createAuth({ dataDir: DB_DIR, allowedCidrs: process.env.RC_ADMIN_ALLOWED_CIDRS || "" });
|
||||
const tts = createTts({
|
||||
dataDir: DB_DIR,
|
||||
projectDir: __dirname,
|
||||
voiceDirs: [process.env.RC_PIPER_VOICES_DIR, path.join(DB_DIR, "piper-voices"), path.join(__dirname, "piper", "voices"), path.join(os.homedir(), ".local", "share", "piper-voices")].filter(Boolean),
|
||||
});
|
||||
const PID_FILE = process.env.RC_TIMING_PID_FILE || "";
|
||||
const SRC_DIR = path.join(__dirname, "src");
|
||||
const RACE_SOUND_DIR = path.join(__dirname, "rc-race-sound-pack");
|
||||
@@ -199,6 +206,38 @@ app.post("/api/auth/disable", (_req, res) => {
|
||||
res.json({ ok: true, enabled: false });
|
||||
});
|
||||
|
||||
app.get("/api/tts/status", (_req, res) => {
|
||||
res.json({ ...tts.status(), install: ttsInstall });
|
||||
});
|
||||
|
||||
// POST (not GET) so generating audio falls under the admin PIN like every other write.
|
||||
app.post("/api/tts/speech", async (req, res) => {
|
||||
try {
|
||||
const file = await tts.speech({ text: req.body?.text, language: req.body?.language, rate: req.body?.rate });
|
||||
res.type("audio/wav").sendFile(file);
|
||||
} catch (error) {
|
||||
res.status(error.code === "unavailable" ? 503 : error.code === "bad_request" ? 400 : 500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// One-time download of Piper + Swedish/English voices, run in the background; progress is
|
||||
// polled through /api/tts/status. Needs internet only while it runs.
|
||||
const ttsInstall = { running: false, log: [], error: "" };
|
||||
app.post("/api/tts/install", (_req, res) => {
|
||||
if (!ttsInstall.running) {
|
||||
Object.assign(ttsInstall, { running: true, log: [], error: "" });
|
||||
installPiperAll({ projectDir: __dirname, hasBinary: Boolean(resolvePiperBinary(process.env.RC_PIPER_BIN || "", __dirname)), log: (line) => ttsInstall.log.push(line) })
|
||||
.catch((error) => { ttsInstall.error = error.message; })
|
||||
.finally(() => { ttsInstall.running = false; });
|
||||
}
|
||||
res.status(202).json(ttsInstall);
|
||||
});
|
||||
|
||||
app.post("/api/tts/pregenerate", (req, res) => {
|
||||
tts.pregenerate(req.body?.items);
|
||||
res.status(202).json(tts.status().pregenerate);
|
||||
});
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({ ok: true, dbPath: DB_PATH });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user