Files
church-translate/app/server/live-audio.js
christianandClaude Fable 5.1 81da657094 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>
2026-10-15 16:05:15 -04:00

691 lines
30 KiB
JavaScript

/*
* live-audio.js — one continuous audio stream per language, as live HLS.
*
* WHY THIS EXISTS, because the obvious design is the one that fails:
*
* The natural thing is to send each sentence to the phone as its own little
* clip and play it. That is what this app did, and it cannot survive a locked
* screen. WebKit decides background behaviour from the media TYPE: an
* AudioContext is MediaType::WebAudio, which is restricted in the background,
* while an <audio> element is MediaType::Audio, which is not restricted at all.
* Web Audio is on the wrong side of that line by construction — no amount of
* keep-alive trickery moves it.
*
* Worse, iOS keeps a locked page's JavaScript and its WebSocket alive only
* while the page is continuously AUDIBLE. Stop making sound and a ten-second
* timer suspends the whole process. Ordinary pauses in a sermon are longer than
* ten seconds, so any per-sentence design loses its socket mid-service and
* cannot get it back until someone unlocks the phone.
*
* So the stream never stops. When nobody is speaking it carries encoded
* silence, which WebKit counts as audible (it never inspects sample values),
* which holds the process alive, which keeps the next sentence arriving. Idle
* silence costs about 1.9 kbit/s — 264 bytes per second per language.
*
* The audio also arrives over plain HTTP fetches driven by the media stack
* rather than over the WebSocket, so even if the socket does die while locked,
* the voice keeps playing and only the captions pause.
*
* A happy side effect: an audible media element puts the page in the
* MediaPlayback audio category instead of AmbientSound, so the ringer switch
* stops silencing it. Listeners with their phone on silent could not hear this
* app at all before.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { encodeAdts, PRIMING_FRAMES } = require('./aac-encode.js');
/* 1024 samples per AAC frame is fixed by the codec. At 22050 Hz that is
46.44 ms, so ~21 frames make a segment just under a second. Short segments
keep the live edge close; HLS players start roughly three segments back. */
/*
* Segment and window sizes are TIME — each stream derives frame counts from
* its own sample rate. A fixed count that gives 0.7s at 22050 Hz gives 0.35s
* at 44100 and shrinks retention into rotate-out failures. 0.70s segments
* (down from 0.975) move the live edge ~0.3-0.5s closer to now.
*/
const SEGMENT_SEC = 0.70;
/* ~12s of window. 8 (~7.8s) left players almost no slack: one brief stall and
the segment they wanted next had already rotated out, which reads as a fatal
error. Segments are a few hundred bytes of silence or 1-2 KB of speech, so
the memory cost of the extra headroom is nothing. */
const WINDOW_SEC = 11;
/*
* How much slack the continuous (microphone) feed carries. Paid once as delay,
* and it is the difference between a hiccup being absorbed and a hiccup being
* a hole in the middle of a word. The hardware output path does not go through
* here at all, so this costs the assistive-listening packs nothing.
*/
const CONTINUOUS_CUSHION_MS = 700;
/* A persistently jittery feed grows its cushion rather than skipping, but not
past this — beyond about two seconds the delay is its own problem. */
const CONTINUOUS_CUSHION_MAX_MS = 2100;
/*
* Extra retention, in seconds, on top of the window.
*
* A delayed listener is served the SAME segments as the room, just starting
* further back — there is only one sliding window per language and nothing to
* send "late" to one listener over HTTP. So the window has to still contain
* what they are about to ask for: retention must cover the stream delay as
* well as the normal player slack, or their next segment has already rotated
* out and the page reports a fatal error.
*
* Cheap enough not to think about: audio is 48 kbps, so a 60-second reserve is
* about 360 KB per language — under 5 MB for a dozen languages.
*/
let reserveSec = 0;
/*
* The stream plays at exactly 1x real time, so anything that queues faster than
* that falls behind FOREVER — the gap never closes on its own. Translated
* speech routinely runs longer than the original (Spanish especially), so
* without a cap the voice ends up minutes behind the preacher by the end of a
* sermon while every caption is current.
*
* When the backlog passes MAX, drop the OLDEST whole sentences until it is back
* under TARGET. Oldest, because in live translation the stale sentence is the
* worthless one — and whole sentences, because chopping mid-word is worse than
* skipping. The caption for anything dropped is already on screen.
*/
/*
* MEASURED against a real service (2026-09-13, 377 sentences replayed through
* this very module): at 6/3 the dropper threw away 73 sentences — 19.4% of the
* sermon — and the median dropped sentence was 3.0 s old. It was not
* trimming stale drift; it was firing on ordinary same-breath bursts, where
* one final becomes 2-4 sentences whose clips land together. The stream then
* drained in under 2 s and sat empty until the next clip: the "skip" and the
* "quiet" were two halves of one event.
*
* Two changes, both measured on the replay. Wider thresholds (9/6): 73 -> 28.
* Plus the rule below — only a sentence whose CAPTION is at least
* STALE_CAPTION_SEC old may be dropped: 73 -> 16 (4.2%), and every one of
* those was genuinely stale. Gating on time since push instead was tried and
* rejected: after any supply stall the flushed backlog is "fresh" by push
* time, and that variant played 94 s of stale audio.
*/
const MAX_BACKLOG_SEC = 9;
const TARGET_BACKLOG_SEC = 6;
const STALE_CAPTION_SEC = 6;
/*
* Silence appended after each sentence. Sentences are synthesised separately
* and butted together with no gap at all, which is what makes a stream of them
* sound blocky and rushed — real speech breathes between sentences.
*
* But the pause is AUDIO, and audio is exactly what we are short of when
* running behind: 320ms on a 2s sentence is another 16% of drift. So the pause
* is a luxury spent only when the stream is keeping up. Caught up, it breathes;
* behind, it gets on with it. That is what a human interpreter does too.
*/
const SENTENCE_GAP_MS = 320;
/* Raised with TARGET, or the breath between sentences would be squeezed to
nothing on 97% of sentences at the new steady state (measured). */
const GAP_COMFORT_SEC = 1.5; // below this backlog, take the full pause
const GAP_SQUEEZE_SEC = 4.0; // above this, no pause at all
/* How often the pump wakes. Not once per frame — that would be 21 timers a
second per language for no benefit. It emits however many frames the wall
clock says are due. */
const TICK_MS = 240;
const SILENCE_DIR = path.join(require('os').tmpdir(), 'church-translate-silence');
// ------------------------------------------------------------------ ADTS
/*
* Split an ADTS file into whole frames. Every frame starts with a 12-bit sync
* word and carries its own length, so a stream is just frames concatenated —
* which is exactly what makes this container splice-able and MP4 not.
*/
function adtsFrames(buf) {
const out = [];
let off = 0;
while (off + 7 <= buf.length) {
if (buf[off] !== 0xff || (buf[off + 1] & 0xf0) !== 0xf0) break;
const len = ((buf[off + 3] & 0x03) << 11) | (buf[off + 4] << 3) | (buf[off + 5] >> 5);
if (len < 7 || off + len > buf.length) break;
out.push(buf.subarray(off, off + len));
off += len;
}
return out;
}
function adtsSampleRate(buf) {
const TABLE = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000];
if (buf.length < 4 || buf[0] !== 0xff) return 0;
return TABLE[(buf[2] & 0x3c) >> 2] || 0;
}
/*
* One frame of encoded silence at a given rate, made once and reused forever.
* Encoded through server/aac-encode.js like everything else — same reasoning as the
* clip encoder in tts-service.js.
*/
const silenceCache = new Map();
function silenceFrame(sampleRate) {
if (silenceCache.has(sampleRate)) return silenceCache.get(sampleRate);
fs.mkdirSync(SILENCE_DIR, { recursive: true });
const wav = path.join(SILENCE_DIR, 'sil-' + sampleRate + '.wav');
const aac = path.join(SILENCE_DIR, 'sil-' + sampleRate + '.aac');
const n = sampleRate; // one second is plenty to slice from
const b = Buffer.alloc(44 + n * 2);
b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12);
b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
b.writeUInt32LE(sampleRate, 24); b.writeUInt32LE(sampleRate * 2, 28);
b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
fs.writeFileSync(wav, b);
/* 48k to MATCH tts-service.js's clip encoder. At 32 kbps afconvert silently
RESAMPLES 44100 Hz input down to 32000, so the Chinese stream would splice
44.1 kHz speech against 32 kHz silence — mismatched-rate frames in one
stream, which players handle by glitching. */
encodeAdts(wav, aac); // keeps the WAV's own rate — see aac-encode.js
const frames = adtsFrames(fs.readFileSync(aac));
/* Skip the first couple — the encoder's priming frames are not steady state. */
const frame = frames[Math.min(3, frames.length - 1)];
silenceCache.set(sampleRate, frame);
try { fs.unlinkSync(wav); } catch (_) {}
return frame;
}
// ---------------------------------------------------------------- streams
function segmentsToKeep() {
return Math.max(6, Math.round((WINDOW_SEC + reserveSec) / SEGMENT_SEC));
}
class LanguageStream {
constructor(lang, sampleRate, onLog) {
this.lang = lang;
this.sampleRate = sampleRate;
this.onLog = onLog;
this.frameMs = (1024 / sampleRate) * 1000;
this.framesPerSegment = Math.max(4, Math.round(SEGMENT_SEC * sampleRate / 1024));
this.keepSegments = segmentsToKeep();
this.pending = []; // [{frames, gap, id, secs, at}] — one entry per sentence; `at` is its CAPTION time
this.pendingFrames = 0; // total frames queued, for the backlog check
this.onSentence = null; // fires with (id, secs) as a sentence starts playing out
this.onDrop = null; // fires with ([ids], secondsDropped) when the trimmer discards sentences
this.dropped = 0; // sentences discarded by the trimmer, this service
this.droppedSec = 0;
this.dropTimes = []; // when, for a "recently" view on the operator page
this.building = []; // frames of the segment being filled
this.segments = []; // { seq, body, frames }
/*
* SEGMENT NUMBERS MUST NEVER REPEAT, and starting at 1 made them repeat.
* A stream object is built when a language's first listener arrives and
* dropped when the last one leaves, so within one service the same
* language can get a fresh stream several times — and a phone that came
* back from the lock screen, or a second listener, asked for
* spa_Latn/1.aac again. The segments are served 'max-age=60, immutable',
* which is the right header for a segment that is never reused and the
* wrong one for a number that is: for up to a minute the browser answered
* from its cache with the PREVIOUS stream's audio under the new number —
* a sentence from earlier in the sermon, replayed. Numbering from the
* clock makes every incarnation's numbers new; HLS only asks that they
* increase by one within a playlist, which they still do.
*/
this.nextSeq = Date.now();
this.timer = null;
this.lastTick = 0;
this.carry = 0; // fractional frames owed from the last tick
}
start() {
if (this.timer) return;
/*
* Seed one segment of silence immediately, so /live/<lang>.m3u8 exists from
* the first moment a player can ask for it. Without this there is a ~1-2s
* window after a listener picks a language where the playlist 404s — and a
* 404 at exactly that moment is indistinguishable from "this device cannot
* play HLS", which once sent pages to the clip fallback for good.
*/
if (!this.segments.length) {
const sil = silenceFrame(this.sampleRate);
for (let i = 0; i < this.framesPerSegment; i++) this.building.push(sil);
this.closeSegment();
}
this.lastTick = Date.now();
this.carry = 0;
this.timer = setInterval(() => this.tick(), TICK_MS);
if (this.timer.unref) this.timer.unref();
}
stop() {
if (this.timer) clearInterval(this.timer);
this.timer = null;
this.continuous = false; // re-prime the cushion on the next service
this.underruns = 0;
this.reprimes = 0;
this.pending.length = 0;
this.pendingFrames = 0;
this.building.length = 0;
/* Drop the window too. Keeping it would serve a playlist that never
advances, and a player reaching the end of a frozen live playlist stalls
rather than failing — which is much harder to notice than a 404. */
this.segments.length = 0;
}
/* Queue a sentence. Frames go out at the stream's own pace, so a long
sentence simply occupies the next few seconds of the stream. */
/*
* Continuous feed (the live microphone): no sentence gap, no read-along
* announce, and an exact-sample debt ledger. Each 0.7s chunk arrives with
* 2112 samples of encoder priming; stripping two whole AAC frames removes
* 2048 of them, and the remaining +64 per chunk would stretch an hour of
* feed by ~20 seconds — the backlog trimmer would then "fix" that with
* audible chunk drops. When the debt reaches a full frame, one extra
* (mostly-priming) frame is stripped and the ledger settles.
*/
pushContinuous(buf) {
const rate = adtsSampleRate(buf);
if (rate && rate !== this.sampleRate) {
this.sampleRate = rate;
this.frameMs = (1024 / rate) * 1000;
this.framesPerSegment = Math.max(4, Math.round(SEGMENT_SEC * rate / 1024));
this.pending.length = 0;
this.pendingFrames = 0;
this.building.length = 0;
}
/*
* A JITTER CUSHION, primed once.
*
* The mic feed produces exactly real time — 0.7s of audio every 0.7s — so
* supply equals demand with nothing spare, and the steady-state backlog
* measured only ~800ms. tick() inserts SILENCE whenever the queue runs dry,
* so any hiccup longer than that cushion — a busy event loop, a WebSocket
* that bunched two chunks, a GC pause — punches an audible hole straight
* through the middle of a word. On a sentence-shaped TTS stream a hole
* lands in a pause and nobody notices; on a continuous feed it is a skip.
*
* MEASURED with a metronome-perfect feed and no network: 0 underruns, 800ms
* backlog. That is healthy but thin, and the real path has neither of those
* luxuries.
*
* Priming silence costs that much added delay ONCE, at the start, and buys
* the same amount of jitter tolerance for the whole service.
*/
if (!this.continuous) {
this.continuous = true;
this.underruns = 0;
this.cushionMs = CONTINUOUS_CUSHION_MS;
this.reprimes = 0;
}
/*
* SELF-HEALING, and this is the part that matters.
*
* A cushion primed once is spent once: the first hiccup big enough to
* empty the queue eats it, and every hiccup after that is an audible hole
* because the buffer never comes back. MEASURED — two stalls (500ms and
* 900ms late) took the backlog from 1472ms to 704ms permanently, and the
* stream reported 15 underrun frames.
*
* So top up whenever the queue is found EMPTY at push time. Empty means
* silence has already gone out — the audio is lost either way — and adding
* the cushion back is the difference between losing it once and losing it
* every time. Never top up a queue that still holds audio; that would
* insert silence into the middle of a word to fix a problem that has not
* happened.
*
* A feed that keeps stalling earns a bigger cushion, up to a ceiling: more
* delay is a fair price for not being punched full of holes, but it cannot
* grow without limit.
*/
if (this.pendingFrames === 0) {
if (this.reprimes > 0) {
this.cushionMs = Math.min(CONTINUOUS_CUSHION_MAX_MS, this.cushionMs + 350);
}
const sil0 = silenceFrame(this.sampleRate);
const cushion = Math.round(this.cushionMs / this.frameMs);
const pad = [];
for (let i = 0; i < cushion; i++) pad.push(sil0);
this.pending.push({ frames: pad, gap: 0, id: null, secs: 0 });
this.pendingFrames += pad.length;
this.reprimes++;
this.onLog('live mic feed for ' + this.lang + ': ' +
(this.reprimes === 1 ? 'primed ' : 'refilled ') +
Math.round(cushion * this.frameMs) + 'ms of jitter cushion' +
(this.reprimes > 1 ? ' (refill #' + this.reprimes + ')' : ''));
}
/* Encoder priming, from aac-encode.js — 2 frames under afconvert, 1 under
ffmpeg. The +64 remainder below is the same under both (measured). */
let strip = PRIMING_FRAMES;
this.sampleDebt = (this.sampleDebt || 0) + 64;
if (this.sampleDebt >= 1024) { strip += 1; this.sampleDebt -= 1024; }
let frames = adtsFrames(buf);
if (frames.length > strip + 2) frames = frames.slice(strip);
if (!frames.length) return;
this.pending.push({ frames, gap: 0, id: null, secs: (frames.length * 1024) / this.sampleRate });
this.pendingFrames += frames.length;
/* No trimBacklog here: a live feed produces exactly real time by
construction (the debt ledger guarantees it), so the backlog stays a
constant one-chunk bubble. */
}
push(buf, terminal, id, captionAt) {
const rate = adtsSampleRate(buf);
if (rate && rate !== this.sampleRate) {
/* A voice was swapped underneath us. Splicing mismatched rates produces
chipmunk audio, so rebuild the stream instead. */
this.onLog('live audio for ' + this.lang + ' switching ' + this.sampleRate + ' -> ' + rate);
this.sampleRate = rate;
this.frameMs = (1024 / rate) * 1000;
this.framesPerSegment = Math.max(4, Math.round(SEGMENT_SEC * rate / 1024));
this.pending.length = 0;
this.pendingFrames = 0;
this.building.length = 0;
}
/*
* The encoder prepends priming to every clip — afconvert 2112 samples,
* ffmpeg 1024 (both measured; see aac-encode.js) — and ADTS
* has no edit list to declare it, so the decoder plays it as dead silence
* at every sentence boundary — 96-142 ms per join. Dropping the first two
* frames removes 2048 of those samples; frame 2 keeps frame 1 as its MDCT
* overlap partner, so the speech onset survives (measured error confined to
* the first 1024 samples, RMS 10-21 LSB). Dropping THREE destroys the onset
* (-5 dBFS error, 12-38% energy loss). Do not raise this number.
*/
let frames = adtsFrames(buf);
if (frames.length > PRIMING_FRAMES + 2) frames = frames.slice(PRIMING_FRAMES);
if (!frames.length) return;
/* The gap rides with the sentence so that dropping one drops its pause too. */
/*
* A full stop earns a full breath; a fragment the VAD cut mid-thought gets
* only a beat, because a sentence-final pause after half a sentence is
* exactly the halting rhythm listeners hear as "blocky".
*/
const ended = terminal === '.' || terminal === '!' || terminal === '?' ||
terminal === '\u3002' || terminal === undefined;
const gapMs = this.gapMsForBacklog() * (ended ? 1 : 0.4);
const gap = Math.round(gapMs / this.frameMs);
const sil = silenceFrame(this.sampleRate);
const secs = (frames.length * 1024) / this.sampleRate; // speech only, sans gap
for (let i = 0; i < gap; i++) frames.push(sil);
/* `at` is when the sentence was SAID (the caption's timestamp), not when
its audio arrived — the difference is the whole dropper fix above. */
this.pending.push({ frames, gap, id, secs, at: Number(captionAt) || Date.now() });
this.pendingFrames += frames.length;
this.trimBacklog();
}
backlogSeconds() { return (this.pendingFrames * 1024) / this.sampleRate; }
gapMsForBacklog() {
const b = this.backlogSeconds();
if (b <= GAP_COMFORT_SEC) return SENTENCE_GAP_MS;
if (b >= GAP_SQUEEZE_SEC) return 0;
/* Taper rather than switch, so the rhythm does not lurch at a threshold. */
const t = (b - GAP_COMFORT_SEC) / (GAP_SQUEEZE_SEC - GAP_COMFORT_SEC);
return Math.round(SENTENCE_GAP_MS * (1 - t));
}
trimBacklog() {
if (this.backlogSeconds() <= MAX_BACKLOG_SEC) return;
/*
* Shorten the breathing room before losing anyone's words. A clipped pause
* costs a little naturalness; a dropped sentence costs the sentence. Only
* once every pause is gone is dropping the right answer.
*/
for (let i = 0; i < this.pending.length && this.backlogSeconds() > TARGET_BACKLOG_SEC; i++) {
const p = this.pending[i];
const trim = Math.min(p.gap, p.frames.length - 1);
if (trim <= 0) continue;
p.frames.length -= trim;
this.pendingFrames -= trim;
p.gap -= trim;
}
if (this.backlogSeconds() <= MAX_BACKLOG_SEC) return;
let dropped = 0;
let droppedSec = 0;
const ids = [];
while (this.pending.length > 1 && this.backlogSeconds() > TARGET_BACKLOG_SEC) {
/* Never cut the sentence that is coming out of the speaker mid-word —
if the head has started playing, the oldest DROPPABLE one is next. */
const at = this.pending[0].announced ? 1 : 0;
if (at >= this.pending.length) break;
/* Only a sentence the preacher said STALE_CAPTION_SEC ago is stale enough
to lose. If the oldest droppable one is fresher than that, everything
behind it is fresher still — stop, and let the hurry work it down. */
if (Date.now() - (this.pending[at].at || 0) < STALE_CAPTION_SEC * 1000) break;
const gone = this.pending.splice(at, 1)[0];
this.pendingFrames -= gone.frames.length;
droppedSec += (gone.frames.length * 1024) / this.sampleRate;
if (gone.id != null) ids.push(gone.id);
dropped++;
}
if (dropped) {
this.dropped += dropped;
this.droppedSec += droppedSec;
this.dropTimes.push(Date.now());
if (this.dropTimes.length > 200) this.dropTimes.splice(0, this.dropTimes.length - 200);
if (this.onDrop) { try { this.onDrop(ids, droppedSec); } catch (_) {} }
}
if (dropped) {
this.onLog('live audio ' + this.lang + ': dropped ' + dropped +
' stale sentence(s) to catch up (backlog now ' +
this.backlogSeconds().toFixed(1) + 's)');
}
}
tick() {
const now = Date.now();
const elapsed = now - this.lastTick;
this.lastTick = now;
/* Frames due since the last wake, carrying the fraction forward so the
stream does not drift slow. */
const exact = elapsed / this.frameMs + this.carry;
let due = Math.floor(exact);
this.carry = exact - due;
/* After a long stall (a laptop sleeping) do not dump a minute of silence
into the buffer — just resume at the live edge. */
if (due > this.framesPerSegment * 2) due = this.framesPerSegment * 2;
const sil = silenceFrame(this.sampleRate);
for (let i = 0; i < due; i++) {
let frame = sil;
while (this.pending.length && !this.pending[0].frames.length) this.pending.shift();
if (!this.pending.length && this.continuous) {
/*
* The queue ran dry on a feed that is supposed to be unbroken. This is
* the skip, and it used to happen silently — the stream simply played
* a little silence and nobody could tell it from the room being quiet.
* Counted and reported, because "it skips sometimes" is not something
* anyone can act on.
*/
this.underruns = (this.underruns || 0) + 1;
if (this.underruns === 1 || this.underruns % 25 === 0) {
this.onLog('live mic feed for ' + this.lang + ': ' + this.underruns +
' underrun frame(s) — audio arriving slower than real time');
}
}
if (this.pending.length) {
const head = this.pending[0];
/* The first frame of a sentence leaving the queue is the moment it
starts existing as sound — which is what the read-along highlight on
the phones needs to know. */
if (!head.announced) {
head.announced = true;
if (this.onSentence && head.id != null) {
try { this.onSentence(head.id, head.secs); } catch (_) {}
}
}
frame = head.frames.shift();
this.pendingFrames--;
}
this.building.push(frame);
if (this.building.length >= this.framesPerSegment) this.closeSegment();
}
}
closeSegment() {
const body = Buffer.concat(this.building);
this.segments.push({ seq: this.nextSeq++, body, frames: this.building.length });
this.building = [];
while (this.segments.length > this.keepSegments) this.segments.shift();
}
segmentDuration() { return (this.framesPerSegment * 1024) / this.sampleRate; }
/*
* `backSec` is how far behind the live edge this listener should start. 0 for
* the room; the stream delay for someone who came through the tunnel. Same
* playlist, same segments, different starting position — which is the only
* way to delay one listener of a shared HLS window and not the others.
*/
playlist(backSec) {
if (!this.segments.length) return null;
const dur = this.segmentDuration();
/* Never ask for a position older than the window actually holds, or the
player seeks to something that has rotated out and stalls. */
const held = Math.max(0, (this.segments.length - 1) * dur);
const back = Math.min(Math.max(0, Number(backSec) || 0) + 1.2, held);
const lines = [
'#EXTM3U',
'#EXT-X-VERSION:3',
'#EXT-X-TARGETDURATION:' + Math.ceil(dur),
'#EXT-X-MEDIA-SEQUENCE:' + this.segments[0].seq,
/* Without this a player starts three segments back and stays there for
the whole service. Measured: 4.92 s of lag becomes 1.61 s. */
'#EXT-X-START:TIME-OFFSET=-' + back.toFixed(2) + ',PRECISE=YES'
];
for (const s of this.segments) {
lines.push('#EXTINF:' + dur.toFixed(3) + ',');
/* Relative to the PLAYLIST's directory, which is /live/ — so the language
has to be in the path or the player asks for /live/17.aac and gets a
404. The playlist itself is /live/<lang>.m3u8, not /live/<lang>/. */
lines.push(this.lang + '/' + s.seq + '.aac');
}
return lines.join('\n') + '\n';
}
segment(seq) {
const s = this.segments.find((x) => x.seq === seq);
return s ? s.body : null;
}
}
// ----------------------------------------------------------------- facade
class LiveAudio {
constructor(onLog) {
this.streams = new Map();
this.onLog = onLog || (() => {});
}
/* Default rate is a starting guess; the first real clip corrects it. */
ensure(lang, sampleRate) {
let s = this.streams.get(lang);
if (!s) {
s = new LanguageStream(lang, sampleRate || 22050, this.onLog);
s.onSentence = (id, secs) => {
if (this.onSentenceStart) { try { this.onSentenceStart(lang, id, secs); } catch (_) {} }
};
s.onDrop = (ids, secs) => {
if (this.onDropped) { try { this.onDropped(lang, ids, secs); } catch (_) {} }
};
this.streams.set(lang, s);
this.onLog('live audio stream open for ' + lang);
}
s.start();
return s;
}
push(lang, buf, sampleRate, terminal, id, captionAt) {
const s = this.ensure(lang, sampleRate || adtsSampleRate(buf));
s.push(buf, terminal, id, captionAt);
}
/*
* What the trimmer has thrown away, per language — the number that was
* invisible for a whole service. `recent` is drop EVENTS in the last minute,
* which is what an operator can act on; totals are for the log.
*/
stats() {
const out = {};
const cut = Date.now() - 60000;
for (const [lang, s] of this.streams) {
if (!s.dropped && !s.underruns) continue;
out[lang] = {
dropped: s.dropped,
droppedSec: +s.droppedSec.toFixed(1),
recent: s.dropTimes.filter((t) => t > cut).length,
underruns: s.continuous ? (s.underruns || 0) : undefined
};
}
return out;
}
/* Per-language backlog in seconds — how far the voice is behind the text.
The engine slows or quickens synthesis from this, like an interpreter. */
backlog() {
const out = {};
for (const [lang, s] of this.streams) {
if (s.timer) out[lang] = +s.backlogSeconds().toFixed(2);
}
return out;
}
/*
* Called whenever the set of languages anyone is listening to changes.
* OPENS a stream for every active language rather than only restarting ones
* that already exist — the stream has to be audible before the first sentence,
* not after it, or the phone is suspended before that sentence can arrive.
*/
retain(langs, rateFor) {
const keep = new Set(langs);
for (const lang of keep) this.ensure(lang, rateFor ? rateFor(lang) : 22050);
for (const [lang, s] of this.streams) {
if (keep.has(lang)) continue;
if (s.timer) { s.stop(); this.onLog('live audio stream idle for ' + lang); }
}
}
stopAll() { for (const [, s] of this.streams) s.stop(); }
playlist(lang, backSec) {
const s = this.streams.get(lang);
return s ? s.playlist(backSec) : null;
}
/*
* Keep enough history to serve a listener who is `sec` behind the live edge.
* Called whenever the stream delay changes, and applied to streams that
* already exist as well as new ones.
*/
reserve(sec) {
const n = Math.max(0, Math.min(300, Math.round(Number(sec) || 0)));
if (n === reserveSec) return;
reserveSec = n;
const keep = segmentsToKeep();
for (const st of this.streams.values()) {
st.keepSegments = keep;
/* Shrinking takes effect on the next rotate; growing must not drop
anything that is already held. */
while (st.segments.length > keep) st.segments.shift();
}
this.onLog('audio window now ' + ((WINDOW_SEC + n)).toFixed(0) + 's (delay reserve ' + n + 's)');
}
segment(lang, seq) { const s = this.streams.get(lang); return s ? s.segment(seq) : null; }
}
module.exports = { LiveAudio, adtsFrames, adtsSampleRate, silenceFrame };