Files
church-translate/app/voice-install.js
T

79 lines
3.5 KiB
JavaScript
Raw Permalink Normal View History

/*
* voice-install.js — download and unpack ONE voice bundle, in a worker thread.
*
* This work used to run on the voice process's own event loop, from inside
* engineFor(), the moment a sentence arrived for a language whose voice was
* not installed. MEASURED on a campus Mac, 2026-10-15 15:11: a listener tapped
* Chinese, three parallel downloads and unpacks of the 121 MB zh model ran at
* once on the thread that has to answer every sentence, every other language
* stalled for about a minute, 26 sentences were dropped, and the catch-up
* pushed the Spanish voices to their +16% rush cap — reported from the pews
* as the voice "changing". bzip2 in pure JS is CPU work; it has no business on
* that thread.
*
* So the whole fetch-check-unpack now happens here, off the loop, and
* tts-service.js de-duplicates it per bundle. The archive is unpacked into a
* staging folder and renamed into place only when complete, so a half-written
* bundle can never pass isInstalled() and be handed to sherpa.
*/
'use strict';
const { parentPort, workerData } = require('worker_threads');
const fs = require('fs');
const path = require('path');
const { url, voiceDir, bundle } = workerData;
(async () => {
fs.mkdirSync(voiceDir, { recursive: true });
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const body = Buffer.from(await res.arrayBuffer());
/* A proxy or a captive portal returns an HTML error page with HTTP 200;
the bzip2 magic is the cheapest way to notice before unpacking it. */
if (body.length < 1024 || body.toString('latin1', 0, 3) !== 'BZh') {
throw new Error('that download was not a voice bundle');
}
parentPort.postMessage({ status: 'installing' });
const bunzip = require('unbzip2-stream');
const tar = require('tar-stream');
const { Readable } = require('stream');
const staging = path.join(voiceDir, '.installing-' + bundle + '-' + process.pid);
fs.rmSync(staging, { recursive: true, force: true });
fs.mkdirSync(staging, { recursive: true });
await new Promise((resolve, reject) => {
const extract = tar.extract();
extract.on('entry', (header, stream, next) => {
/* Never write outside the staging folder, whatever the archive claims. */
const dest = path.resolve(staging, header.name);
if (dest !== staging && !dest.startsWith(staging + path.sep)) {
stream.resume(); return next();
}
if (header.type === 'directory') {
fs.mkdirSync(dest, { recursive: true });
stream.resume(); return next();
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
stream.pipe(fs.createWriteStream(dest)).on('finish', next).on('error', reject);
});
extract.on('finish', resolve);
extract.on('error', reject);
Readable.from(body).pipe(bunzip()).on('error', reject).pipe(extract);
});
/* The archive's top-level folder is the bundle name — that is what
voicePath() opens. Swap it into place in one rename. */
const unpacked = path.join(staging, bundle);
if (!fs.existsSync(unpacked)) throw new Error('the archive did not contain ' + bundle);
const final = path.join(voiceDir, bundle);
fs.rmSync(final, { recursive: true, force: true });
fs.renameSync(unpacked, final);
fs.rmSync(staging, { recursive: true, force: true });
parentPort.postMessage({ status: 'ready' });
})().catch((err) => {
parentPort.postMessage({ status: 'error', message: (err && err.message) || String(err) });
});