// Downloads the standalone Piper release for this OS and the Swedish + English voices // into /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 are installed by default; `extra` voices // (~63 MB each) are only downloaded when picked under Settings -> Audio. Genders were // checked by measuring each voice's median pitch, not assumed from the name (nst 81 Hz, // ryan 152, alan 103, northern_english_male 110, joe 98, john 117, norman 103; lisa 184, // lessac 208, alma 185, alba 199, cori 202). `default` is the voice used until someone picks // another in Settings. `accent` is shown next to the name. 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: "sv_SE-alma-medium", path: "sv/sv_SE/alma/medium", language: "sv", gender: "female", label: "Alma", extra: true }, { name: "en_US-ryan-medium", path: "en/en_US/ryan/medium", language: "en", gender: "male", label: "Ryan", accent: "us", default: true }, { name: "en_US-lessac-medium", path: "en/en_US/lessac/medium", language: "en", gender: "female", label: "Lessac", accent: "us" }, { name: "en_GB-alan-medium", path: "en/en_GB/alan/medium", language: "en", gender: "male", label: "Alan", accent: "gb", extra: true }, { name: "en_GB-northern_english_male-medium", path: "en/en_GB/northern_english_male/medium", language: "en", gender: "male", label: "Northern English", accent: "gb_north", extra: true }, { name: "en_GB-cori-medium", path: "en/en_GB/cori/medium", language: "en", gender: "female", label: "Cori", accent: "gb", extra: true }, { name: "en_GB-alba-medium", path: "en/en_GB/alba/medium", language: "en", gender: "female", label: "Alba", accent: "scottish", extra: true }, { name: "en_US-joe-medium", path: "en/en_US/joe/medium", language: "en", gender: "male", label: "Joe", accent: "us", extra: true }, { name: "en_US-john-medium", path: "en/en_US/john/medium", language: "en", gender: "male", label: "John", accent: "us", extra: true }, { name: "en_US-norman-medium", path: "en/en_US/norman/medium", language: "en", gender: "male", label: "Norman", accent: "us", extra: true }, ]; 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"}`); } // Installs the standard voices, or exactly the named catalog voices (`names`). async function installVoices({ voicesDir, log, names = null }) { fs.mkdirSync(voicesDir, { recursive: true }); const wanted = names ? VOICES.filter((voice) => names.includes(voice.name)) : VOICES.filter((voice) => !voice.extra); for (const voice of wanted) { 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. // `voice` set: install Piper if missing plus only that extra voice. async function installAll({ projectDir, hasBinary, log = () => {}, voice = "" }) { if (voice && !VOICES.some((item) => item.name === voice)) throw new Error(`Unknown voice: ${voice}`); if (!hasBinary) await installPiperBinary({ projectDir, log }); else log("Piper already installed."); await installVoices({ voicesDir: path.join(projectDir, "piper", "voices"), log, names: voice ? [voice] : null }); log("Done."); } module.exports = { installAll, installVoices, installPiperBinary, VOICES, PIPER_ARCHIVES };