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 {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 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 build({ entryPoints: [resolve(root, 'piper-runtime.js')], outfile: join(publicRoot, 'piper-runtime.js'), bundle: true, format: 'iife', platform: 'browser', target: ['es2022'], external: ['fs', 'path'], sourcemap: false, legalComments: 'none', }); await build({ entryPoints: [resolve(root, 'piper-worker.js')], outfile: join(assetRoot, 'piper-worker.js'), bundle: true, format: 'iife', platform: 'browser', target: ['es2022'], external: ['fs', 'path'], sourcemap: false, 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.'); } console.log(`Piper browser assets prepared at ${assetRoot}`);