← all documents · raw markdown · 12 KB

LEO VOICE PIPELINE MAP — end-to-end

Generated: 2026-06-30 · For the "fast-forward / time-compressed voice" investigation.

Scope: every stage Leo's audio passes through (inbound mic → Gemini → playout) plus

every module/dependency Leo relies on. File paths are relative to C:\KAI\tools\oracle-discord.

> TL;DR of the bug (full detail in §6): @discordjs/[email protected] paces a StreamType.Raw

> resource with an absolute-clock scheduler (one 20 ms frame per timer tick, target time

> advanced by a fixed +20 ms each frame). When the Node event loop stalls (sync better-sqlite3

> flush into the 191 MB transcript DB, GC, Gemini session rebuild), the scheduler falls behind

> wall-clock and then fires back-to-back with a 1 ms delay until it catches up, draining the

> buffered PCM faster than real time = the audible speed-up. The producer hands Gemini's whole

> utterance to the buffer faster than real time, which is the fuel the catch-up burst races

> through.

---

0. Process & dependencies Leo loads

| Dependency | Where | Role |
|---|---|---|
| discord.js + @discordjs/[email protected] | bots/leo.mjs:1-20 | Discord gateway + voice connection, AudioPlayer, createAudioResource, StreamType, EndBehaviorType |
| prism-media (prism.opus.Decoder/Encoder) | leo.mjs:4793, 5805 | Opus decode of inbound mic; Opus encode is internal to @discordjs/voice on playout |
| ws (WebSocket) | shared/gemini-live-bridge.mjs:19 | Gemini Live bidi socket |
| better-sqlite3 | shared/transcript-memory.mjs:1 | Episodic transcript / FTS5 memory (the 191 MB DB) — synchronous engine |
| @ricky0123/vad-node + onnxruntime-node (optional) | leo.mjs:118-155 | Neural Silero VAD; OFF by default (RMS gate used unless LEO_NEURAL_VAD=1) |
| stream.PassThrough | leo.mjs:104 | The jitter buffer between the audio producer and the player |
| ffmpeg (spawned) | leo.mjs:611-637, 4580 | Only for the soundboard / TTS-file / edge-reading side-paths (OggOpus). The Live conversational voice does NOT go through ffmpeg — it is raw PCM. |
| tools/oracle-discord/.env | secrets | GEMINI_* keys, LEO_* tuning vars. Never printed. |
| The Rust engine / Oracle (command-center-server.mjs :3001) | separate procs | Not in the audio path; Leo only reads telemetry from it. |

Three audio players are created up front (leo.mjs:435-445):

voice subscription away and hands it back on Idle.

---

1. INBOUND — Discord mic → VAD/gate → Gemini

1. Receiver subscriptiongetLeoVoiceReceiver() (leo.mjs:411) returns

voiceConnection.receiver. Per speaker, `_rxLive.subscribe(uid, { end: { behavior:

AfterSilence, duration: END_OF_SPEECH_MS } }) (leo.mjs:4792`, default 2200 ms, env

LEO_END_OF_SPEECH_MS / LEO_SILENCE_FINALIZE_MS).

2. Opus decodenew prism.opus.Decoder({ frameSize: 960, channels: 2, rate: 48000 }),

stream.pipe(decoder) (leo.mjs:4793-4795). Inbound is 48 kHz stereo.

3. Noise gate / VAD — RMS energy gate per frame (leo.mjs:~4810+); thresholds

LEO_MIC_GATE (idle, default 400) and LEO_MIC_GATE_SPEAKING (raised while Leo talks so a

cough can't barge in). Neural Silero VAD only if LEO_NEURAL_VAD=1.

Echo half-duplex guard: while bridge._leoSpeakingUntil is in the future

(leo.mjs:3456, LEO_ECHO_TAIL_MS default 1200) the mic loop will not forward, so Leo's

own speaker echo can't reach Gemini's VAD and cut his reply short.

4. Forward to Gemini — gated chunks go to liveBridge.sendAudio(chunk)

(leo.mjs:4873, 4971 pre-roll flush, 5063).

5. Downsample for GeminisendAudio() (gemini-live-bridge.mjs:1326) calls

_downsample48to16() (:1443): 48 kHz stereo → 16 kHz mono s16le, base64, sent as

realtimeInput.audio { mimeType: "audio/pcm;rate=16000" } (:1334-1336).

Activity markers activityStart / activityEnd (:1369, :1379); idle silence keepalive

(:1390); sendAudioStreamEnd() (:1400).

---

2. GEMINI LIVE — the websocket session

(gemini-live-bridge.mjs:29).

with an ordered fallback list of native-audio preview models, Flash-Live last resort

(:41-52).

prebuiltVoiceConfig.voiceName (:777-788`).

realtimeInputConfig (:792), sessionResumption handle (:845).

throttles (minGap), and on an idle session closes without reconnect; otherwise closes

now and reconnects cleanly with context preserved.

sessionResumptionUpdate; the next setup resumes with it (fixes the "1008 GoAway abort

loop" + memory loss across the boundary).

429, rotates model then key with backoff; _reconnectAttempts budget reset per new

model/key; long-cooldown + post-outage reconnect paths.

---

3. OUTBOUND audio generation → decode → resample

1. Publishes the throttled fleet "Leo is speaking" heartbeat, deferred via setImmediate

so the (now-debounced) recordLeoVoiceConversation write never blocks the frame loop

(leo.mjs:3412-3427) — *prior hot-path fix, keep intact.*

2. Decodes/resamples: GeminiLiveBridge.decodeAudioChunk(base64, mimeType)

(gemini-live-bridge.mjs:1465). Rate-aware (srcRate from mimeType, default 24000),

linear interpolation upsample to 48 kHz stereo s16le:

outFrames = floor(n * 48000 / srcRate), step = srcRate/48000, pos = i*step computed

fresh per frame (no cumulative drift). ✅ Ratio is correct — *ruled out as a cause.*

3. Echo reference RMS — sparse stride-32 scan (leo.mjs:3445-3451) — *prior hot-path

fix; was an O(n) full scan that stalled the loop, now cheap. Keep.*

4. Writes PCM into the jitter buffer — see §4.

---

4. JITTER BUFFER → AudioPlayer → Opus → UDP (the failure point)

(leo.mjs:3458).

_jitterBytes = ms*192) is accumulated in bridge._prebuf, then flushed into the stream and

audioPlayer.play(createAudioResource(stream, { inputType: StreamType.Raw }))

(leo.mjs:3463-3484).

to the PassThrough (leo.mjs:3485-3508). Optional backlog drop LEO_MAX_PLAYOUT_BACKLOG_MS

(default 0 = never drop, because dropping truncates Leo's tail). **This is the producer that

dumps the utterance faster than real time.**

then stream.end() and resets _playing (:3523-3535); also writes the transcript and

per-user channel post.

scheduler tick** (node_modules/@discordjs/voice/dist/index.js:600 _stepDispatch,

:646 state.resource.read()), Opus-encodes, and sends over the Discord UDP socket.

The scheduler is the only clock (index.js:120-145).

A 20 ms frame at 48 kHz / 16-bit / stereo = 48000 * 0.02 * 2ch * 2bytes = 3840 bytes.

---

5. CONTROL PATHS that touch a turn

lines. Deferred when the socket isn't ready into this._pendingContext (:659,

:1420-1422), flushed on setup-complete (:1235-1238). *Prior fix — keep.* Many call

sites in leo.mjs (join/leave context, contradiction notes, reading control, etc.).

3797, 4308, 4428 (pre-empt current sentence on confirmed user speech).

watchdog and may be tool-only (no audio).

via ffmpeg), ambientPlayer (ambience). effects/ambient temporarily subscribe away from

the voice and hand back on Idle (leo.mjs:586-661, 728-757).

on turn-complete and message events (leo.mjs:3318, 3360, 3572, 3849). Writes are **already

deferred/batched** (transcript-memory.mjs:102-151): rows queue in _writeQueue, a

setTimeout (TRANSCRIPT_FLUSH_MS, default 150) fires _flushQueue() which runs ONE

db.transaction(...). But better-sqlite3 is synchronous — when that flush fires it

blocks the event loop for the duration of the FTS write, and ingestMessage still does two

inline synchronous SELECTs (dedup :188-191, previous-message :209-214) on the caller's

thread. These are the remaining event-loop stall sources that trigger the catch-up burst.

logs per-utterance RTF (Real-Time Factor = audio-ms emitted / wall-ms elapsed); RTF > 1.05

= playing faster than real time = the speed-up caught in the act.

---

6. ROOT CAUSE (confirmed) — see the separate analysis in the Codex changelog

The scheduler in @discordjs/[email protected]:

// node_modules/@discordjs/voice/dist/index.js
120  var FRAME_LENGTH = 20;
124  function audioCycleStep() {
126    nextTime += FRAME_LENGTH;                       // target advances a FIXED +20ms/frame
128-130  for (player of available) player._stepDispatch();   // send ONE frame
131    prepareNextAudioFrame(available);
138    audioCycleInterval = setTimeout(audioCycleStep, Math.max(1, nextTime - Date.now()));

nextTime is an absolute wall-clock target, advanced by exactly +20 ms per frame

regardless of how long the loop was actually blocked. After a stall of, say, 200 ms, the timer

fires with nextTime 200 ms behind Date.now(), so Math.max(1, nextTime - Date.now()) = 1.

It then dispatches a frame, advances nextTime by 20 ms (still behind), schedules again in 1 ms,

and bursts ~10 buffered frames in ~10 ms of wall-clock until nextTime catches up — i.e.

200 ms of audio played in ~10 ms ≈ 20× = the fast-forward. _stepPrepare reads exactly one

3840-byte frame from the PassThrough each cycle (index.js:646), and because the producer has

dumped the whole utterance into the buffer faster than real time, there is always a frame ready

for the burst to consume.

Ruled out: resample ratio bug (§3.2 — correct, no drift), Opus frame-size mismatch (Raw is

framed internally at 3840 B; inbound decoder is a separate path), and producer-dump *alone*

(it's the fuel, but without the scheduler catch-up burst a correctly-paced player would still

play it at real time).

The fix (implemented in leo.mjs, flag LEO_PACED_PRODUCER): pace the producer so the

PassThrough never holds more than a tiny cushion — there is then no backlog for the catch-up

burst to race through, so audio can never exceed real time. See the Codex changelog and

LEO-VOICE-FASTFORWARD-FIX notes for the exact diff.