Skip to content

feat: low-latency voice pipeline — wall-clock pacing, Krisp denoiser, NAMO EOT, word-accurate barge-in + re-delivery#208

Merged
nicolotognoni merged 11 commits into
mainfrom
feat/low-latency-voice-pipeline
Jul 2, 2026
Merged

feat: low-latency voice pipeline — wall-clock pacing, Krisp denoiser, NAMO EOT, word-accurate barge-in + re-delivery#208
nicolotognoni merged 11 commits into
mainfrom
feat/low-latency-voice-pipeline

Conversation

@nicolotognoni

Copy link
Copy Markdown
Collaborator

Summary

Five low-latency / turn-taking voice-pipeline improvements, all opt-in with byte-identical defaults so nothing changes for existing agents until enabled. Dual-SDK (Python + TypeScript) at strict parity.

  1. Wall-clock outbound audio pacing (paced_output / pacedOutput) — paces carrier audio onto a fixed 20 ms / 160-byte (μ-law) grid with a precomputed silence frame (no cold-start latency) and a drift-corrected monotonic-deadline loop (no post-pause bursts). Mirrors the standard telephony framing; the deliberate drift-correction avoids the pitfalls documented in the prior failed pacing attempts.
  2. Krisp denoiser selection, bring-your-own-license (denoiser="krisp-viva-tel-v2" | "krisp-bvc-o-pro-v3") — real Krisp model ids resolved to the existing pre-STT AudioFilter slot, NC vs BVC session handled. Ships zero Krisp binaries/models; loads the operator's own krisp_audio SDK + license, fail-fast with clear errors otherwise.
  3. Text-based semantic end-of-turn — new NamoTurnDetector (NAMO Turn Detector v1, Apache-2.0, ONNX) over the rolling transcript. TurnDetectorProvider.predict gained a backward-compatible keyword-only transcript arg; the audio-native smart-turn detector is untouched.
  4. Word-accurate barge-in heard-prefix — on interruption, history truncates to what the caller actually heard down to a word boundary mid-sentence (was: round-up to whole sentences from a byte estimate). Played position uses the best available source: Twilio carrier marks → pacer emitted position → byte estimate.
  5. Opt-in barge-in re-delivery (redeliver_interrupted / redeliverInterrupted) — when the un-heard remainder still matters, a one-shot system nudge tells the LLM to answer the caller's new message first, then resume the unfinished idea only if still relevant. Pluggable RedeliveryPolicy. Pipeline-only; no-op in realtime.

Note: streaming input (input_audio_buffer.append per frame, not buffered to end-of-utterance) was already implemented — verified, no change needed.

Implementation

  • New modules: audio/pacer.py/.ts (OutboundFramePacer + precomputed silence), providers/denoiser.py/.ts (Krisp registry), providers/namo_turn_detector.py/.ts, services/redelivery.py/.ts.
  • stream_handler.py/.ts: pacer lifecycle (start/stop/clear-on-barge-in), send-site → enqueue routing when paced, pacer-driven playback tracking, per-sentence seg_<n> marks + mark-confirmed heard cursor, heard/unsaid split, re-delivery capture+one-shot inject.
  • services/llm_loop.py/.ts: extra system-message injection for the re-delivery nudge (same pattern as the compaction summary).
  • Config surface added to both models.py and types.ts; exports in __init__.py / index.ts.
  • Realtime engines intentionally untouched (they arrive provider-paced; heard-prefix/segments are pipeline-only).

Feasibility / licensing notes (why these shapes)

  • Krisp: the Krisp SDK is commercial and its .kef models are non-redistributable; LiveKit's Krisp path requires LiveKit Cloud. An OSS SDK cannot ship Krisp — hence bring-your-own-license pass-through, not bundled models.
  • EOT: LiveKit's turn-detector model license forbids standalone/other-framework use, so its weights were not ported. NAMO v1 (Apache-2.0) is the clean-weights equivalent using the same transcript→probability technique.

Breaking change?

No. Every feature is opt-in and defaults off; unpaced/no-denoiser/no-EOT/no-redelivery paths are byte-identical to main. predict() gained a defaulted keyword-only arg (source- and behavior-compatible).

Test plan

  • Python: pytest tests/2980 passed, 8 skipped, 2 xfailed. New: pacer, paced-output, denoiser, NAMO, redelivery + extended barge-in/heard-prefix suites.
  • TypeScript: npm test2385 passed, 9 skipped (151 files); npm run lint (tsc --noEmit) clean. Mirrored new suites.
  • Parity verified: all new fields/exports present in both SDKs.
  • Live E2E smoke with a real call to validate paced-output audio quality + barge-in re-delivery UX before enabling by default (follow-up; features ship opt-in).

Docs updates

  • CHANGELOG.md Unreleased (Added/Changed). No version bump (deferred to a separate release PR). Follow-up: docs pages + feature-inventory rows for the new opt-in knobs.

🤖 Generated with Claude Code

nicolotognoni and others added 9 commits July 2, 2026 00:34
…oundation)

Adds the isolated, dual-SDK building block for the low-latency outbound path,
ahead of wiring it into the stream handler:

- Precomputed silence frames (mu-law 0xFF x160 / PCM16 zero) so an empty queue
  never stalls the clock and the first tick pays no encode cost (no cold start).
- Fixed 20 ms / 160-byte (mu-law) re-framer of arbitrary enqueued audio, with a
  next_frame() 'NextSample' contract that pads a sub-frame remainder and returns
  silence when drained.
- OutboundFramePacer: a drift-corrected wall-clock loop (monotonic deadline that
  advances one frame period per tick, not by work duration) that emits silence
  on an empty queue and bounds catch-up after an oversleep — avoiding the
  post-pause bursts that sank prior naive sleep(20ms) pacing attempts.

getpatter/audio/pacer.py + src/audio/pacer.ts, 9 unit tests each. Not yet wired
into the send path (follow-up); default behaviour unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D52f6rBsvzicXg45C6MKVM
String-keyed denoiser selection (Agent.denoiser / AgentOptions.denoiser) mapping
the real Krisp model ids krisp-viva-tel-v2 (NC/VIVA telephony) and
krisp-bvc-o-pro-v3 (Background Voice Cancellation) to a resolved AudioFilter via
a registry. NC vs BVC session-type handled (NcInt16 vs BvcInt16). Bring-your-own-
license: ships zero Krisp binaries/models — resolves against the operator's own
krisp_audio SDK + KRISP_VIVA_SDK_LICENSE_KEY + KRISP_MODELS_DIR, with clear
fail-fast errors when SDK/license/model is missing. Reuses the existing
audio_filter chain slot (explicit audio_filter wins). Default None = unchanged.
TS mirrors the API surface + throws the same clear 'no Node Krisp SDK' guidance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D52f6rBsvzicXg45C6MKVM
Adds NamoTurnDetector (NAMO Turn Detector v1, Apache-2.0, ONNX) as a clean-weights
semantic end-of-turn detector consuming the rolling conversation transcript.
Widens TurnDetectorProvider.predict backward-compatibly with a keyword-only
transcript param (predict(pcm16_window, *, transcript=None) / predict(window,
transcript?)) so the audio-native SmartTurnDetector keeps working byte-identically.
The stream handler assembles the last ~4 turns + in-flight utterance and passes it
on every predict call in the existing semantic hold/poll/backstop machinery.
onnxruntime + HF tokenizer are optional/lazy with clear install errors; weights
loaded at runtime (PATTER_NAMO_MODEL), never bundled. Opt-in via
agent.turn_detector; default behaviour unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D52f6rBsvzicXg45C6MKVM
Wire the (already-built, already-tested) OutboundFramePacer into the
outbound carrier audio path as an opt-in mode. Default OFF is byte-identical
to today's event-driven send — the pacer is never created and every send
takes the current direct path.

Opt-in flag: Agent.paced_output (Python) / AgentOptions.pacedOutput (TS),
default false, threaded via the Agent object already handed to the handler.

When ON (pipeline mode):
- One OutboundFramePacer per call. Frame size / silence codec chosen from the
  sender's output format: native mu-law 8 kHz -> 160 B + mulaw silence, else
  PCM16 16 kHz -> 640 B + pcm16 silence. send_frame forwards to the existing
  audio_sender.send_audio / encodePipelineAudio+bridge.sendAudio so per-carrier
  transcode/envelope is unchanged.
- Lifecycle: started as a tracked background task in start()/initPipeline once
  the output codec is settled (before the greeting, which also flows through
  the pacer); stopped + awaited in cleanup()/handleStop()/handleWsClose().
- Every pipeline send-site (synthesize_sentence, drain_sentence_entry,
  spec_send_chunk, first-message streaming + prewarm) enqueues instead of
  direct-sending. Realtime engines are unaffected (they arrive realtime-paced
  from the provider).
- Barge-in / pause: added OutboundFramePacer.clear() and call it on every
  send_clear cancel path so the queued (cancelled) audio is dropped in the
  same beat and cancel latency stays low.
- Playback tracking driven from the pacer's REAL emitted (non-silence) frames
  x 20 ms so the heard-prefix estimate stays exact under pacing; the
  byte-estimate path is retained unchanged for the unpaced default.

TDD: mirrored Python/TS unit tests for clear(), fixed-frame 20 ms grid,
silence gap-fill, barge-in drop, byte-identical default-OFF direct send, and
emitted-frame playback tracking. Full suites green (Python 2957 passed,
TS 2364 passed, tsc + ruff clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJVkGyc6zocoh4Shiw6dBe
Truncate the interrupted turn's history to what the caller ACTUALLY
heard — including mid-sentence — instead of rounding up whole sentences
from a byte estimate.

- Segment record extended to (text, start_s, dur_s): each RESPONSE
  sentence carries its own playout duration, finalized when the next
  sentence stamps; the last/in-flight sentence resolves its extent from
  the live playback total. Stamping centralized in one helper.
- heard_response_split includes fully-heard sentences verbatim plus the
  heard fraction of the sentence in flight, split on a word boundary
  (proportional-by-word, rounded half up). heard_response_prefix keeps
  its stable (heard, everything) shape; the richer split exposes the
  unsaid remainder for the coming re-delivery feature.
- Played-position ladder, highest tier first: (i) Twilio carrier-
  confirmed seg_<n> marks — emitted per sentence, advanced in on_mark
  without disturbing fm_* first-message flow control; (ii) paced
  emitted-frame cursor; (iii) byte estimate. Telnyx/Plivo have no
  reliable marks and fall to (ii)/(iii).

Strict Python/TypeScript parity. No version bump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJVkGyc6zocoh4Shiw6dBe
When the agent is interrupted mid-answer and the un-heard remainder still
matters, inject a one-shot system nudge on the next turn so the LLM
re-addresses the caller's new message first and then resumes the unfinished
point naturally — instead of silently dropping what the caller never heard.

- New Agent.redeliver_interrupted / AgentOptions.redeliverInterrupted (default
  false ⇒ zero behaviour change) plus an optional redelivery_policy /
  redeliveryPolicy hook.
- services/redelivery: RedeliveryPolicy protocol (mirrors BargeInStrategy),
  default MinUnsaidWordsPolicy (gates on heard_everything=false AND >=
  DEFAULT_MIN_UNSAID_WORDS un-heard words), and build_redelivery_nudge.
- Capture (heard, unsaid) on a confirmed barge-in right after the completed-
  turn truncation, before the playback cursor is reset; store on the handler.
- Inject on the next turn via LLMLoop.set_redelivery_nudge / setRedeliveryNudge
  as a SECOND system message in _build_messages (same pattern as the rolling
  compaction summary); consumed on first build so it is strictly one-shot.
- Pipeline-mode only; realtime/ConvAI have no heard/unsaid split and no-op.

Strict Python/TypeScript parity. Tests: +18 Python, +17 TypeScript.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJVkGyc6zocoh4Shiw6dBe
…r, EOT, barge-in)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D52f6rBsvzicXg45C6MKVM
Comment thread libraries/python/getpatter/providers/namo_turn_detector.py Fixed
nicolotognoni and others added 2 commits July 2, 2026 09:46
Bandit B615 flagged AutoTokenizer.from_pretrained() in the NAMO turn
detector for downloading without an immutable revision pin. Thread an
optional revision through the loader (PATTER_NAMO_REVISION env var or
revision= argument on load()) in both SDKs so a Hub repo id can be
pinned to a commit/tag. The documented default source is a local,
operator-provided model directory, not an unpinned Hub download, so a
scoped nosec covers that residual case where revision is legitimately
None.
Resolves the release-0.7.1 (#202) integration: keeps #208's Unreleased
entries above the new 0.7.1 CHANGELOG section, adopts 0.7.1 across all
version files (no additional bump), and regenerates package-lock.json from
the merged package.json (Gemini deps + NAMO onnxruntime/transformers).
Full suites green post-merge: Python 3066, TypeScript 2476, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D52f6rBsvzicXg45C6MKVM
@nicolotognoni nicolotognoni merged commit f3142a7 into main Jul 2, 2026
14 checks passed
nicolotognoni added a commit that referenced this pull request Jul 2, 2026
Integrates releases #202 (0.7.1) and #208 (low-latency pipeline) now on main.
Keeps this PR's transfer-allowlist Unreleased entry alongside #208's entries
under the single Unreleased section, adopts 0.7.1 across version files (no
extra bump), and regenerates package-lock.json from the merged package.json.
Full suites green post-merge: Python 3083, TypeScript 2493, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D52f6rBsvzicXg45C6MKVM
@github-actions github-actions Bot deleted the feat/low-latency-voice-pipeline branch July 3, 2026 07:21
nicolotognoni added a commit that referenced this pull request Jul 3, 2026
Version files already bumped to 0.7.1 by #202; #206 (transfer allowlist) and
#208 (low-latency voice pipeline) then merged onto main at 0.7.1 but their
entries sat under Unreleased. Fold them into the 0.7.1 section so the published
release notes match the code that ships. No version change (already 0.7.1); no
code change. Merge this, then tag v0.7.1 to publish to npm + PyPI.


Claude-Session: https://claude.ai/code/session_01D52f6rBsvzicXg45C6MKVM

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants