121 lines
5.3 KiB
JavaScript
121 lines
5.3 KiB
JavaScript
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}`);
|