One voice per listener, and the voice process stops stalling itself
Build 127. Reported from a live test: part of the translation came out in a
different voice on an Android phone, and iPhones were skipping and repeating.
Three investigations against the campus Mac's own log and transcript, one
test harness driving the voice process directly, and the answer is not what
it sounded like.
WHAT THE MACHINE DID. One voice process ran all afternoon. Every language
kept a single resident engine from its first sentence (14:54:26) to STOP
(15:23:18); 2,800 clips, 560 per language, every one filed under its own
language and spoken by its own bundle. No synthesis-side voice change was
possible with that configuration. What the log shows at the time of the
complaint instead:
- Someone on the English stream heard the ROOM MICROPHONE for 21 s
(15:09:10-32). English was the first button on every phone's list, forced
there by the page whatever the operator had chosen (fixed in build 125).
- A listener tapped Chinese at 15:11:17. The 121 MB zh model was fetched
and unpacked THREE TIMES AT ONCE on the voice process's only thread.
Every stream stalled for about a minute, 26 sentences were dropped across
all five languages, and the catch-up rushed the Spanish voices to their
+16% cap. Same voice, sounding wrong.
WHAT WAS WRONG, AND IS NOW FIXED.
The live path filed every engine as an "audition". speak() resolved the
language's default voice, which always has a bundle, and passed it as
`explicit`, so the key was 'audition:'+bundle for every ordinary sentence
while the preload warmed copies under the language name that no sentence
ever read. Five languages asked for nine engines against a cap of six; every
service's first sentence built its real engines on the spot (1.3-2 s); the
operator's voice save flushed nothing that mattered. Engines are now keyed
by bundle and shared by every language, variant and audition that uses one,
auditions take at most two slots of their own and can never evict a live
engine, and the cap is 8, sized to the largest campus from measurement
(~165 MB per engine once it has spoken, 1.3 GB for eight).
Voice installs ran on the thread that answers every sentence, and were not
de-duplicated. They now run in a worker thread (voice-install.js), one per
bundle however many ask, staged and renamed into place whole so a half-
written bundle can never pass isInstalled().
Listener rows are { code, count } and engine.js read { lang, n } in five
places, so the set of "languages someone is on" was always empty — and a late
sentence keeps speech only for those. Under load the app dropped EVERY
language's speech instead of only the unchosen ones. Late became silent.
HLS segment numbers restarted at 1 for every new stream object, under a
'max-age=60, immutable' header. A phone back from the lock screen could be
answered from its cache with the previous stream's audio under the new
number: an earlier sentence, replayed. Numbers are seeded from the clock.
In clips mode a clip still downloading when the listener switched language
landed and played in the voice they had left; the frame now carries its
language and voice and the page checks both when the bytes land. In stream
mode the old playlist stayed loaded through a switch and three things could
restart it; it is stopped, not paused. A listener's alternate voice no longer
loses a round trip to the default on the original-text toggle.
One genuine wrong-voice path, dormant today because natural voices are off on
this campus: a variant waiting on another variant's premium synthesis
inherited that variant's Piper FALLBACK, in that variant's bundle — spa_ES
received the byte-identical Mexican clip and its own voice never loaded. A
follower now shares a fallback only when it is its own voice.
An audition (the Voices card's play button) played on the system default
output, which on the campus Mac is the Dante card feeding the hardware
channels. It now plays on another output, or refuses and says why.
VERIFIED: a harness drives the voice process through the baseline, a batch
of four auditions during live sentences, a voices save mid-stream, and nine
live languages against the cache — every clip spoken by its own bundle, and
auditions of a live voice now cost 78-311 ms with nothing rebuilt.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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) });
|
||||
});
|
||||
Reference in New Issue
Block a user