2026-09-23 15:41:21 -04:00
|
|
|
/*
|
|
|
|
|
* 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 }
|
2026-10-15 16:05:15 -04:00
|
|
|
/*
|
|
|
|
|
* 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();
|
2026-09-23 15:41:21 -04:00
|
|
|
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 };
|