/* * 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) }); });