Serve Piper speech assets from dedicated static service
Build and publish frontend / publish (push) Successful in 49s

This commit is contained in:
Ein Anderssono
2026-10-16 07:32:34 +02:00
parent 1458fa8382
commit ed0a8b90a6
12 changed files with 31 additions and 140 deletions
+14 -88
View File
@@ -1,86 +1,13 @@
import {build} from 'esbuild';
import {createHash} from 'node:crypto';
import {createWriteStream} from 'node:fs';
import {cp, mkdir, readdir, rm, stat, rename, unlink} from 'node:fs/promises';
import {mkdir, rm, readFile} from 'node:fs/promises';
import {join, resolve} from 'node:path';
import {Readable, Transform} from 'node:stream';
import {pipeline} from 'node:stream/promises';
const root = process.cwd();
const publicRoot = join(root, 'public');
const assetRoot = join(publicRoot, 'tts', 'piper-wasm-v1');
const onnxSource = join(root, 'node_modules', 'onnxruntime-web', 'dist');
const piperSource = join(root, 'node_modules', '@diffusionstudio', 'piper-wasm', 'build');
const piperWasm = join(piperSource, 'piper_phonemize.wasm');
const piperData = join(piperSource, 'piper_phonemize.data');
const workerRoot = join(publicRoot, 'tts', 'piper-wasm-v1');
const voiceRevision = 'c10ece1aade47bb51c153c893d14e5bf8e5b7117';
const voiceRepository = 'https://huggingface.co/rhasspy/piper-voices/resolve';
const voiceFiles = [
{path: 'sv/sv_SE/alma/medium/sv_SE-alma-medium.onnx', size: 63434611, sha256: 'f75455eb6afeb1db9e2905d5bd7e4115afcd3710e0d399b6a40981766d32fedc'},
{path: 'sv/sv_SE/alma/medium/sv_SE-alma-medium.onnx.json', size: 6059, gitBlobSha1: '7eaf3ef20af4a74e9cee9d29b98de3d9fcec0bdc'},
{path: 'sv/sv_SE/lisa/medium/sv_SE-lisa-medium.onnx', size: 63511038, sha256: '94cae912b31d6e9140d3f5160f1815951588600c7a9e43d539ba1e81a110d131'},
{path: 'sv/sv_SE/lisa/medium/sv_SE-lisa-medium.onnx.json', size: 7239, gitBlobSha1: 'b2b5fb0f8af2aae4ddfb81eaa7709c6b8faa412b'},
{path: 'sv/sv_SE/nst/medium/sv_SE-nst-medium.onnx', size: 63104526, sha256: 'df011f56825a59dd1efc080c38a65a1ef70407e60f63050e9246f43a3d7e471e'},
{path: 'sv/sv_SE/nst/medium/sv_SE-nst-medium.onnx.json', size: 4157, gitBlobSha1: '9766b9769436046a610a505fda925032e12a66b3'},
];
async function downloadVoiceFile(file) {
const destination = join(assetRoot, 'voices', file.path);
const temporary = `${destination}.part`;
await mkdir(join(destination, '..'), {recursive: true});
const response = await fetch(`${voiceRepository}/${voiceRevision}/${file.path}`);
if (!response.ok || !response.body) {
throw new Error(`Unable to download Piper voice asset ${file.path}: HTTP ${response.status}`);
}
const sha256 = createHash('sha256');
const sha1 = createHash('sha1').update(Buffer.from(`blob ${file.size}\0`));
let size = 0;
const verifyAndPassThrough = new Transform({
transform(chunk, _encoding, callback) {
size += chunk.length;
sha256.update(chunk);
sha1.update(chunk);
callback(null, chunk);
},
});
try {
await pipeline(Readable.fromWeb(response.body), verifyAndPassThrough, createWriteStream(temporary));
const actualSha1 = sha1.digest('hex');
if (size !== file.size || (file.sha256 && sha256.digest('hex') !== file.sha256) ||
(file.gitBlobSha1 && actualSha1 !== file.gitBlobSha1)) {
throw new Error(`Piper voice asset integrity check failed for ${file.path} (${size} bytes)`);
}
await rename(temporary, destination);
} catch (error) {
await unlink(temporary).catch(() => {});
throw error;
}
}
await rm(assetRoot, {recursive: true, force: true});
await mkdir(join(assetRoot, 'onnx'), {recursive: true});
await mkdir(join(assetRoot, 'piper'), {recursive: true});
for (const file of voiceFiles) await downloadVoiceFile(file);
await cp(join(root, 'PIPER_VOICE_NOTICES.txt'), join(assetRoot, 'voices', 'ATTRIBUTION.txt'));
const onnxRuntimeFiles = (await readdir(onnxSource))
.filter(name => name.endsWith('.wasm') || name.endsWith('.mjs'));
const requiredOnnxRuntimeFiles = [
'ort-wasm-simd-threaded.wasm',
'ort-wasm-simd-threaded.mjs',
];
for (const file of requiredOnnxRuntimeFiles) {
if (!onnxRuntimeFiles.includes(file)) throw new Error(`Missing ONNX Runtime asset: ${file}`);
}
for (const file of onnxRuntimeFiles) {
await cp(join(onnxSource, file), join(assetRoot, 'onnx', file));
}
for (const file of [piperWasm, piperData]) {
const fileStat = await stat(file).catch(() => null);
if (!fileStat?.isFile() || fileStat.size === 0) throw new Error(`Missing Piper WebAssembly asset: ${file}`);
await cp(file, join(assetRoot, 'piper', file.split('/').at(-1)));
}
await rm(workerRoot, {recursive: true, force: true});
await mkdir(workerRoot, {recursive: true});
await build({
entryPoints: [resolve(root, 'piper-runtime.js')],
@@ -96,7 +23,7 @@ await build({
await build({
entryPoints: [resolve(root, 'piper-worker.js')],
outfile: join(assetRoot, 'piper-worker.js'),
outfile: join(workerRoot, 'piper-worker.js'),
bundle: true,
format: 'iife',
platform: 'browser',
@@ -106,15 +33,14 @@ await build({
legalComments: 'none',
});
for (const file of voiceFiles) {
const downloaded = join(assetRoot, 'voices', file.path);
if ((await stat(downloaded)).size !== file.size) throw new Error(`Missing Piper voice asset: ${downloaded}`);
}
const workerBundle = await (await import('node:fs/promises')).readFile(join(assetRoot, 'piper-worker.js'), 'utf8');
if (!workerBundle.includes('https://huggingface.co/diffusionstudio/piper-voices/resolve/main/') ||
!workerBundle.includes('/tts/piper-wasm-v1/voices/') ||
!workerBundle.includes('/tts/piper-wasm-v1/onnx/')) {
throw new Error('The Piper worker is missing its same-origin model download route.');
const workerBundle = await readFile(join(workerRoot, 'piper-worker.js'), 'utf8');
for (const route of [
'https://huggingface.co/diffusionstudio/piper-voices/resolve/main/',
'/static/piper-wasm-v1',
'/static/piper-wasm-v1/voices/',
'/static/piper-wasm-v1/onnx/',
]) {
if (!workerBundle.includes(route)) throw new Error(`The Piper worker is missing static route ${route}`);
}
console.log(`Piper browser assets prepared at ${assetRoot}`);
console.log(`Piper worker bundle prepared at ${workerRoot}`);
+3 -4
View File
@@ -48,11 +48,10 @@ grep -q 'worker.js?v=' piper-runtime.js
grep -q 'worker.onmessageerror' piper-runtime.js
grep -q 'localPiperModelUrl' piper-worker.js
grep -q 'createOnnxWasmPaths(self.location.origin)' piper-worker.js
grep -q 'ort-wasm-simd-threaded.mjs' scripts/build-piper.mjs
grep -q 'ort-wasm-simd-threaded.wasm' scripts/build-piper.mjs
grep -q '/static/piper-wasm-v1/onnx/' piper-onnx-config.mjs
grep -q 'PIPER_MODEL_ORIGIN' piper-model-routing.mjs
grep -q '/tts/piper-wasm-v1/voices/' piper-model-routing.mjs
grep -q 'same-origin.*voice' README.md
grep -q '/static/piper-wasm-v1/voices/' piper-model-routing.mjs
grep -q 'ai-spel-static' README.md
grep -q 'ATTRIBUTION.txt' index.html
grep -q "SpeechRecognition || win.webkitSpeechRecognition" voice.js
grep -q "submitMessage(text, 'voice')" index.html