Docs Python SDK

Integrations

Python SDK

The tenms package: in-process engine, licensing, telemetry, Pipecat, LiveKit and the Edge client.

CPU-only audio layer for voice agents, in process (Tier 1):

Status: DSP baseline. Every module runs hand-tuned signal processing (spectral echo/noise suppression, prosodic end-of-turn, heuristic quality scores). The engine has a slot for trained weights (model_path=, tenms-gru-v1), but no trained model ships yet. The thresholds were tuned on synthetic signals and checked in a handful of live calls. Read Limitations before you rely on any of the numbers below.

ModuleWhat you get
Focuscaller audio with agent echo removed (needs the agent’s playback as reference), noise suppressed, other voices attenuated
TurnVAD, turn_prediction (“prepare”: start the LLM early), turn_end, interruption kinds barge_in / backchannel / noise / other_speaker
Pulselive quality (quality events) and a CallSummary at the end of the call (MOS, SNR, echo, ASR risk, failure attribution)

The engine is Rust (crates/tenms-core) behind a PyO3 abi3 extension (tenms._native, CPython ≥ 3.10). It works in 10 ms frames at 8/16/24/48 kHz and allocates nothing per frame. Compute is ~10–40 µs per frame at p50 and up to ~120 µs at p99 (see Latency notes).

pip install tenms                    # core SDK (in-process engine, session tokens, telemetry)
pip install "tenms[edge]"            # + Tenms Edge WebSocket client (tenms.edge)
pip install "tenms[pipecat]"         # + Pipecat integration (pipecat-ai 1.15.x)
pip install "tenms[livekit]"         # + LiveKit Agents integration (livekit-agents 1.8.x)
pip install livekit-plugins-tenms    # same LiveKit integration as `livekit.plugins.tenms`

Wheels: CPython ≥ 3.10 (abi3) on Linux x86_64 / aarch64 (manylinux_2_28, glibc ≥ 2.28), macOS x86_64 / arm64 and Windows x86_64. They bundle the listening_mini model (0.22 MB). A license is required: create one at https://app.tenms.cloud and set TENMS_LICENSE (see Licensing).

Engine

import tenms

engine = tenms.Engine(16000, modules=["focus", "turn", "pulse"], focus_profile="stt",
                      turn={"confirm_threshold": 0.85}, focus={"echo_tail_ms": 300})

out, res = engine.process(mic_chunk, reference=agent_playback_chunk)  # any length; bytes or numpy
for ev in res.events:          # tenms.Event: .type, .at_ms, .probability, .silence_ms, .kind, ...
    if ev.type == "turn_prediction": ...           # start the LLM speculatively
    elif ev.type == "turn_end": ...                # commit the user turn
    elif ev.type == "interruption" and ev.kind == "barge_in": ...  # stop TTS (ignore other kinds)

engine.set_agent_speaking(True)                     # enables interruption classification
engine.push_transcript("my order is late", confidence=0.93)
summary = engine.finish("call-123", source="sdk")   # CallSummary as a dict
  • Input: bytes (int16 little-endian PCM) or numpy int16/float32. The output has the same type and length. reference must match mic in type and length when given. The GIL is released while audio is processed.
  • Chunking and delay. Chunks of any length are buffered into 10 ms frames. total_delay_samples = algorithmic_delay_samples + buffer_delay_samples:
    • Algorithmic delay applies with Focus only: 5 ms at 16 kHz, 7 ms at 24/48 kHz, 8 ms at 8 kHz.
    • Buffering delay is 0 when every chunk is a whole number of 10 ms frames. Otherwise, the first misaligned chunk adds a one-time frame_len - 1 samples of silence (< 10 ms), and the delay stays constant from then on.
    • flush() returns the held-back samples.
  • FrameResult covers the frames completed by one call. Fields: frames, at_ms, vad, vad_max, frame_vad, eot_probability, speaker_match, events, compute_us.
  • Focus profile (focus_profile=): "stt" when the output feeds a speech recogniser (echo removal + residual echo suppression + main-speaker gate, noise suppression with a gentle -10 dB floor) or "listening" (full suppression, -20 dB floor; the default of Engine / TenmsSession). The Pipecat and LiveKit components create their session with "stt". Measured with Deepgram nova-3 on the Arena’s 110 noisy / echo-heavy calls: WER 0.233 (stt) vs 0.250 (no Focus) vs 0.255 (listening); full noise suppression alone costs +14 % WER on the noise-only set (docs/MODELS_AND_BENCHMARKS.md). focus={...} options apply on top of the profile; engine.focus_profile / engine.focus_config report what is in effect.
  • Listening tiers (opt-in, audio people listen to - recordings, review, listen-in - never STT input):
    • focus_profile="listening_mini": the "listening" settings plus the Tenms-trained model’s Focus head (tenms-mini-focus-v1.tnw, int8, 0.22 MB, native runtime): about 2x the DSP background removal on music / babble / noise (12.1 vs 5.9 dB in the Arena), same 5-8 ms latency, ~0.04 ms more CPU per 10 ms frame. The model is loaded from listening_mini_model= (a path), else $TENMS_LISTENING_MINI_MODEL, else tenms/models/tenms-mini-focus-v1.tnw inside the installed package (bundled in every wheel, with tenms/models/manifest.json), else the repository’s models/ (tenms._engine.listening_mini_model_path() says which; the native layer applies the same lookup when no path is passed). The native layer verifies its sha256 against the shipped model and raises ValueError on a mismatch; model_path= / $TENMS_MODEL_PATH do not apply to this profile. engine.focus_profile == "listening_mini".
    • focus_profile="listening_pro" (FastEnhancer-M neural enhancer, 27-30 ms) needs ONNX Runtime and is only available through Tenms Edge: EdgeClient(..., focus_profile="listening_pro"); Engine(...) raises ValueError. EdgeClient also forwards "listening_mini".
  • Options: focus={noise_suppression, echo_cancellation, speaker_lock, min_gain_db, other_speaker_gain_db, echo_tail_ms, echo_suppression_db, reference_delay_ms, auto_reference_delay}, turn={prepare_threshold, confirm_threshold, max_silence_ms, min_silence_ms, min_turn_speech_ms, barge_in_ms, backchannel_max_ms, echo_aware, barge_in_voiced_ms, burst_window_ms, burst_merge_ms, echo_gate_db, transcript_confirm_words}, model_path= (a tenms-gru-v1 .tnw file).
  • push_transcript() returns the interruption it decided while the agent speaks ("barge_in" / "backchannel" / None); push_agent_text(text) feeds the agent’s TTS text; echo_delay_ms, reference_delay_ms and recent_caller_voiced_s are diagnostics.

Licensing

Tokens are Ed25519-signed and verified offline. Audio never waits on the network.

engine = tenms.Engine(16000, license=token, public_key=PUBLIC_KEY_B64URL)
# or set TENMS_LICENSE / TENMS_LICENSE_PUBLIC_KEY in the environment

The engine runs the modules you request that the license also allows. A malformed, forged or expired token raises tenms.LicenseError. tenms.verify_license(token, key) returns the token’s claims.

Without a license, the wheels on PyPI (release builds) raise tenms.LicenseError from Engine(), TenmsSession and the Pipecat / LiveKit components: tenms: a license is required. Pass license=... or set TENMS_LICENSE; create a license at https://app.tenms.cloud .... Unlicensed development mode (every module, a warning logged once) exists only in source builds made with maturin develop --features dev-unlicensed (see Development); tenms.DEV_UNLICENSED says which kind of build is installed. With public_key= (or $TENMS_LICENSE_PUBLIC_KEY) a token is verified against that key instead of the Tenms vendor keys: that path is for self-hosted deployments that issue their own licenses.

tenms.Engine.from_vendor_license(token, sample_rate) verifies a Tenms-issued license against the vendor key pinned in the native library and the system clock, so you pass no public key. It needs a native build with Engine::from_vendor_license. Older builds raise NotImplementedError.

Session tokens for browsers (ElevenLabs bridge)

A browser that talks to the Edge ElevenLabs bridge (wss://edge.tenms.cloud/v1/elevenlabs/stream?agent_id=…&token=…) must never see your license. Your backend exchanges the license for a short-lived session token at the control plane ($TENMS_CONTROL_URL, default https://app.tenms.cloud): POST <control>/v1/session-tokens with the license as bearer returns {token, expires_at}. Only that token goes to the browser:

# backend, e.g. FastAPI; TENMS_LICENSE is set in its environment (TENMS_CONTROL_URL optional)
import os
from fastapi import FastAPI, HTTPException
from tenms.control import ControlError, amint_session_token

app = FastAPI()

@app.post("/api/voice-session")
async def voice_session(agent_id: str):
    # authenticate *your* user here before minting
    try:
        token, expires_at = await amint_session_token(os.environ["TENMS_LICENSE"], ttl_secs=300)
    except ControlError as e:          # e.fatal (unauthorized / forbidden: bad, revoked or expired license) -> don't retry
        raise HTTPException(503 if e.retryable else 403, e.code)
    return {"signed_url": f"wss://edge.tenms.cloud/v1/elevenlabs/stream?agent_id={agent_id}&token={token}",
            "expires_at": expires_at}   # unix seconds
// browser: @elevenlabs/client, WebSocket transport (WebRTC bypasses Edge)
const { signed_url } = await (await fetch("/api/voice-session?agent_id=" + agentId, { method: "POST" })).json();
const conversation = await Conversation.startSession({ signedUrl: signed_url, connectionType: "websocket" });

mint_session_token(license, control_url=None, *, ttl_secs=None) is the blocking variant. Both return (token, expires_at) and retry network errors, 429 and 5xx (honouring Retry-After).

  • Request body: {"ttl_secs": n} when given, otherwise empty. The control plane rejects unknown fields.
  • Lifetime: capped by the server’s session-token TTL and by the license’s expiry.
  • Scope: the token inherits the license’s modules, ElevenLabs agent allowlist and max_sessions. There is no per-request agent choice. A session token cannot mint further session tokens (forbidden).
  • Errors: tenms.control.ControlError carries the server’s own code, raised straight away for fatal errors. The control plane’s errors look like {"error": {"code", "message"}}, e.g. 401 unauthorized “invalid token”. Edge’s look like {"error": code}. Both shapes are parsed.

Mint one token per conversation, just before connecting. When the bridge reports license_expired, mint a new one. The endpoint is live: a production check returned 401 unauthorized for an invalid token, parsed as a fatal error.

Endpoints

ServiceDefaultOverrideFallback while tenms.cloud DNS propagates
Tenms Edge (tenms.edge.EdgeClient)wss://edge.tenms.cloudTENMS_EDGE_URLwss://edge.tenms.cloud
Control plane (session tokens, telemetry, dashboard)https://app.tenms.cloudTENMS_CONTROL_URLhttps://app.tenms.cloud

tenms.endpoints has the constants (DEFAULT_EDGE_URL, RAILWAY_EDGE_URL, …) and edge_url() / control_url() (argument, else environment, else default).

Telemetry

When a call ends, its CallSummary is POSTed to $TENMS_CONTROL_URL/v1/calls:batch, authenticated with the license token. Uploads are batched, run on a background thread, retried on network errors, 429 and 5xx, and logged and dropped after the retries run out. submit() never blocks the caller and never raises.

from tenms.telemetry import ControlPlaneReporter
reporter = ControlPlaneReporter()             # url from TENMS_CONTROL_URL, token from TENMS_LICENSE
reporter.submit(summary)                      # O(1); safe from audio callbacks

TenmsSession (used by both integrations) reports automatically only when TENMS_CONTROL_URL is set (opt-in: set it to https://app.tenms.cloud to see your calls in the dashboard). Pass reporter=False to disable this, or pass your own reporter (ControlPlaneReporter(tenms.endpoints.control_url())).

Pipecat

from tenms.pipecat import TenmsCall

tenms = TenmsCall()  # one per call; options: modules, license, focus_profile ("stt"), focus, turn, call_id, ...

transport = DailyTransport(room_url, token, "bot", DailyParams(
    audio_in_enabled=True, audio_out_enabled=True,
    audio_in_filter=tenms.focus_filter(),         # Focus + drives the engine
    audio_out_filter=tenms.reference_filter(),    # agent playback -> echo reference (as written)
))
user_agg, assistant_agg = LLMContextAggregatorPair(context, user_params=LLMUserAggregatorParams(
    vad_analyzer=tenms.vad_analyzer(),                  # gated VAD
    user_turn_strategies=tenms.user_turn_strategies(),  # Tenms start + TurnAnalyzer stop strategy
))
proc = tenms.processor()
pipeline = Pipeline([transport.input(), stt, proc, user_agg, llm, tts, transport.output(), assistant_agg])
task = PipelineTask(pipeline, observers=[tenms.observer()])   # bot text, for echo-transcript rejection

@proc.event_handler("on_call_summary")
async def on_summary(processor, summary): ...
  • TenmsFocusFilter (BaseAudioFilter) processes every input chunk once. With Focus on, it returns the cleaned audio, tuned for the STT behind it (focus_profile="stt"; TenmsCall(focus_profile="listening") for full suppression). FilterEnableFrame(False) makes it pass audio through while analysis continues.
  • TenmsVADAnalyzer (VADAnalyzer) reports the engine’s VAD. While the bot speaks, the VAD reads as silence until the engine classifies a barge-in. Backchannels, noise and other speakers therefore never produce VADUserStartedSpeakingFrame, so they never interrupt.
  • TenmsUserTurnStartStrategy replaces Pipecat’s default start strategies. The default transcription fallback would turn every “mm-hmm” transcript into an interruption. This strategy starts the turn on gated VAD, or immediately on barge_in.
  • TenmsTurnAnalyzer (a streaming BaseTurnAnalyzer) returns COMPLETE on the engine’s turn_end. Use it via TurnAnalyzerUserTurnStopStrategy.
  • TenmsProcessor goes after STT, before the user aggregator. It:
    • tracks BotStarted/StoppedSpeakingFrame
    • feeds final transcripts to Pulse
    • replaces transcripts of backchannels with UserBackchannelFrame, so they stay out of the LLM context
    • drops transcripts of noise, other speakers and the bot’s own echo
    • pushes TenmsTurnPredictionFrame, TenmsInterruptionFrame and other TenmsEventFrames downstream
    • on EndFrame / CancelFrame, first delivers the engine events still queued (on_tenms_event and frames, ahead of the End/Cancel frame, for at most flush_timeout = 1 s), then reports the CallSummary

LiveKit Agents

from livekit.agents import AgentSession, room_io
from livekit.plugins import tenms   # or: from tenms.livekit import ...

async def entrypoint(ctx):
    call = tenms.TenmsLiveKit()
    session = AgentSession(
        stt=deepgram.STT(), llm=openai.LLM(model="gpt-4o-mini"), tts=elevenlabs.TTS(),
        vad=call.vad(),
        turn_handling={"turn_detection": call.turn_detector(),
                       "interruption": {"mode": "vad", "min_duration": 0.3}},
    )
    await session.start(
        agent=tenms.TenmsAgent(call, instructions="..."),
        room=ctx.room,
        room_options=room_io.RoomOptions(audio_input=room_io.AudioInputOptions(
            noise_cancellation=call.noise_cancellation())),
    )
    call.attach(session)          # agent speaking, echo-reference output tap, transcripts, summary
  • TenmsFocus is an rtc.FrameProcessor[rtc.AudioFrame], the same interface as the Krisp and ai-coustics plugins, used through AudioInputOptions(noise_cancellation=...). Its session uses focus_profile="stt" unless told otherwise (TenmsLiveKit(focus_profile="listening")). It returns frames with the same size, rate and channel count as its input:
    • Stereo and multichannel tracks are averaged to mono for the engine, and the cleaned signal is written to every channel.
    • A track whose rate differs from the engine rate (e.g. TenmsLiveKit(sample_rate=16000) on a 24 kHz track) is resampled both ways. This adds a few ms of resampler delay.
    • With focus.enabled = False, LiveKit stops calling the processor and the audio passes through unchanged. The engine keeps running: VAD / TurnDetector take over feeding it, so VAD, turn detection and Pulse stay live. They also take over when the processor was created but never installed on a track.
  • VAD is a livekit.agents.vad.VAD with the same gating as the Pipecat VAD. The moment the engine decides a barge-in, the VAD reports START_OF_SPEECH, even if the caller is between words. That decision can come from audio, or from a transcript that confirms it, such as an interim “Wait.”. The VAD credits the voiced time the engine already heard: at least turn.barge_in_ms, at least LiveKit’s interruption.min_duration (read by attach()), at most 1 s. LiveKit’s VAD interruption then fires at once, without waiting for the final transcript.
  • TurnDetector is a streaming turn detector (stream() → push_audio / predict). It is prosodic, so it works for any language:
    • Its prediction resolves on the engine’s turn_end.
    • While STT still owes the final for an interim it already sent, the answer is held until that final arrives (at most max_stt_wait = 1 s). LiveKit therefore commits the turn with the caller’s last words.
    • It always answers before LiveKit’s prediction_timeout. If the engine has not decided 0.25 s before the deadline, it answers with the engine’s current end-of-turn probability, so LiveKit never logs “eot prediction timed out”.
  • TenmsAudioOutput wraps session.output.audio and records what is queued for playout as the echo reference. It follows livekit-agents 1.8 semantics:
    • pause() can be resumed. LiveKit pauses on a possible interruption and resumes if it was false. The room output stops forwarding, but the ~200 ms already in its rtc.AudioSource queue still plays (pause_drain_ms). The reference after that point is held.
    • resume() continues the held reference from the moment playback resumes.
    • clear_buffer() drops the reference audio that hasn’t played yet, whether paused or not.
  • TenmsAgent overrides stt_node, because LiveKit interrupts on any final transcript while the agent speaks. It drops transcripts of backchannels, noise, other speakers and the agent’s echo. It also overrides tts_node to record the agent’s text.

Tenms Edge client (Tier 3)

from tenms.edge import EdgeClient   # pip install "tenms[edge]"

async with EdgeClient(token=license_token, sample_rate=16000,  # url: $TENMS_EDGE_URL, else wss://edge.tenms.cloud; focus_profile="listening" / "listening_mini" / "listening_pro" opt.
                      on_event=lambda ev: print(ev)) as edge:
    await edge.send_reference(agent_chunk)      # optional, aligned with the mic stream
    await edge.send_audio(mic_chunk)            # int16 bytes or numpy
    await edge.set_agent_speaking(True)
    await edge.push_transcript("hello", confidence=0.9, is_final=True)   # is_final defaults to False, like the wire
    processed = edge.audio_nowait()             # Focus output (tag 0x01)
    summary = await edge.end()                  # CallSummary dict

The client implements start/ready, binary tags 0x01 (mic) and 0x02 (reference), and JSON events (speech_start, turn_prediction, turn_end, interruption, quality). It also handles end → summary, and error messages (EdgeError). Event objects are the same tenms.Event as in-process.

Errors come from three places: HTTP upgrade rejections ({"error": code} body), error messages, and close reasons. Each becomes an EdgeError with the server’s code (plus .status, .close_code and .retry_after when the server sends them):

codeclasswhat to do
license_revoked, agent_not_allowed, invalid_license, missing_license, bad_requestfatalnever retried; fix credentials / config
license_expiredfatal, needs_new_tokenmint a new session token or renew the license, then reconnect
rate_limited, at_capacity, draining, server_shutdown, network errorsretryableback off and reconnect

EdgeClient(..., connect_retries=3, retry_backoff=0.5) retries retryable failures inside connect(), using exponential backoff or the server’s Retry-After. Fatal errors raise straight away, and after a fatal error the client refuses further sends. The classification lives in tenms.control (is_fatal, is_retryable).

How the integrations decide what is a real interruption

The engine decides (the same logic serves Tenms Edge and the ElevenLabs bridge, which use the core directly):

  • Echo-reference alignment. The SDK places the agent’s audio on a wall-clock playout timeline (tenms.reference.PlaybackTimeline). The engine estimates the remaining echo delay online (multi-band envelope correlation, 0-1000 ms) and re-aligns the reference (engine.echo_delay_ms, engine.reference_delay_ms).

    • The mic stream is anchored to the timeline by its earliest arrival over the last 2 s. Event-loop stalls only ever delay chunks, so they no longer shift the reference. A shift is an echo-delay jump the engine must re-learn: on a loaded machine, 16 of them let leaked echo become a false barge-in.
    • A real gap in the mic stream re-anchors once it has lasted 0.8 s.
    • Audio queued far ahead of playout (a whole TTS reply) is kept until it has played.
  • Echo suppression over codecs. Opus leaves too little waveform for a linear canceller. At 24 kb/s the decoded echo matches the reference with only ~6.5 dB SNR, so the linear stage alone removes at most ~6.5 dB, and ~0 dB when the delay drifts. A phase-insensitive (power-domain) echo model therefore drives the residual-echo suppressor. Total Focus suppression measured:

    • offline (crates/tenms-core/README.md; echo-only, reference-active blocks after the first 3 s): 34–39 dB on the synthetic realistic paths, and 31.4 / 38.7 dB with real Opus on both legs
    • live, mean over the frames where the reference was active (SDK echo_analysis):
      • Pipecat over Daily: 29.3 / 33.0 dB in the last two runs
      • LiveKit (local livekit-server, simulated -14 dB acoustic echo): 31–34 dB
    • before this suppressor: 11–19 dB offline, and 16.5 / 18.7 dB live

    This is suppression, not cancellation (see Limitations).

  • Residual-echo gate. While the reference is active, frames > 20 dB and bursts > 17 dB below the echo estimate are ignored by Turn.

  • Barge-ins are decided per utterance. Voiced evidence accumulates across bursts that start within 2 s (turn.burst_window_ms) of the first (“Wait, … stop please” is one barge-in); single acknowledgements (“uh-huh”, “yeah”, “okay”) stay backchannels; at most one barge-in per agent utterance. Turn counts in the summary are final.

  • Transcript confirmation. TenmsSession.lexical_barge_in(text) hands interim/final transcripts heard during agent speech to the engine:

    • An intent word (“stop”, “wait”, “hold”, “no”, “sorry”, “actually”, …) in a transcript of up to 4 words confirms a barge-in as soon as the caller is audible.
    • So do ≥ 2 non-acknowledgement words that are plausible for the caller’s voiced time and are not the agent’s own words (note_agent_text, fed from TTSTextFrame / the TTS node).
    • Acknowledgements only make it a backchannel.
  • Echo transcripts are dropped (SDK): a transcript that matches the agent’s recent TTS text is dropped, as is one with more words than the caller could have said. The exception is a late STT final that continues words the caller said before the agent’s current speech began: it is always passed on.

The old SDK-side heuristics (escalate_bursts, echo_burst_margin_db, the GCC-PHAT reference estimator) are superseded by the engine; the session arguments are accepted and ignored.

Latency notes

Live numbers come from two sources, both on 2026-11-05 with Deepgram + gpt-4o-mini + ElevenLabs, all modules, on Apple M-series:

  • the latest Pipecat-over-Daily run (tests/live_reports/pipecat_daily_latest.json); ranges cover the runs that day, and “after the fixes” means the three runs after the reference-anchoring fix
  • the LiveKit runs against a local livekit-server (tests/live_reports/livekit_local_*.json); “latest” is the final run after the fixes below

Offline numbers come from crates/tenms-core/README.md.

  • Compute per 10 ms frame:
    • Pipecat, after the fixes: p50 14–25 µs, p99 78–196 µs; earlier runs: p50 10.5–24 µs, p99 35–240 µs. The higher values come from runs on a loaded machine.
    • latest LiveKit run: p50 13.8 µs, p99 61 µs; the last three runs: p50 12.5–13.8 µs, p99 59–65 µs
    • offline, 16 kHz with an echo reference: p50 30 µs, p99 46 µs (DSP only)
    • The higher live p99 comes from the newer power-domain echo path and from scheduling noise. The engine budget is p99 < 1 ms, and the product target is < 150 µs.
  • Audio delay: 5 ms at 16 kHz with Focus (7 ms at 24/48 kHz, 8 ms at 8 kHz; 0 without Focus). Buffering delay is added only for misaligned chunks.
  • Turn: turn_prediction arrives ~150–300 ms after the caller stops. turn_end arrives ~350–900 ms after (prosodic; at most max_silence_ms). Live p50 / p95: 360 / 610 ms (350–360 / 610 over the four runs).
  • Interruptions:
    • offline TTS fixtures over realistic echo (core README):
      • a continuous barge-in is decided after 800 ms of voiced speech
      • “Wait, stop please. Actually…” is one barge-in 220–320 ms after the onset of “stop”
      • an intent word in an interim transcript decides at once
    • live Pipecat, caller speech start to interruption: 0.99–1.07 s after the fixes (0.39–1.2 s before).
      • Deepgram sends “Wait.” as a final with no interim, so the confirmation waits for STT. The acoustic rule needs 800 ms of continuous voicing.
      • One run on a loaded machine failed (2.9 s): a false barge-in, caused by reference shifts during event-loop stalls, had already stopped the bot. That is fixed; see Echo-reference alignment.
    • live LiveKit, barge-in start to the agent stopping: 0.88 s in the latest run, 0.65–1.06 s over the last three runs (before the fixes: 2.2 s). “Wait.” is confirmed by its interim transcript, and the VAD credit lets LiveKit interrupt at once.
    • No backchannel interrupted the agent in the latest Pipecat and LiveKit runs.
  • First response (caller stops → agent audio): 1.9–2.4 s in the LiveKit runs, and 2.3–2.8 s in the Pipecat runs after the fixes (2.2–3.1 s before). Most of it is LLM + TTS.

Releasing

Packages: tenms (this directory; Rust extension, abi3 wheels) and livekit-plugins-tenms (livekit-plugins-tenms/, pure Python). .github/workflows/python-release.yml builds and tests both and publishes them with PyPI Trusted Publishing (no API tokens).

One-time setup (repository Cenrax/tenms):

  1. https://pypi.org → log in → Your account → Publishing → Add a new pending publisher (GitHub). Add it twice, once per project name:
    • PyPI project name tenms, owner Cenrax, repository tenms, workflow python-release.yml, environment pypi
    • PyPI project name livekit-plugins-tenms, same owner / repository / workflow / environment
  2. The same on https://test.pypi.org (a separate account), with environment testpypi.
  3. GitHub → repository → Settings → Environments: create pypi (recommended: required reviewer = you, deployment tags py-v*) and testpypi.
  4. Optional: repository secret the server limit (a Tenms-issued license) so the wheel smoke tests also exercise the vendor-key path; repository variable a server setting to upload the sdist too. The sdist contains the engine’s Rust sources, so it is not uploaded by default (wheels only); it is still built and tested.

Release:

  1. Bump the version in pyproject.toml, tenms/__init__.py, livekit-plugins-tenms/pyproject.toml, livekit-plugins-tenms/livekit/plugins/tenms/version.py and [workspace.package] in the root Cargo.toml (the workflow fails if they differ or do not match the tag).
  2. Optional dry run: Actions → python-release → Run workflow (publishes to TestPyPI), then pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ tenms.
  3. git tag py-v0.1.0 && git push --tags
  4. The workflow builds wheels for manylinux_2_28 x86_64 / aarch64, macOS x86_64 / arm64 and Windows x86_64, plus the sdist and livekit-plugins-tenms; runs the offline suite against the installed wheel (Linux x86_64, macOS; smoke test for aarch64 under QEMU, Python 3.10, Windows and the sdist build); twine check; then uploads tenms, then livekit-plugins-tenms, to PyPI (after the pypi environment’s approval, if configured).

A version can be uploaded once: to fix a broken release, bump the version (or yank it on PyPI).

Limitations

This is a DSP baseline. Most of these come from the engine; see “Honest limitations” in crates/tenms-core/README.md.

  • No trained model. Every decision is a hand-tuned DSP heuristic: Focus gains, VAD, end of turn, interruption kinds and the Pulse MOS / SNR / ASR-risk scores. The thresholds were tuned on synthetic signals and checked in a few live calls. Real rooms, voices and codecs will shift the operating points. The model_path slot accepts tenms-gru-v1 weights, but none ship yet.
  • Interruption classification is imperfect:
    • Open-vowel acknowledgements (“uh-huh”, “yeah”) said at full voice are classified as barge-ins. A nasal “mm-hmm” is a backchannel.
    • Without a transcript, a short real barge-in can be taken for a backchannel. In the latest Pipecat run the caller’s “Wait.” was suppressed as a backchannel, and the barge-in only fired on “Stop, please.” That is about 1 s later. With LiveKit the interim “Wait.” now confirms it.
    • One continuous burst is decided by voiced duration (800 ms), so a drawn-out “yeeeah” is a barge-in. A continuous “hold on” waits 800 ms unless a transcript confirms it earlier.
  • Echo is suppressed, not cancelled.
    • Over Opus, the linear canceller alone removes at most ~6.5 dB (~0 dB with a drifting delay). The power-domain suppressor brings the total to ~30–39 dB (see above). It attenuates near-end speech at or below the echo level during double talk.
    • Echo-only transcripts still reach STT sometimes. The SDK drops those that match the agent’s text.
    • Before the delay estimator locks (~0.85 s of agent speech), Turn holds its decisions on caller activity heard while the reference is active (pre-lock hold). A real barge-in in the agent’s first second is decided later (core README: p50 ≈ 1.1 s).
  • End of turn uses prosody only. There are no lexical or semantic cues: “my number is 4 1 5 …” can end a turn. Expect 0.35–0.9 s, not the ~200 ms a trained model can reach. A pause between sentences can end the turn early. The LiveKit TurnDetector holds its answer for a pending STT final (≤ 1 s), but an STT that lags more than that still splits the turn.
  • The speaker lock is weak. It uses pitch and cepstrum statistics, so it separates clearly different voices only. It enrols on the first second of confident speech.
  • Pulse scores are uncalibrated. MOS and ASR risk are heuristic mappings, not checked against listening tests or WER. The SNR estimate reads high at very low SNR.
  • The echo reference assumes a real-time sink. The SDK assumes the output plays queued audio in real time, and for LiveKit that a pause keeps playing ~200 ms (pause_drain_ms). Other sinks need reference_delay_ms / pause_drain_ms tuned to them.
  • Test coverage:
    • Live tests have run against Pipecat + Daily and against a local livekit-server with a scripted caller. Neither has had real rooms or real phones.
    • The LiveKit integration is also tested offline against the real livekit-agents 1.8 classes.