The installer now fetches one male and one female Piper voice per language (Swedish NST/Lisa, English Ryan/Lessac), with genders confirmed by measuring each voice's pitch rather than guessed from the name. Settings -> Audio gets a voice selector per language with its own Test button that always plays the Piper voice. Male is preselected for both. The integration test now runs its server with an empty home directory, so voices installed on the developer's own machine can't leak into it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
71 lines
3.5 KiB
JavaScript
71 lines
3.5 KiB
JavaScript
// Downloads the standalone Piper release for this OS and the Swedish + English voices
|
|
// into <project>/piper/. Needs internet once; everything runs offline afterwards.
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const { Readable } = require("stream");
|
|
const { pipeline } = require("stream/promises");
|
|
const { spawnSync } = require("child_process");
|
|
|
|
const PIPER_RELEASE_BASE = "https://github.com/rhasspy/piper/releases/download/2023.11.14-2";
|
|
const PIPER_ARCHIVES = {
|
|
"linux-x64": "piper_linux_x86_64.tar.gz",
|
|
"linux-arm64": "piper_linux_aarch64.tar.gz",
|
|
"win32-x64": "piper_windows_amd64.zip",
|
|
"darwin-x64": "piper_macos_x64.tar.gz",
|
|
"darwin-arm64": "piper_macos_aarch64.tar.gz",
|
|
};
|
|
const VOICE_BASE = "https://huggingface.co/rhasspy/piper-voices/resolve/main";
|
|
// One male and one female voice per language. Genders were checked by measuring each
|
|
// voice's median pitch (nst 81 Hz, ryan 152 Hz; lisa 184 Hz, lessac 208 Hz), not assumed
|
|
// from the name. `default` is the voice used until someone picks another in Settings.
|
|
const VOICES = [
|
|
{ name: "sv_SE-nst-medium", path: "sv/sv_SE/nst/medium", language: "sv", gender: "male", label: "NST", default: true },
|
|
{ name: "sv_SE-lisa-medium", path: "sv/sv_SE/lisa/medium", language: "sv", gender: "female", label: "Lisa" },
|
|
{ name: "en_US-ryan-medium", path: "en/en_US/ryan/medium", language: "en", gender: "male", label: "Ryan", default: true },
|
|
{ name: "en_US-lessac-medium", path: "en/en_US/lessac/medium", language: "en", gender: "female", label: "Lessac" },
|
|
];
|
|
|
|
async function download(url, target) {
|
|
const response = await fetch(url, { redirect: "follow" });
|
|
if (!response.ok || !response.body) throw new Error(`Download failed (${response.status}): ${url}`);
|
|
const partial = `${target}.part`;
|
|
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(partial));
|
|
fs.renameSync(partial, target);
|
|
}
|
|
|
|
async function installPiperBinary({ projectDir, log }) {
|
|
const archive = PIPER_ARCHIVES[`${process.platform}-${process.arch}`];
|
|
if (!archive) throw new Error(`No standalone Piper release for ${process.platform}-${process.arch}`);
|
|
const tmpFile = path.join(os.tmpdir(), `racecore-${archive}`);
|
|
log(`Downloading Piper (${archive})...`);
|
|
await download(`${PIPER_RELEASE_BASE}/${archive}`, tmpFile);
|
|
log("Unpacking Piper...");
|
|
// tar ships with Linux, macOS and Windows 10+, and the Windows one also unpacks .zip.
|
|
const result = spawnSync("tar", ["-xf", tmpFile, "-C", projectDir], { encoding: "utf8", windowsHide: true });
|
|
fs.rmSync(tmpFile, { force: true });
|
|
if (result.status !== 0) throw new Error(`Unpacking failed: ${result.stderr || result.error?.message || "tar error"}`);
|
|
}
|
|
|
|
async function installVoices({ voicesDir, log }) {
|
|
fs.mkdirSync(voicesDir, { recursive: true });
|
|
for (const voice of VOICES) {
|
|
for (const file of [`${voice.name}.onnx.json`, `${voice.name}.onnx`]) {
|
|
const target = path.join(voicesDir, file);
|
|
if (fs.existsSync(target)) continue;
|
|
log(`Downloading voice ${file}...`);
|
|
await download(`${VOICE_BASE}/${voice.path}/${file}`, target);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Skips anything already present, so re-running is cheap and safe.
|
|
async function installAll({ projectDir, hasBinary, log = () => {} }) {
|
|
if (!hasBinary) await installPiperBinary({ projectDir, log });
|
|
else log("Piper already installed.");
|
|
await installVoices({ voicesDir: path.join(projectDir, "piper", "voices"), log });
|
|
log("Done.");
|
|
}
|
|
|
|
module.exports = { installAll, installVoices, installPiperBinary, VOICES, PIPER_ARCHIVES };
|