Files

691 lines
30 KiB
JavaScript
Raw Permalink Normal View History

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