Co-authored-by: Aaron D. Lee <himself@adlee.work> Co-committed-by: Aaron D. Lee <himself@adlee.work>
61 KiB
Rutster slice 5 — Rented transport: a real phone number via Twilio Media Streams
- Status: Draft (pending review)
- Date: 2026-07-05
- Spearhead step: 5 of 6 (ADR-0007 — rent the transport, no first-party SIP)
- Origin: ADR-0007 (2026-06 strategic-relevance review — owns no SIP stack; carrier/PSTN reach is rented transport in three layers; rutster owns only the top one). The spearhead list (PORT_PLAN §Phasing) puts step 5 as the "real phone number" demo.
- Depends on (already merged):
- slice 1 — WebRTC media loopback — the media
core +
AudioPipetrait - slice 2 — The agent tap —
TapAudioPipe(REUSED as the trunk leg's brain-side AudioPipe) and the "core-authoritative playout buffer" invariant - 2026-06-30 slice-3 realtime brain —
MockRealtimeBrain, thespeech_started/speech_stoppedadvisory events - slice 4 — Barge-in / VAD-driven playout kill —
Reflex<P>+LocalVadReflex<P>decorators (decoratingTapAudioPipereused identically on the trunk leg) + theMediaThreadstd-thread graduation - 2026-07-04 slice-5/seams (the infra slice, NOT this slice despite the same number):
config.rsenv-parser pattern,event_sink.rs,MediaCmd::Stats, non-blocking tap teardown, the drain lifecycle, the advertised media address (MediaAddressConfig) — every one of these seams this slice inherits
- slice 1 — WebRTC media loopback — the media
core +
- Naming note: this is spearhead "step 5" (real phone number). It is NOT the merged "slice-5/seams" infra plan (which pre-paves this slice per its "Naming note"). See the strategic plan §5.5.
- Related: ADR-0008 (FOB/green-zone doctrine — split for this slice: FOB owns the WebSocket ingress + codec + the trunk-side tick driver; green-zone owns the REST call-control client), ADR-0009 (provider credentials never reach the brain), ARCHITECTURE.md §"Media plane" (the trunk leg participates in the same 20 ms tick on the dedicated thread)
TL;DR
Stand up spearhead step 5: a real phone number with no first-party SIP stack. The MVP is Twilio Media Streams as a CPaaS raw-audio fork (layer 1 of ADR-0007's three-layer doctrine): Twilio answers the PSTN call + forks its audio over a WebSocket to rutster; call control (answer / hangup / originate) is Twilio's REST API, completely external to the FOB.
The PSTN audio enters the FOB reflex loop as a second-leg-kind alongside WebRTC — but the
reflex stack + AudioPipe are reused unchanged. The architectural simplification this slice
deliberately exploits: a MediaThread session is the wrapped stack
LocalVadReflex<Reflex<TapAudioPipe>>, where the inner TapAudioPipe was introduced in slice-2
as an abstraction over "the brain's WS audio." That SAME TapAudioPipe serves a trunk leg
identically — the only difference is what fills its inbound caller-PCM mpsc: str0m's
MediaData event (for WebRTC) versus the TwilioMediaStreamsServer's WSS-pump task (for trunk).
The leg-kind decides the tick driver function; the wrapped pipe + the brain wiring are
reused verbatim.
A new trunk_driver::drive(&mut TrunkSession, now) parallels loop_driver::drive(&mut RtcSession, now) — same shape, no str0m/Opus/RTP footprint. The slice-4 seam gate stays green: loop_driver.rs
rtc_session.rsbyte-identical because the trunk leg never enters that code path.
Five things this slice deliberately is NOT:
- Not a SIP stack. Not even a thinnest sliver of one. rutster parses zero SIP bytes (ADR-0007).
- Not a managed Voice-AI product integration (Twilio ConversationRelay, Telnyx Voice AI). These would consume the reflex loop that is rutster's differentiation; ADR-0007 explicitly forbids.
- Not a Layer-2 out-of-tree SBC adapter (the on-prem graduation case). ADR-0007 layer 2 is deferred to a graduation rung, not the spearhead's MVP.
- Not a multi-tenant carrier management surface. One Twilio account; one provider config; one active media-streams endpoint per binary. Multi-tenant is later rung.
- Not an inbound endpoint registration server (desk phones). ADR-0007 explicitly out-of-tree and never first-party.
1. Scope
1.1 In scope
crates/rutster-trunk/filled in (currently a stub; ADR-0007 + slice-1 §2.2 pre-paved the crate boundary). Now contains the rented-transport ingress.G711Codec— an in-core µ-law (G.711) codec: encode/decode (table-driven, learner-facing explanation of the µ-law companding formula — a fascinating piece of telephony history) + the 8 kHz ↔ 24 kHz resampling (linear interpolation; rutster'sPcmFrameis 24 kHz, Twilio's raw-audio fork is 8 kHz µ-law). No new dep; pure std.TwilioMediaStreamsServer— a tokio WebSocket server (axum; routable on the existing binary HTTP router) that accepts inbound Twilio Media Streams WSS connections, parses the provider's JSON envelope (connected/start/media/stopper Twilio's documented protocol), and ferries base64-encoded µ-law audio into atokio::sync::mpsc::Sender<PcmFrame>consumed by the FOB media thread.TrunkSession— the per-trunk-leg session struct. ParallelsRtcSessionminus str0m/Opus/ UDP-socket: holds the wrapped pipeLocalVadReflex<Reflex<TapAudioPipe>>(REUSED from slice-4), the inbound-from-Twilio mpsc Receiver, the outbound-to-Twilio mpsc Sender, + bookkeeping (last_idle_rx,next_timeout).trunk_driver::drive(&mut TrunkSession, now) -> Option<Duration>— the trunk-leg tick function. Parallelsloop_driver::driveminus the str0m/Opus/RTP machinery: drains inbound mpsc → callssession.pipe.on_pcm_frame(frame)(which feeds into the wrapped LocalVadReflex<Reflex>, which forwards to the brain via tx_pcm_in as in slice-2); on the outbound boundary pullssession.pipe.next_pcm_frame()+ pushes to the outbound-to-Twilio mpsc. Function lives incrates/rutster-trunk/src/loop_driver.rs(NOT incrates/rutster-media/src/loop_driver.rs— the binary-side trunk crate owns its own driver).CallControlClienttrait +MockCallControlClient+TwilioCallControlClient— the provider call-control abstraction. Answer/hangup/originate operations REST against Twilio's Call Control API (green-zone); the trait locks the seam so the next provider (Telnyx later) is an implementation, not a refactor.TwilioCredentialsconfig struct + env parser — added tocrates/rutster/src/config.rs(slice-5/seams established the pattern). Account SID + auth token- media-streams bind URL + signing-key validation (the MVP validates the connection, not the REST signature — env-configurable to upgrade).
MediaCmd::RegisterTrunkvariant — extends slice-5/seams' existingMediaCmdenum with a variant the binary's REST/routes layer uses when a Twilio Media Streams WSS connection arrives. The MediaThread constructs aTrunkSession, wraps its innerTapAudioPipeinReflex<TapAudioPipe>thenLocalVadReflex<Reflex<TapAudioPipe>>(same composition as slice-4 Task 6 for WebRTC legs), and inserts it in the session map keyed byChannelId.MediaLegenum — incrates/rutster/src/media_thread.rs(binary-side, not media-crate):enum MediaLeg { WebRTC(RtcSession), Trunk(TrunkSession) }. The MediaThread's tick loop dispatches on this enum + calls the corresponding driver. The slice-4 WebRTC code path stays byte-identical becauseloop_driver::driveis called only from theWebRTCvariant's match arm (not changed).- NEW HTTP routes:
POST /v1/trunk/sessions— initiates an outbound call (green-zone path: routes viaTwilioCallControlClientREST; credentials in config, never passed through to the brain).POST /v1/trunk/webhook— Twilio's webhook receiver for inbound-call signaling ("Twilio is calling you back; here's the CallSid — open a Media Streams WebSocket for it"). Optional: if no webhook configured, inbound calls are accept-all.
- Reflex-on-trunk-leg verification test — proves slice-4's
Reflex<TapAudioPipe>+LocalVadReflexdecorate the trunk leg identically to a WebRTC leg; barge-in fires on PSTN caller speech the same as on WebRTC caller speech. The reflex stack IS the same code path, so this test asserts "[trunk leg] behaves like [WebRTC leg] modulo the inbound-PCM-source channel" — a meaningful invariant, not a tautology. - PSTN sim e2e integration test — an in-process Twilio simulator (mock Twilio Media Streams server + MockCallControlClient) drives a synthetic PSTN caller through the FOB reflex loop; asserts barge-in fires + CDR/EventSink emission.
- QUICKSTART + README updates — env-var table for Twilio credentials; "make a real phone call" walkthrough.
1.2 Out of scope (with scheduled return)
| Deferred item | Returns in | Why deferred |
|---|---|---|
| Layer-2 out-of-tree SBC adapter (Kamailio / FreeSWITCH / drachtio + rtpengine) | graduation rung | ADR-0007 layer 2; on-prem sovereignty case; not spearhead-scale. |
| Telnyx (or other provider) raw-media-fork impl | post-step-5 refinement | The trait locks the seam; the second impl is a project, not a refactor. ADR-0007 explicitly lists Twilio OR Telnyx as MVP candidates; we ship Twilio first. |
| Twilio ConversationRelay or other managed Voice-AI product integration | never | ADR-0007 explicit; consuming the reflex loop is a competitive-architecture betrayal. |
| Inbound endpoint registration (desk phones, soft phones) | never first-party | ADR-0007 explicit green-zone / out-of-tree; defeats the WebRTC-first / SSO UX. |
| Outbound registration to a carrier SIP trunk | never first-party | ADR-0007: the rented transport or the out-of-tree SBC owns carrier registration. |
| Multi-tenant Twilio account management | later rung | One Twilio account; SaaS multi-tenant carrier layer is post-rung-2-escalation. |
Media Streams TwiML voice preview / <Gather> / <Say> |
never in core | Twilio-managed Voice-AI redirect — explicit ADR-0007 violation. |
Outbound origination beyond <Dial> semantics (simultaneous ring, etc.) |
later rung | The MVP supports "originate one outbound call" via the REST API; richer origination is contact-center work. |
Authn/authz on the new /v1/trunk/* routes |
slice-6 (spend cap) | Same deferral as slice-1/slice-2 — authn was deferred to step 6. The new routes are not pre-production exposed. |
| TLS / WSS termination on the Twilio Media Streams server binding | post-step-5 | Media Streams sends audio over WSS; Twilio accepts reverse-proxy TLS termination. Localhost dev keeps plaintext WS; production termination is a deployment concern. |
| Spend cap enforcement on originated calls | step-6 (spearhead 6) | The spend/abuse gate (ADR-0009) is spearhead 6; this slice leaves the seam open (CallControlClient::originate accepts an optional spend_token: Option<SpendToken> arg as the pre-paved hook). Not enforced in this slice. |
| DTMF tones through the trunk leg | later rung | Twilio Media Streams does deliver DTMF events; the FOB doesn't yet parse them. Banks/IVR menus need them; that's contact-center-domain work. |
| Fax / T.38 | never | Modern telephony; out of spearhead. |
| Recording on the trunk leg (CDR audio capture) | rung-2 escalation (warm-handoff artifact) | Same deferral as slice 4½'s "no per-call audio recording" call. |
| Concurrent trunk legs beyond proof-of-concept scale (dozens+) | later rung | Same single-thread media-loop cap as WebRTC legs; defer threadpool shard work to the slice 4½ / slice-5-debt graduation tier. |
rubato / speexdsp resampler for 8 kHz ↔ 24 kHz |
post-spearhead | Linear interpolation is the cheap, correct-enough answer for the spearhead MVP — the wedge claim is "the reflex loop works against real phone audio"; the resampler artifacts are below the barge-in trigger threshold (RMS energy). |
2. Architecture delta
2.1 What changes vs slice-4 + slice-5/seams
Slice 5 adds ONE crate-internal fillin (crates/rutster-trunk/ already exists as a stub; this
slice populates it) + ONE new MediaLeg enum + ONE new MediaCmd variant + TWO new HTTP routes.
The fused vertical's existing hot path (loop_driver.rs + rtc_session.rs + MediaThread +
Reflex<P> + TapAudioPipe + LocalVadReflex<P>) is untouched — the trunk leg shares the same
reflex stack, the same TapAudioPipe composition, the same playout buffer mechanics.
┌─ Twilio (cloud; green zone) ────────────┐
│ PSTN caller → Twilio Media Streams │
│ JSON "Start" + "Media" + "Stop" frames │
│ (base64 µ-law @ 8kHz) + REST Call Control │
└────────────────────────┬─────────────────┘
│ inbound WSS (their push)
▼
┌─────────── Rutster trust boundary (FOB) ──────────────────────────────────────────────────┐
│ │
│ axum router (existing) + NEW routes: │
│ POST /v1/trunk/sessions POST /v1/trunk/webhook │
│ │ │ │
│ ▼ ▼ │
│ TwilioCallControlClient TwilioMediaStreamsServer (NEW, FOB) │
│ (green-zone): REST call (axum WebSocket handler) │
│ control via reqwest • accepts Twilio's WSS │
│ • parses JSON envelope │
│ • decodes base64 µ-law via G711Codec → 24kHz PcmFrame │
│ • pushes PcmFrame to a per-call `inbound_from_twilio_tx` │
│ mpsc (Receiver handed to MediaThread via RegisterTrunk) │
│ • credentials NEVER reach the brain (ADR-0009 amendment) │
│ │
│ │ │ │
│ ▼ ▼ │
│ MediaCmd::Originate (NEW) MediaCmd::RegisterTrunk (NEW) │
│ └────────────┐ ┌─────────────┘ │
│ ▼ ▼ │
│ MediaThread's session map: HashMap<ChannelId, MediaLeg> │
│ │
│ MediaLeg enum (NEW, in crates/rutster/src/media_thread.rs): │
│ enum MediaLeg { │
│ WebRTC(RtcSession), // unchanged code path │
│ Trunk(TrunkSession), // new │
│ } │
│ │
│ The std thread tick loop dispatches: │
│ match leg { │
│ MediaLeg::WebRTC(s) => loop_driver::drive(s, now), // UNCHANGED │
│ MediaLeg::Trunk(s) => trunk_driver::drive(s, now), // NEW │
│ } │
│ │
│ trunk_driver::drive (NEW, in crates/rutster-trunk/src/loop_driver.rs): │
│ 1. Drain session.inbound_from_twilio_rx via try_recv │
│ → for each PcmFrame: session.pipe.on_pcm_frame(frame) (hits LocalVadReflex │
│ → Reflex → TapAudioPipe → tx_pcm_in → TapEngine → brain WS) │
│ 2. Outbound tick (every 20 ms): pull session.pipe.next_pcm_frame() │
│ → push to session.outbound_to_twilio_tx (the WSS pump task drains + │
│ encodes µ-law via G711Codec + sends back to Twilio) │
│ 3. Idle timeout check │
│ │
│ TrunkSession (NEW, in crates/rutster-trunk/src/session.rs): │
│ • pipe: LocalVadReflex<Reflex<TapAudioPipe>> ← REUSED from slice-4 verbatim │
│ • inbound_from_twilio_rx: tokio::sync::mpsc::Receiver<PcmFrame> │
│ • outbound_to_twilio_tx: tokio::sync::mpsc::Sender<PcmFrame> │
│ • last_idle_rx, next_timeout, channel-state bookkeeping │
│ │
│ ▼ │
│ loop_driver.rs + rtc_session.rs (byte-identical, untouched — slice-4 seam HOLD) │
│ │
│ CallControlClient trait (NEW, green-zone seam): │
│ async fn originate(&self, to, from, spend_token: Option<SpendToken>) │
│ async fn hangup(&self, correlation_id) │
│ • TwilioCallControlClient: reqwest POST to Twilio REST │
│ • MockCallControlClient: in-process test double │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────┘
2.2 The FOB / green-zone split (load-bearing — ADR-0008 application)
Per ADR-0008, every capability passes one of three tests: hot-path, security-constitutive, or differentiating. Slice 5 splits cleanly:
FOB members (this slice):
TwilioMediaStreamsServer— hot path (its decodedPcmFrames feed the 20 ms tick); the WSS acceptor runs on the tokio runtime but produces PCM for the std thread via mpsc (same cold-path vs hot-path split as slice-2'sTapEngine).G711Codec(µ-law encode/decode + 8 kHz ↔ 24 kHz resampling) — hot path; runs on the 20 ms tick inside the WSS pump task (decode) + inside the WSS pump task's send loop (encode).TrunkSession+trunk_driver::drive— hot path; the per-tick trunk driver is the vtable for the FOB's loop touching the trunk leg.Reflex<TapAudioPipe>+LocalVadReflex<Reflex<TapAudioPipe>>(REUSED from slice-4, instantiated against the trunk leg's TapAudioPipe instance, NO new code).
Green-zone member (this slice):
TwilioCallControlClient— REST API client; not on the per-call hot path (only fires on originate + on inbound-call webhook receipt); arm's length via trait + reqwest dep; the brain never holds Twilio credentials (the slice-5/seams-merged ADR-0009 amendment demands).
2.3 The reflex loop applies symmetrically (the architecture's load-bearing claim)
Slice 4 introduced Reflex<P> + LocalVadReflex<P> as AudioPipe-agnostic decorators (slice-4
§3.2 + §6.4). The wedge argument: the FOB's reflex kills playout when the caller speaks,
regardless of which ingress path the audio came in on. Step 5 tests this by reusing the EXACT
slice-4 stack against a trunk leg: a PSTN caller through a Twilio Media Streams leg must be
barge-in-killed the same way a WebRTC caller is — same Reflex state machine, same
LocalVadReflex RMS-detector input, same barge_in_flush semantics.
If the trunk leg somehow required a parallel "trunk-specific" barge code path, that would be an architectural smell that the slice-4 reflex trait wasn't actually pipe-agnostic — surface it as a question to PM, don't fork the design silently.
2.4 The seam invariant (re-affirmed)
crates/rutster-media/src/loop_driver.rs + crates/rutster-media/src/rtc_session.rs stay
byte-identical through this slice. The slice-4 Task 10 pinned-blob CI gate continues guarding
unchanged. The trunk leg's tick lives entirely in crates/rutster-trunk/src/loop_driver.rs (a
DIFFERENT but parallel-titled file in a different crate). The existing RtcSession codepath
(for WebRTC legs) is untouched.
The MediaLeg enum lives in crates/rutster/src/media_thread.rs (binary-side, not media-crate)
— same locality that already houses the bridge between the binary's axum/runtime and the std
thread. The match arm dispatching MediaLeg::WebRTC(s) => loop_driver::drive(s, now) is one new
line in media_thread.rs (the existing call site for loop_driver::drive redirects through it);
MediaLeg::Trunk(s) => trunk_driver::drive(s, now) is its sibling.
2.5 ADR-0007 honored: rutster parses zero SIP
The hot path's wire surface in this slice is:
- WebRTC RTP/SRTP (slice-1, unchanged — only on WebRTC legs)
- WebSocket TCP frames carrying JSON envelope (Twilio Media Streams protocol — a documented application protocol, NOT SIP)
- HTTP/REST JSON (Twilio Call Control API — outbound only, green-zone)
Zero SIP bytes are parsed. The memory-safety claim (ARCHITECTURE.md §"Memory-safe by construction") remains literally true at the wire: every byte the FOB parses is JSON or RTP, never SIP. ADR-0007's central promise holds for step 5 definitionally, not as a stretch.
2.6 ADR-0009 honored: provider credentials never reach the brain
Per the slice-5/seams-merged ADR-0009 amendment ("enforcement locality vs accounting locality"): the spend gate + provider credentials live INSIDE the trust boundary; the brain is OUTSIDE. Concretely:
TwilioCredentials(account_sid + auth_token) live in the binary's process env, scoped tocrates/rutster-trunk/. TheTwilioCallControlClientreads them at REST-call time.- The actual outbound REST call is made by the binary (
POST /v1/trunk/sessions→ triggersTwilioCallControlClient::originate→ Twilio REST). The brain never sees the URL. - The brain holds ONLY the media fork's WebSocket URL (the address Twilio tells you to listen
on for the audio stream) + the per-call
CallSid(an opaque correlation ID). Neither is a credential.
This slice does NOT enforce spend-caps yet (step 6 lands that) but pre-paves the seam:
CallControlClient::originate takes an Option<SpendToken> parameter (passed as None in this
slice; step 6's spend gate will populate it for real before any originate). The signature locks
the seam so step 6 is additive, not a refactor.
3. Component design
3.1 G711Codec (µ-law encode/decode + resampling)
// crates/rutster-trunk/src/g711.rs
//! # G.711 µ-law codec + 8 kHz ↔ 24 kHz resampling
//!
//! ## Why in-core, not a `g711` crate dep
//!
//! µ-law is ~30 lines of table-driven code. The codec has been standard since
//! 1972 (ITU-T G.711); it has not changed. Pulling a dependency for a
//! constant-mapping table would be a FOB hygiene violation (ADR-0008 —
//! link a maintained lib for *internals* a mature library already solves,
//! but only if the lib is healthy + the complexity is non-trivial; this
//! is neither). The implementation is also learner-facing: the µ-law
//! companding formula is a piece of telephony history worth teaching.
use rutster_media::PcmFrame;
/// 8-bit µ-law to 16-bit linear decode table. Generated from the ITU-T
/// G.711 standard's piecewise-linear decoding curve. Twilio Media Streams
/// delivers µ-law'd 8-bit samples at 8 kHz; we decode each to i16 before
/// resampling. The 256-byte table fits in L1; no memory-pressure concern.
static MULAW_TO_LINEAR: [i16; 256] = include!("./mulaw_decode_table.rs");
/// 16-bit linear to 8-bit µ-law encode table. Used when sending audio
/// BACK to Twilio — the FOB encodes its 24 kHz PCM output → 8 kHz µ-law.
static LINEAR_TO_MULAW: [u8; 65536] = include!("./mulaw_encode_table.rs");
pub struct G711Codec;
impl G711Codec {
/// Decode a Twilio Media Streams "Media" frame payload (already
/// base64-decoded bytes of µ-law samples at 8 kHz) into a 24 kHz
/// PcmFrame (the slice-1 canonical format). 3× linear upsample.
///
/// # Why 3× upsample by linear interpolation
///
/// 24 kHz / 8 kHz = 3. Each input sample becomes 3 output samples:
/// out[3i] = input[i]
/// out[3i + 1] = (input[i] + input[i+1]) / 2
/// out[3i + 2] = (2*input[i] + input[i+1]) / 3
///
/// Linear interpolation is the cheap correct-enough answer for the
/// spearhead MVP; `rubato` (or speexdsp resampler) would be the
/// production-grade answer ADR-0008 admits. The wedge claim is
/// "the reflex loop works against real phone audio"; the resampler
/// artifacts are below the audibility threshold for the barge-in
/// trigger (which only needs RMS energy above `VAD_RMS_THRESHOLD`).
pub fn decode_mulaw_to_pcm(mulaw: &[u8]) -> PcmFrame {
let linear_8k: Vec<i16> = mulaw.iter().map(|&b| MULAW_TO_LINEAR[b as usize]).collect();
// SAFETY: Twilio Media Streams sends 160 µ-law bytes per 20 ms
// (8000 samples/sec * 0.020 sec = 160). debug_assert to detect
// protocol drift; the function still returns a zeroed frame on
// undersized input (hot-path policy).
debug_assert_eq!(linear_8k.len(), 160, "expected 160 µ-law bytes per 20ms frame");
let mut samples = [0i16; 480]; // PcmFrame.samples is fixed-size [i16; 480]
let n = linear_8k.len().min(159);
for i in 0..n {
samples[3*i] = linear_8k[i];
samples[3*i + 1] = ((linear_8k[i] as i32 + linear_8k[i+1] as i32) / 2) as i16;
samples[3*i + 2] = ((2*linear_8k[i] as i32 + linear_8k[i+1] as i32) / 3) as i16;
}
PcmFrame { samples }
}
/// Encode a 24 kHz PcmFrame to 8 kHz µ-law bytes for Twilio Media Streams.
/// Inverse of `decode_mulaw_to_pcm`: 3× downsample by decimation (every
/// 3rd sample) + linear-to-µ-law table lookup.
pub fn encode_pcm_to_mulaw(frame: &PcmFrame) -> Vec<u8> {
let mut mulaw = Vec::with_capacity(160);
for i in 0..160 {
let sample = frame.samples[3 * i];
mulaw.push(LINEAR_TO_MULAW[(sample as u16) as usize]);
}
mulaw
}
}
3.2 TwilioMediaStreamsServer (the WebSocket ingress)
// crates/rutster-trunk/src/twilio_media_streams.rs
//! The inbound WSS server accepting Twilio Media Streams forks.
//!
//! ## Topology
//!
//! Twilio opens an outbound WSS connection TO us when a PSTN call is
//! answered. The connection carries JSON-encoded frames: `connected`
//! (handshake), `start` (metadata: CallSid, stream_sid, custom params),
//! `media` (base64-encoded µ-law audio, both directions on the SAME WS),
//! and `stop` (the call ended). Audio flows in real-time; the JSON
//! envelope is the entire protocol.
//!
//! ## Why tokio, not the std thread
//!
//! The WSS connection is async by nature (TcpListener::accept + WS upgrade
//! + JSON parse + base64 decode per frame). The dedicated 20 ms std-thread
//! is for the LOOP, not for IO. Same split as slice-2's TapEngine: tokio
//! task receives the WS, ferries µ-law → decoded PCM over mpsc to the std
//! thread, which consumes via `TrunkSession::on_pcm_frame` (called from
//! `trunk_driver::drive`).
use axum::{
extract::ws::{Message, WebSocket, WebSocketUpgrade},
routing::get,
Router,
};
use tokio::sync::mpsc;
use rutster_media::PcmFrame;
pub struct TwilioMediaStreamsServer;
impl TwilioMediaStreamsServer {
pub fn router(
register_tx: mpsc::Sender<RegisterTrunkInboundChannel>,
) -> Router {
Router::new()
.route("/twilio/media-stream", get(handle_media_stream))
.with_state(register_tx)
}
}
/// The mpsc abstraction handed from the WSS pump task to MediaThread:
/// a pair of mpsc ends (caller→FOB inbound + FOB→caller outbound) that
/// the TrunkSession::drive uses to talk to the WSS pump task.
pub struct RegisterTrunkInboundChannel {
pub call_sid: String,
pub inbound_from_twilio_rx: mpsc::Receiver<PcmFrame>,
pub outbound_to_twilio_tx: mpsc::Sender<PcmFrame>,
/// The brain's WS URL for THIS call's TapEngine (the operator configures
/// where the brain listens). The TapEngine wiring is exactly like the
/// WebRTC spawn seam from slice-2; the trunk leg's session reveals its
/// tap_url when handed to RegisterTrunk.
pub tap_url: url::Url,
}
async fn handle_media_stream(
ws: WebSocketUpgrade,
axum::extract::State(register_tx): axum::extract::State<mpsc::Sender<RegisterTrunkInboundChannel>>,
) -> impl axum::response::IntoResponse {
ws.on_upgrade(|socket| run_media_stream(socket, register_tx))
}
async fn run_media_stream(mut socket: WebSocket, register_tx: mpsc::Sender<RegisterTrunkInboundChannel>) {
// 1. Wait for "start" frame; extract CallSid + StreamSid + custom params.
// 2. Construct the two mpsc pairs (inbound + outbound, capacity 16 each).
// 3. Send a RegisterTrunkInboundChannel to MediaThread via register_tx.
// Wait for MediaThread's RegisterTrunk reply (a oneshot confirming the
// ChannelId) — the WSS pump then continues.
// 4. Loop:
// a. Read next WS message. If "media" frame, base64-decode the payload,
// G711Codec::decode_mulaw_to_pcm, push PcmFrame into inbound_tx.
// b. Concurrently drain outbound_rx (PcmFrame produced by the std
// thread via trunk_driver::drive): G711Codec::encode_pcm_to_mulaw,
// wrap in a JSON "media" envelope, send WS Text frame.
// c. On "stop" frame OR outbound_rx closed: tear down.
// (Implementation detail; learner-commented per AGENTS.md code style.)
}
3.3 TrunkSession + trunk_driver::drive
// crates/rutster-trunk/src/session.rs
//! The per-trunk-leg session struct. Parallels `RtcSession` minus the
//! str0m/Opus/UDP-socket footprint. The wrapped pipe is REUSED from slice-4
//! verbatim — `LocalVadReflex<Reflex<TapAudioPipe>>` — because a trunk
//! leg's brain audio is the same mpsc pattern as a WebRTC leg's.
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use rutster_media::{PcmFrame, Reflex, LocalVadReflex, ReflexMetrics};
use rutster_tap::TapAudioPipe;
use rutster_call_model::{Channel, ChannelId, ChannelState};
pub const IDLE_TIMEOUT: Duration = Duration::from_secs(60);
pub struct TrunkSession {
pub channel: Channel, // { id: ChannelId, state: ChannelState, started_at, ... }
/// The wrapped reflex stack. REUSED from slice-4 Task 6 composition.
pub pipe: LocalVadReflex<Reflex<TapAudioPipe>>,
/// Caller PCM from Twilio (decoded at 24 kHz by the WSS pump task).
pub inbound_from_twilio_rx: mpsc::Receiver<PcmFrame>,
/// FOB outbound PCM for Twilio (the WSS pump encodes μ-law + sends back).
pub outbound_to_twilio_tx: mpsc::Sender<PcmFrame>,
pub last_idle_rx: Instant,
pub next_timeout: Option<Instant>,
}
// crates/rutster-trunk/src/loop_driver.rs
//! The trunk-leg tick function. Parellels `crates/rutster-media/src/loop_driver.rs`
//! minus the str0m/Opus/RTP machinery — there is no RTP to decode, no Opus
//! to encode, no str0m poll loop to drain. The caller→FOB direction is a
//! pure mpsc drain; the FOB→caller direction is a mpsc push.
//!
//! ## The seam (sacred — slice-4 Task 10 pinned-blob CI gate)
//!
//! This function is a SEPARATE file in a SEPARATE crate. `loop_driver.rs`
//! in `rutster-media` stays byte-identical because the trunk leg NEVER
//! enters that code path. The MediaThread's tick dispatches via the
//! `MediaLeg` enum (in `crates/rutster/src/media_thread.rs`):
//!
//! ```ignore
//! match leg {
//! MediaLeg::WebRTC(s) => loop_driver::drive(s, now), // UNCHANGED from slice-4
//! MediaLeg::Trunk(s) => trunk_driver::drive(s, now), // NEW this slice
//! }
//! ```
use std::time::{Duration, Instant};
use crate::session::{TrunkSession, IDLE_TIMEOUT};
use rutster_media::SAMPLES_PER_FRAME; // Match slice-1's frame size
/// 20 ms tick for outbound encoding (matches slice-1's PCM frame size:
/// 480 samples @ 24 kHz = 20 ms).
pub const OUTBOUND_TICK: Duration = Duration::from_millis(20);
/// One iteration of the trunk-leg driver. Parallels
/// `rutster_media::loop_driver::drive` minus str0m.
pub fn drive(session: &mut TrunkSession, now: Instant) -> Option<Duration> {
// === Step 1: drain inbound caller PCM from Twilio ===
// (The WebRTC equivalent reads UDP + str0m::Input::Receive; the trunk
// equivalent drains a tokio mpsc the WSS pump task fills.)
while let Ok(frame) = session.inbound_from_twilio_rx.try_recv() {
// Push the caller's PCM through the wrapped AudioPipe stack:
// LocalVadReflex<Reflex<TapAudioPipe>>::on_pcm_frame will inspect
// for VAD (slice-4 §3.4 — primary trigger path) + delegate inward
// → Reflex::on_pcm_frame → TapAudioPipe::on_pcm_frame → mpsc
// to TapEngine → brain WS as `audio_in`.
session.pipe.on_pcm_frame(frame);
session.last_idle_rx = now;
}
// === Step 2: outbound encode tick (every 20 ms) ===
if now.duration_since(session.last_outbound_at) >= OUTBOUND_TICK {
if let Some(reply_frame) = session.pipe.next_pcm_frame() {
// Brain reply PCM; push to the WSS pump task for µ-law encode
// + Twilio Media Streams JSON Media frame emission. try_send;
// drop + observe on full (hot-path policy).
if session.outbound_to_twilio_tx.try_send(reply_frame).is_err() {
// Log + bump a counter (TrunkMetrics, slice-5/seams pattern).
tracing::warn!("outbound_to_twilio_tx full; dropping brain reply frame");
}
}
session.last_outbound_at = now;
}
// === Step 3: idle timeout (matches slice-1 §4.5) ===
if now.duration_since(session.last_idle_rx) > IDLE_TIMEOUT {
tracing::info!(channel_id = %session.channel.id, "trunk idle timeout; closing");
session.channel.state = ChannelState::Closed;
return None;
}
// === Step 4: sleep budget ===
// The std thread's meta-tick is 10 ms; this duration tells the next
// tick's worth of sleep (matches loop_driver::drive's contract).
session.next_timeout = Some(now + OUTBOUND_TICK);
Some(OUTBOUND_TICK)
}
3.4 CallControlClient trait + impls (the green-zone seam)
// crates/rutster-trunk/src/provider/mod.rs
//! Provider abstraction: the trait that locks the seam so the NEXT
//! provider (Telnyx later) is an implementation, not a refactor.
//!
//! ## Green zone (ADR-0008)
//!
//! The trait + impls in this module run ARM'S-LENGTH: the call-control
//! client talks to Twilio's REST API over HTTPS (reqwest). The brain
//! never holds Twilio credentials; the FOB never parses SIP; the
//! trait locks both invariants mechanically.
use rutster_call_model::ChannelId;
#[async_trait::async_trait]
pub trait CallControlClient: Send + Sync {
/// Originate an outbound call to `to_phone`. The provider answers
/// itself, opens a Media Streams fork back to us, + returns a
/// provider-assigned correlation ID (Twilio: CallSid). The
/// `spend_token: Option<SpendToken>` is the pre-paved seam for
/// spearhead step 6 (the spend cap); this slice passes `None`.
async fn originate(
&self,
to_phone: &str,
from_phone: &str,
spend_token: Option<SpendToken>,
) -> Result<String, CallControlError>;
/// Hang up an in-progress call. Idempotent. Returns after the
/// provider acknowledges the terminate.
async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError>;
}
#[derive(Debug)]
pub struct CallControlError(pub String);
/// A placeholder for the slice-6 spend-gate token. The trait's `originate`
/// takes Option<SpendToken> now; this slice passes None everywhere; step 6
/// populates it before any REST origination is dispatched.
pub struct SpendToken(/* opaque */);
// crates/rutster-trunk/src/provider/mock.rs
//! In-process test double for `CallControlClient`. CI tests use this; the
//! live TwilioCallControlClient is feature-gated behind `--features=twilio-live`.
pub struct MockCallControlClient { /* in-process queues */ }
// crates/rutster-trunk/src/provider/twilio.rs
//! Live Twilio Call Control client. Performs REST API calls via reqwest.
//! NOT enabled in CI by default; the maintainer runs `cargo test --features=twilio-live`
//! when validating a release.
pub struct TwilioCallControlClient {
pub credentials: TwilioCredentials,
pub http: reqwest::Client,
}
pub struct TwilioCredentials {
pub account_sid: String,
pub auth_token: String, // NEVER logged; NEVER reaches the brain
/// URL Twilio redirects to once a call connects; the binary's axum server.
pub media_streams_bind: std::net::SocketAddr,
pub webhook_base: url::Url,
}
3.5 MediaLeg enum + MediaCmd::RegisterTrunk (the seam extensions)
// crates/rutster/src/media_thread.rs (modified this slice)
// ...existing MediaCmd enum from slice-5/seams + slice-4½ (if it lands first):
// Register, AcceptOffer, Delete, Shutdown, Stats, Drain, [RegisterSim]
pub enum MediaCmd {
// ... existing variants unchanged from prior slices ...
/// slice-5: register a trunk-side session. The MediaThread:
/// 1. Allocates a ChannelId.
/// 2. Constructs the TapAudioPipe + TapConn (spawn_tap_engine, REUSED
/// from slice-2/slice-4; the tap_url is the brain's WS URL).
/// 3. Wraps as `Reflex::new(tap_pipe, advisory_rx, metrics)` then
/// `LocalVadReflex::new(reflex, advisory_tx)` — same Task-6-style
/// composition as slice-4 for WebRTC legs.
/// 4. Inserts a TrunkSession into the session map under MediaLeg::Trunk.
/// 5. Spawns the TapEngine tokio task.
/// 6. Replies with the ChannelId.
RegisterTrunk {
call_sid: String,
inbound_from_twilio_rx: tokio::sync::mpsc::Receiver<PcmFrame>,
outbound_to_twilio_tx: tokio::sync::mpsc::Sender<PcmFrame>,
tap_url: url::Url,
reply: oneshot::Sender<ChannelId>,
},
}
// The per-session-storage enum (NEW, binary-side).
pub enum MediaLeg {
/// Existing WebRTC leg. Ticked by `rutster_media::loop_driver::drive`.
/// Untouched code path from slice-4.
WebRTC(RtcSession),
/// New trunk leg. Ticked by `rutster_trunk::loop_driver::drive`.
Trunk(TrunkSession),
}
// In the std thread loop:
fn run_per_leg_tick(leg: &mut MediaLeg, now: Instant) -> Option<Duration> {
match leg {
MediaLeg::WebRTC(s) => rutster_media::loop_driver::drive(s, now), // UNCHANGED
MediaLeg::Trunk(s) => rutster_trunk::loop_driver::drive(s, now), // NEW this slice
}
}
3.6 New HTTP routes
// crates/rutster/src/routes.rs (extended this slice)
// POST /v1/trunk/sessions
// Body: { to: "+1...", from: "+1..." }
// Handler: calls CallControlClient::originate(to, from, None).
// Returns: { channel_id, call_sid }
//
// POST /v1/trunk/webhook
// Twilio's webhook receiver for inbound calls. Twilio calls this when a
// PSTN caller dials the Twilio number configured to point at us; the
// handler responds with TwiML instructing Twilio to open a Media
// Streams WebSocket against <media_streams_bind>.
// Localhost development can omit the webhook; Twilio's Media Streams
// can be opened with a direct WSS POST in test mode (see QUICKSTART).
3.7 TwilioCredentials env parser (config seam)
// crates/rutster/src/config.rs (extended this slice)
/// Parse RUTSTER_TWILIO_ACCOUNT_SID + RUTSTER_TWILIO_AUTH_TOKEN +
/// RUTSTER_TWILIO_MEDIA_BIND + RUTSTER_TWILIO_WEBHOOK_BASE from env.
/// Returns None if any required var is missing (the binary runs without
/// trunk support if credentials aren't provided — WebRTC ingress still works).
pub fn twilio_credentials() -> Option<TwilioCredentials> { /* ... */ }
4. Data flow
4.1 Inbound PSTN call (Twilio calling us)
1. PSTN caller dials the Twilio phone number.
2. Twilio fires the configured webhook (POST /v1/trunk/webhook) with the
inbound call details.
3. axum handler responds with TwiML instructing Twilio to open a Media
Streams fork against our /twilio/media-stream endpoint.
4. Twilio opens WSS to our TwilioMediaStreamsServer.
5. The server's WS handler:
a. Parses the "start" frame; extracts CallSid + StreamSid.
b. Constructs the two mpsc pairs (inbound + outbound, capacity 16 each).
c. Sends a RegisterTrunkInboundChannel to MediaThread via the
register_tx mpsc.
6. MediaThread receives RegisterTrunk:
a. Allocates a ChannelId.
b. Constructs TapAudioPipe + TapConn via spawn_tap_engine REUSED from
slice-2 (with tap_url = the brain's WS URL for this call).
c. Wraps as Reflex::new(tap_pipe, advisory_rx, metrics) →
LocalVadReflex::new(reflex, advisory_tx) — same composition as
slice-4 Task 6 for WebRTC legs.
d. Constructs TrunkSession { channel, pipe, inbound_from_twilio_rx,
outbound_to_twilio_tx, ... } and inserts into the session map
under MediaLeg::Trunk.
e. Replies with the ChannelId.
7. The 20 ms media loop dispatches the trunk leg to trunk_driver::drive:
- Drain inbound_from_twilio_rx → caller PCM frames →
session.pipe.on_pcm_frame(frame) — hits LocalVadReflex first.
`LocalVadReflex::on_pcm_frame` inspects RMS; if above threshold after
debounce, fires AdvisoryEvent::SpeechStarted → Reflex::drain_advisories
applies barge_in_flush → TapAudioPipe::barge_in_flush clears ring +
drains rx_audio_out (slice-4 §3.3 — UNCHANGED); the next call to
`next_pcm_frame` returns None → brain reply suppressed.
- After the LocalVadReflex inspects, Reflex delegates to TapAudioPipe
→ TapAudioPipe::on_pcm_frame pushes the caller PCM to tx_pcm_in →
the TapEngine task drains → brain WS receive audio_in JSON event.
8. On the outbound tick (every 20 ms): `trunk_driver::drive` pulls
`session.pipe.next_pcm_frame()`:
- If muted (post-barge): returns None; no brain reply sent to Twilio
this tick. Caller hears silence.
- If not muted: returns Some(reply_frame). The frame is pushed to
`outbound_to_twilio_tx` mpsc; the WSS pump task drains it,
encodes µ-law via G711Codec::encode_pcm_to_mulaw, wraps in a
JSON "media" envelope, sends as a WS Text frame back to Twilio.
Caller hears the brain's reply.
9. Caller hangs up → Twilio sends "stop" frame → WS handler closes the
socket → MediaCmd::Delete → MediaThread removes the TrunkSession
from the map + tears down the TapEngine (slice-5/seams non-blocking
teardown path).
4.2 Outbound PSTN call (us calling out)
1. axum handler receives POST /v1/trunk/sessions { to, from }.
2. Handler calls TwilioCallControlClient::originate(to, from, None) [None
because spend gate is step 6].
3. TwilioCallControlClient:
a. POSTs to Twilio's REST API: <account>/Calls.json with the
`to` + `from` + a `twiml` parameter telling Twilio to open a Media
Streams fork against our /twilio/media-stream endpoint.
b. Twilio returns the new CallSid; the REST call returns immediately
(the actual fork will arrive later as an inbound WSS).
4. The remaining flow waits for Twilio's inbound WSS connection (step 4
onwards in §4.1). The originate call returns once Twilio accepts.
5. The handler responds to the caller (the operator hitting the REST
endpoint) with the assigned ChannelId + CallSid for correlation;
the caller can poll MediaCmd::Stats or wait for an EventSink emission
when the leg connects.
4.3 Barge-in on the trunk leg (the wedge verification)
Identical to slice-4 §5.1, applied to the trunk leg's wrapped pipe:
1. Caller speaks loudly over PSTN → Twilio's µ-law-encoded audio arrives
via the WSS pump task.
2. The pump task base64-decodes the JSON "media" payload, decodes µ-law
via G711Codec::decode_mulaw_to_pcm → 24 kHz PcmFrame.
3. Pump pushes PcmFrame into the leg's inbound_from_twilio_tx mpsc.
4. Loop tick: trunk_driver::drive drains inbound_from_twilio_rx → calls
session.pipe.on_pcm_frame(decoded_frame).
5. The wrapper is LocalVadReflex<Reflex<TapAudioPipe>>:
- LocalVadReflex::on_pcm_frame sees the caller PCM FIRST, computes RMS
(slice-4 §3.4 unchanged). If ≥500.0 + debounce streak of 3 →
try_send(AdvisoryEvent::SpeechStarted) into advisory_tx.
- Then delegates inward to Reflex::on_pcm_frame → Reflex delegates
(Reflex is observational on the sink side, slice-4 §3.5) →
TapAudioPipe::on_pcm_frame pushes the caller PCM to tx_pcm_in →
TapEngine → brain WS as `audio_in` JSON event (slice-3 plumbing,
unchanged).
6. Reflex::next_pcm_frame drains advisory_rx → SpeechStarted applies
barge_in_flush (slice-4 §3.3 unchanged: clears playout ring + drains
rx_audio_out). The next call to next_pcm_frame returns None → no
brain reply sent to Twilio this tick → caller hears silence.
7. Brain yields + sends a fresh audio_out post-barge → TapEngine →
tx_audio_out mpsc → TapAudioPipe::next_pcm_frame (the wrapped inner
the Reflex inspects inside its next_pcm_frame). Reflex sees Some →
muted=false; returns Some → trunk_driver pushes to outbound_to_twilio_tx
→ WSS pump encodes µ-law + sends JSON Media frame back to Twilio →
caller hears the brain's new reply.
The reflex loop is INGRESS-AGNOSTIC: the only difference between WebRTC's loop_driver::drive + trunk's trunk_driver::drive is where the caller PCM arrives (str0m's MediaData event vs an mpsc the WSS pump fills).
5. ADR-0009 honoring — the credential isolation invariant
The slice-5/seams-merged ADR-0009 amendment says: the spend gate + provider credentials live INSIDE the trust boundary; the brain is OUTSIDE.
Concrete enforcement in this slice:
TwilioCredentialsis defined incrates/rutster-trunk/, scoped to the trunk crate. It ispubto the binary crate (crates/rutster/), but NOT re-exported through the workspace.- The brain WS protocol carries ONLY: the audio (PCM in / PCM out), the
function-call events (slice-3), + the
speech_started/speech_stoppedadvisory events (slice-4). It does NOT carry the Twilio account SID, the Twilio auth_token, the REST endpoint URL, or the CallSid. (Wait — the brain might benefit from the CallSid for correlation in its own logs. Adjust: CallSid is treated as an opaque correlation ID, NOT a credential; theTapConncarries aprovider_correlation_id: Option<String>field for the operator's debug-logging convenience. The brain only sees it if it's routed through the tap protocol — which it ISN'T in this slice. The CallSid lives onTrunkSessionfor log correlation in the FOB only.) - The
CallControlClienttrait lives incrates/rutster-trunk/. The brain's interface surface doesn't even IMPORT it. - The
TwilioCallControlClient::originatefunction reads credentials only fromself.credentials(passed in at construction via env parser). It NEVER logs the auth_token; tracing fields use only the account_sid's last 4 chars.
These are invariants the slice-6 spend-cap work depends on.
6. Why these decisions
6.1 Why reuse TapAudioPipe for the trunk leg (NOT a new TrunkAudioPipe)
The slice-4 stack LocalVadReflex<Reflex<TapAudioPipe>> is brain-pipe-specific, not
leg-kind-specific. The TapAudioPipe exists precisely to bridge the FOB loop and a brain-side
WebSocket; slice-4's Reflex<P> is a generic over P: AudioPipe. A trunk leg's brain audio is
the same mpsc pattern as a WebRTC leg's brain audio: the TapEngine task produces PCM frames in
tx_audio_out (for next_pcm_frame to pull from) and consumes PCM frames from tx_pcm_in
(for on_pcm_frame to push to). The leg-kind decides ONLY the source of caller PCM (str0m's
RTP-decoded MediaData event vs the WSS-pump-supplied mpsc). Reusing TapAudioPipe:
- Avoids duplicating the slice-2 TapAudioPipe's mpsc pair + ring buffer logic.
- Makes the leg-kind distinction pure-binary-side (a new driver + a new session struct), not per-crate.
- Asserts (architecturally) that the FOB reflex loop is ingress-agnostic at theInnermost layer.
If we made TrunkAudioPipe a separate type, it would be 95% duplicate code, and the barge_in_flush semantics would have to be re-tested for a structurally-identical mpsc-and-ring pair. That's the opposite of "the seam holds."
6.2 Why a separate trunk_driver::drive (NOT extending loop_driver.rs)
loop_driver.rs is the seam-file: byte-identical through slices 1–4, gated by the slice-4 Task 10
pinned-blob CI step. Extending it with a Twilio path would either (a) re-open the slice-4 seam gate
and weaken the architectural claim that the WebRTC media loop is untouched; or (b) fork into
"loop_driver has a Trunk variant" which puts trunk-specific concerns in the media crate (violating
ADR-0008's FOB-member criterion — trunk's µ-law + WSS framing isn't media-layer concern).
A separate trunk_driver::drive in crates/rutster-trunk/ honors both: the slice-4 seam stays
green (loop_driver.rs byte-identical), and trunk concerns stay in the trunk crate. The
MediaLeg enum (in crates/rutster/src/media_thread.rs, the binary-side bridge) is the only
addition the existing code sees; the WebRTC match arm preserves the existing call site 1:1.
6.3 Why Twilio Media Streams is the MVP (not Telnyx, raw SIP fork, or a custom raw-audio shim)
- Documented application protocol (JSON envelope; one of Twilio's most-stable public APIs).
- Operational foothold: the maintainer likely already has a Twilio account for personal telephony hacks; the dev loop falls out of one phone call. Telnyx would require a separate account onboarding step.
- Generic at the seam: the
CallControlClienttrait locks the abstraction; the next provider (Telnyx, a custom raw-audio shim) is animplof the trait, not a refactor. We're betting on the architecture's flexibility, not on Twilio being the right carrier forever.
6.4 Why the WSS server runs on axum (not a separate tokio listener)
- Reuses the binary's existing
axum::serveruntime — same tokio pool, same graceful-shutdown plumbing. No newTcpListeneracceptor to wire throughmain.rs. - The WSS endpoint (
/twilio/media-stream) sits alongside the existing HTTP routes (POST /v1/sessions, etc.); a reverse proxy / load balancer at the production edge handles TLS / WS upgrade uniformly. - Axum's WebSocketUpgrade extractor handles the WS handshake natively — no need to plumb a
separate
tokio-tungstenite::accept_asyncpath.
6.5 Why in-core µ-law (not a g711 crate dep)
- ~30 lines of table-driven code; static table is 256 + 65536 = ~66 KB binary size increase (irrelevant on any modern platform; the tables fit in L1/L2).
- µ-law is a 1972 ITU-T standard — the decode table has not changed in 50 years; there is no "actively maintained" dep that materially improves on a constant array (ADR-0008's "actively maintained" gate is about addressing bus-factor / unsanctioned changes; a const table has no bus factor).
- The decode table is a piece of telephony history — learner-commented, it teaches the µ-law companding formula that AGENTS.md says this codebase is supposed to teach (the project OVERRIDES the no-comments convention; this is exactly the kind of thing the override is for).
6.6 Why 3× linear interpolation for 8 kHz ↔ 24 kHz resampling (not rubato)
- The downstream consumer of the resampled 24 kHz PCM is the
LocalVadReflexRMS-detector (the barge-in trigger), which only cares about ENERGY (RMS over 480 samples). Linear interpolation introduces harmonic-distortion artifacts at the high-frequency end that are well below the RMS-detection threshold; the barge-in trigger is unaffected. - The brain's audio (when sent back to Twilio) is encoded µ-law + played over PSTN, which has a 3.4 kHz cutoff anyway — Twilio's PSTN callee can't hear distortion above the µ-law bandlimit, so the resampler's high-frequency artifacts are inaudible by construction.
rubatoadds a dep + non-trivial CPU cost (FIR filter + windowing). The spearhead doesn't need it; per AGENTS.md ADR-0008: "When in doubt, default to green zone" / "The FOB earns its members, it doesn't collect them." Deferrubatoto a post-spearhead tier when (a) the resampler artifacts matter for some downstream consumer (transcription accuracy, recording quality), OR (b) the test corpus catches a measurable regression.
7. Done-criteria
cargo test --allpasses (stable + 1.85); the routine gate.cargo fmt --check+cargo clippy -- -D warningsclean on the new code.cargo deny checkpasses —reqwestandasync-traitare acknowledged indeny.toml.cargo doc --no-depsrenders the newcrates/rutster-trunk/cleanly with learner-facing comments per AGENTS.md code style.- Seam gate STILL holds:
loop_driver.rs+rtc_session.rsbyte-identical to slice-3 (CI pinned-blob from slice-4 Task 10 unchanged because this slice never touches the media-crate seam files). MediaLeg::WebRTC(s) => loop_driver::drive(s, now)is the unchanged call path for WebRTC legs. The newMediaLeg::Trunk(s) => trunk_driver::drive(s, now)is the only newly-introduced tick dispatch.Reflex<TapAudioPipe>+LocalVadReflex(slice-4) instantiate against the trunk leg's TapAudioPipe without code change (the generic composition is the proof point).- The
MockCallControlClient+ an in-process Twilio Media Streams simulator drive a synthetic PSTN caller through the FOB reflex loop: barge-in fires on PSTN caller speech within slice-4's ≤80 ms kill budget (CI gate from slice 4½ if it lands; otherwise slice-4's manual e2e budget applies witness, not asserted in this slice). - PSTN sim e2e integration test:
MockCallControlClient+MockTwilioMediaStreamsServer→ MediaThread → Reflex → barge-in verified on PSTN leg → brain reply observed → caller hangup → session tear down + EventSink emission ofChannelEnded(with wall-clockstarted_atfrom slice-5/seams). - Credential isolation: a static assertion (a unit test that imports only
rutster-trunk's public API + asserts thatTwilioCredentialsis NOT re-exported from the workspace, NOT inrutster-media's public API, NOT inrutster-tap's public API). The brain never sees a Twilio credential. - QUICKSTART + README updated with: env-var table for Twilio credentials, "make a real phone
call" walkthrough (local dev with
--features=twilio-liveOR a direct WSS POST test mode). - Outbound origination (us calling out) is plumbed through
TwilioCallControlClient::originate- the rest of the wiring flows identically to inbound (the Twilio fork arrives inbound).
8. Open decisions
8.1 Should the MockTwilioMediaStreamsServer (test double) live in crates/rutster-trunk/
or in crates/rutster/tests/?
Decision (slice 5): in crates/rutster-trunk/src/twilio_media_streams.rs's #[cfg(test)]
module + a pub(crate) simulator API exposed only to in-process tests. Reason: the simulator IS
the FOB testable surface for the trunk leg; it's NOT a test of the binary's plumbing (which
crates/rutster/tests/sim_integ.rs covers). The simulator's JSON-frame parser is structurally
the same code as the live impl, just driven by in-memory queues instead of a real WSS.
8.2 Should the WSS pump task be ONE tokio task per call (read+write in one task) or TWO
(read + write separately)?
Decision (slice 5): ONE task, with a tokio::select! loop. The read + write directions
share the WS socket (Twilio's protocol interleaves inbound + outbound frames on the same WSS);
separating them into two tasks adds channel overhead for no real concurrency benefit (the read
side is rate-limited by µ-law frame arrival; the write side is rate-limited by the FOB's 20 ms
tick). One task is simpler.
8.3 When the brain is unreachable mid-call (WS drops), should the trunk leg hang up the
PSTN call or keep playing silence?
Decision (slice 5): keep playing silence for up to 5 seconds (the existing tap-engine
reconnect path from slice-2 §5.3 isn't implemented for trunk legs in this slice — it's a
deferred refinement). After 5 s of silence: trigger CallControlClient::hangup(call_sid). This
matches what a human caller would want (5 s of silence is "did the line drop?") and is
predictable. The reconnect-after-drop feature is a slice-6 / post-step-5 refinement paired with
the spend gate (which has to know "is this call still going?").
8.4 Should the WSS acceptor authenticate Twilio's connection (HMAC signature validation)?
Decision (slice 5): NO. The MVP runs on localhost for dev + behind a reverse proxy for
production. The reverse proxy / SSR-firewall layer enforces "Twilio's WSS connections come from
Twilio's documented IP ranges"; the binary doesn't re-authenticate. (A future hardening rung adds
HMAC validation per Twilio's documented signature scheme; it's tracked in cargo-deny /
architecture but not spearhead-scale.)
8.5 Should the trunk leg support barge_in_flush semantics over the WSS path (i.e. flush
in-flight frames in outbound_to_twilio_tx AND inbound_from_twilio_rx)?
Decision (slice 5): YES for the wrap-side Reflex<TrunkAudioPipe>::barge_in_flush-equivalent
call. The inner TapAudioPipe's barge_in_flush (slice-4 §3.3) clears its OWN ring + its OWN
rx_audio_out mpsc; the trunk leg inherits this. The trunk-specific NEW mpsc (
inbound_from_twilio_rx + outbound_to_twilio_tx) is NOT part of the bar's atomicity — the
inbound mpsc drains naturally on the next tick; the outbound mpsc is bounded small (capacity 4),
so a stale outbound frame would arrive at Twilio as at most one extra 20 ms of audio (a perceptual
cost below the barge-in triggering cadence).
If integration tests show audible pre-barge leakage, this decision gets revisited.
8.6 Should the harness from slice 4½ (if it lands first) exercise the trunk leg?
Decision (slice 5): YES via future-extension. Slice 4½'s SimAudioPipe is a separate
AudioPipe impl; if it lands first, slice 4½ tests against the WebRTC ingress only. Once the
trunk leg lands (this slice), a refinement adds a TrunkSimAudioPipe to slice 4½'s harness OR
refactors SimAudioPipe to be leg-agnostic. The decision is deferred to the harness-implementation
phase, NOT this slice.
9. Cross-references
- ADR-0007 — the central rationale: rutster owns no SIP stack; carrier transport is rented (layer 1 = CPaaS raw-media fork, this slice; layer 2 = out-of-tree SBC, deferred; layer 3 = never first-party).
- ADR-0008 — FOB/green-zone doctrine; this slice's split (FOB owns WSS server + codec + TrunkSession; green-zone owns REST call-control).
- ADR-0009 — provider credentials never reach the brain; this slice pre-paves the spend-token seam for step 6.
- slice-2 spec §4.1 — the core-authoritative playout buffer; trunk leg inherits the invariant (output comes from the FOB's pipe, not from the brain).
- slice-4 spec §3.3 —
TapAudioPipe::barge_in_flushsemantics; this slice REUSES them unchanged. - slice-4 spec §3.4 —
LocalVadReflex<P>RMS-detector; this slice REUSES it unchanged. - slice-5/seams plan — config.rs, event_sink.rs, MediaCmd::Stats, non-blocking tap teardown, advertise media address — every seam this slice inherits.
- PORT_PLAN.md §Phasing — step 5 = real phone number (this slice).
- ARCHITECTURE.md §"Media plane" — "dedicated timing threads for the 20ms
loop, never the shared tokio pool"; the trunk leg participates in the same dedicated thread
via
trunk_driver::drive.