feat(trunk): G711Codec — µ-law encode/decode + 8kHz↔24kHz linear-interpolated resampling (slice-5 T1)
In-core ~30-line table-driven codec (no dep). The ITU-T G.711 µ-law companding formula is a piece of telephony history worth teaching (AGENTS.md learner-facing comment mandate). 3× linear upsample on decode; 3× decimation downsample on encode. The resampler artifacts are below the barge-in trigger threshold (LocalVadReflex only needs RMS energy); rubato lands in a post- spearhead refinement if a downstream consumer needs better (spec §6.6). Task T1 of slice-5 — T3 (TwilioMediaStreamsServer) consumes this codec. Signed-off-by: Aaron D. Lee <himself@adlee.work>
This commit is contained in:
17
Cargo.lock
generated
17
Cargo.lock
generated
@@ -143,6 +143,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"axum-core",
|
"axum-core",
|
||||||
"axum-macros",
|
"axum-macros",
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -161,8 +162,10 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -1280,6 +1283,20 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "rutster-trunk"
|
name = "rutster-trunk"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"base64",
|
||||||
|
"rutster-call-model",
|
||||||
|
"rutster-media",
|
||||||
|
"rutster-tap",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
|
|||||||
@@ -5,4 +5,38 @@ version = "0.0.0"
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
description = "Rented carrier transport — CPaaS media-leg ingress / out-of-tree SBC; no first-party SIP (filled in step 5, ADR-0007)."
|
description = "Rented carrier transport — CPaaS media-leg ingress; no first-party SIP (spearhead step 5, ADR-0007)."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# FOB-side wiring (slice-5 dev-c branch -- tasks T1/T3/T4/T5/T7/T8):
|
||||||
|
# * rutster-media: PcmFrame + SAMPLES_PER_FRAME + Reflex stack (REUSED slice-4 verbatim).
|
||||||
|
# * rutster-call-model: Channel + ChannelId for TrunkSession (T4).
|
||||||
|
# * rutster-tap: TapAudioPipe (REUSED verbatim -- the architectural load-bearing claim, spec §6.1).
|
||||||
|
rutster-media = { path = "../rutster-media" }
|
||||||
|
rutster-call-model = { path = "../rutster-call-model" }
|
||||||
|
rutster-tap = { path = "../rutster-tap" }
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] }
|
||||||
|
axum = { workspace = true, features = ["ws"] }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
# NOTE: reqwest + async-trait land in dev-b's branch (T2 provider trait + T6 TwilioCallControlClient
|
||||||
|
# live REST impl). They live at arm's length from the FOB hot path per ADR-0009 -- never re-exported
|
||||||
|
# through the workspace, never imported by g711/twilio_media_streams/session/loop_driver. The
|
||||||
|
# stacked-branches rebase-merge (per AGENTS.md Git workflow carve-out) reconciles the [dependencies]
|
||||||
|
# table at merge time.
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
# Reserved for T6 (dev-b's live TwilioCallControlClient REST impl). The routine CI gate stays
|
||||||
|
# default-features-off -- MockCallControlClient is the per-PR integration test surface. The
|
||||||
|
# maintainer triggers `cargo test --features=twilio-live` manually pre-release.
|
||||||
|
twilio-live = []
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# `tower::ServiceExt::oneshot` is the canonical axum router helper for unit tests
|
||||||
|
# (turns a Router into a one-shot Service: `app.oneshot(request).await`).
|
||||||
|
tower = { workspace = true }
|
||||||
|
|||||||
266
crates/rutster-trunk/src/g711.rs
Normal file
266
crates/rutster-trunk/src/g711.rs
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
//! # G.711 µ-law codec + 8 kHz <-> 24 kHz linear-interpolated resampling
|
||||||
|
//!
|
||||||
|
//! `G711Codec` is the boundary codec between Twilio Media Streams raw-audio
|
||||||
|
//! forks (8 kHz µ-law, base64-encoded JSON envelopes) and rutster's canonical
|
||||||
|
//! 24 kHz mono PCM `PcmFrame` (slice-1 spec §3.9 -- 480 samples / 20 ms).
|
||||||
|
//!
|
||||||
|
//! # 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
|
||||||
|
//! mature OSS only when the complexity is non-trivial; this is neither). The
|
||||||
|
//! implementation is also learner-facing per AGENTS.md "Code style (Rust)":
|
||||||
|
//! the µ-law companding formula is a piece of telephony history worth teaching.
|
||||||
|
//!
|
||||||
|
//! # The hot path
|
||||||
|
//!
|
||||||
|
//! [`G711Codec::decode_mulaw_to_pcm`] runs inside the WSS pump task for every
|
||||||
|
//! inbound Twilio "media" frame (one per 20 ms tick). [`G711Codec::encode_pcm_to_mulaw`]
|
||||||
|
//! runs in the WSS pump's outbound send loop for every brain reply the std
|
||||||
|
//! thread produces. Both call paths are pure-function over `&[u8]` -- no I/O
|
||||||
|
//! inside, no allocation beyond the result buffer's `Vec` capacity hint.
|
||||||
|
//!
|
||||||
|
//! # Why 3x upsample by linear interpolation (not `rubato` / speexdsp)
|
||||||
|
//!
|
||||||
|
//! 24 kHz / 8 kHz = exactly 3. Each input sample becomes 3 output samples:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! out[3i] = input[i]
|
||||||
|
//! out[3i + 1] = (2*input[i] + input[i+1]) / 3
|
||||||
|
//! out[3i + 2] = (input[i] + 2*input[i+1]) / 3
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! These weights place the two interpolated samples at 1/3 and 2/3 of the
|
||||||
|
//! 8 kHz sample interval -- a straight line between each pair of input
|
||||||
|
//! samples. Linear interpolation is the cheap correct-enough answer for the
|
||||||
|
//! spearhead MVP; the aliasing is below `LocalVadReflex`'s RMS threshold.
|
||||||
|
//!
|
||||||
|
//! Linear interpolation is the cheap correct-enough answer for the spearhead
|
||||||
|
//! MVP -- the artifacts are below the audibility threshold for `LocalVadReflex`'s
|
||||||
|
//! barge-in trigger, which only needs RMS energy above `VAD_RMS_THRESHOLD`
|
||||||
|
//! (slice-4 spec §3.4). A production-grade resampler (`rubato` or `speexdsp`)
|
||||||
|
//! would polish the high-frequency aliasing further, but doing so now would
|
||||||
|
//! pull in a transitive dep we don't need for the wedge claim ("the FOB reflex
|
||||||
|
//! loop works against real phone audio"). `rubato` lands in a post-spearhead
|
||||||
|
//! refinement if a downstream consumer needs it (slice-5 spec §6.6 / §8.1).
|
||||||
|
//!
|
||||||
|
//! # Resampling round-trip drift budget
|
||||||
|
//!
|
||||||
|
//! The encode -> decode round-trip energy drift is bounded by µ-law's intrinsic
|
||||||
|
//! segment quantization error (3 dB worst-case at segment transitions, well
|
||||||
|
//! under the 12% energy-drift budget in slice-5 spec §6.5). The drift is
|
||||||
|
//! verified by `tests::decode_then_encode_round_trips_a_loud_signal_within_12pct_energy_drift`.
|
||||||
|
|
||||||
|
use rutster_media::{PcmFrame, SAMPLES_PER_FRAME};
|
||||||
|
|
||||||
|
use crate::mulaw_decode_table::MULAW_TO_LINEAR;
|
||||||
|
use crate::mulaw_encode_table::LINEAR_TO_MULAW;
|
||||||
|
|
||||||
|
/// Zero-state codec. The methods are pure functions -- the codec holds no
|
||||||
|
/// per-session state because the µ-law encode/decode tables are global
|
||||||
|
/// `static` arrays (compile-time-generated, see `mulaw_decode_table.rs`
|
||||||
|
/// and `mulaw_encode_table.rs`). The struct exists so callers can build
|
||||||
|
/// router / handler signatures that explicitly thread the codec concept
|
||||||
|
/// (matching the slice-5 spec §3.1 surface we extend in later tasks).
|
||||||
|
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` (slice-1 canonical format). 3x linear upsample.
|
||||||
|
///
|
||||||
|
/// # Frame size contract
|
||||||
|
///
|
||||||
|
/// Twilio Media Streams delivers 160 µ-law bytes per 20 ms tick
|
||||||
|
/// (8000 samples/sec * 0.020 sec = 160). 3x upsampled, that becomes
|
||||||
|
/// 480 samples -- exactly one `PcmFrame` of slice-1 spec §3.9.
|
||||||
|
///
|
||||||
|
/// # Hot-path policy
|
||||||
|
///
|
||||||
|
/// A malformed frame (wrong byte count) does NOT crash the WSS pump
|
||||||
|
/// task: `debug_assert!` surfaces protocol drift in test builds; in
|
||||||
|
/// release the function returns a zeroed `PcmFrame` and lets the
|
||||||
|
/// caller drop + observe (spec §3.1).
|
||||||
|
pub fn decode_mulaw_to_pcm(mulaw: &[u8]) -> PcmFrame {
|
||||||
|
// `debug_assert!` catches protocol drift in test builds; release
|
||||||
|
// builds take the safe fallback so the WSS pump task never crashes
|
||||||
|
// on a malformed envelope (hot-path policy, AGENTS.md).
|
||||||
|
debug_assert_eq!(
|
||||||
|
mulaw.len(),
|
||||||
|
160,
|
||||||
|
"expected 160 µ-law bytes per 20ms frame (got {})",
|
||||||
|
mulaw.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Malformed input is dropped on the hot path: return silence so the
|
||||||
|
// loop tick continues safely. A correct Twilio Media Streams frame
|
||||||
|
// always carries exactly 160 µ-law bytes per 20 ms tick.
|
||||||
|
if mulaw.len() != 160 {
|
||||||
|
return PcmFrame::zeroed();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the 160 µ-law bytes into a fixed-size 8 kHz linear buffer.
|
||||||
|
// 160 * sizeof(i16) = 320 bytes -- small enough to live on the stack.
|
||||||
|
let mut linear_8k = [0i16; 160];
|
||||||
|
for (i, &byte) in mulaw.iter().enumerate() {
|
||||||
|
linear_8k[i] = MULAW_TO_LINEAR[byte as usize];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut samples = [0i16; SAMPLES_PER_FRAME];
|
||||||
|
for i in 0..160 {
|
||||||
|
let current = linear_8k[i];
|
||||||
|
// For the final 8 kHz sample there is no next sample to
|
||||||
|
// interpolate toward, so hold the last value -- no extrapolation
|
||||||
|
// past the frame boundary.
|
||||||
|
let next = if i < 159 { linear_8k[i + 1] } else { current };
|
||||||
|
|
||||||
|
let base = 3 * i;
|
||||||
|
samples[base] = current;
|
||||||
|
samples[base + 1] = ((2 * current as i32 + next as i32) / 3) as i16;
|
||||||
|
samples[base + 2] = ((current as i32 + 2 * next as i32) / 3) as i16;
|
||||||
|
}
|
||||||
|
|
||||||
|
PcmFrame { samples }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a 24 kHz `PcmFrame` into 8 kHz µ-law bytes for Twilio Media
|
||||||
|
/// Streams. Inverse of [`decode_mulaw_to_pcm`]: 3x downsample by
|
||||||
|
/// decimation (take every 3rd sample) plus a `LINEAR_TO_MULAW` table
|
||||||
|
/// lookup. The result is a 160-byte `Vec<u8>`, one per 20 ms tick.
|
||||||
|
///
|
||||||
|
/// # Why decimation (not averaging)
|
||||||
|
///
|
||||||
|
/// Decimation simply drops the samples at positions 3i+1 and 3i+2. The
|
||||||
|
/// aliasing energy this introduces is below `LocalVadReflex`'s RMS
|
||||||
|
/// threshold (slice-4 spec §3.4); the brain never cares about
|
||||||
|
/// frequencies above 4 kHz (its speech model is band-limited below
|
||||||
|
/// 8 kHz Nyquist). `rubato` would deliver better quality at the cost
|
||||||
|
/// of a transitive dep + slower hot path -- not warranted for the
|
||||||
|
/// spearhead MVP (spec §6.6 + §8.1).
|
||||||
|
pub fn encode_pcm_to_mulaw(frame: &PcmFrame) -> Vec<u8> {
|
||||||
|
let mut mulaw = Vec::with_capacity(160);
|
||||||
|
for i in 0..160 {
|
||||||
|
// `i16 as u16` reinterprets the bit pattern -- i16 -1 -> u16 65535
|
||||||
|
// -> table index 65535. This is the correct index for
|
||||||
|
// LINEAR_TO_MULAW, which is laid out `linear_to_mulaw(i as i16)` for
|
||||||
|
// all `i in 0..65536` -- the bit-pattern identity holds.
|
||||||
|
let sample = frame.samples[3 * i];
|
||||||
|
mulaw.push(LINEAR_TO_MULAW[(sample as u16) as usize]);
|
||||||
|
}
|
||||||
|
mulaw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_160_byte_frame_yields_480_samples() {
|
||||||
|
// 160 µ-law bytes is one 20 ms Twilio Media Streams tick; decoding
|
||||||
|
// must yield exactly the slice-1 canonical 480-sample PcmFrame.
|
||||||
|
// (Input is `0xFF` = ITU-T G.711 silence per spec; decode is the
|
||||||
|
// `mulaw_to_linear(0xFF)` value, repeated 480 times after upsample.)
|
||||||
|
let mulaw = vec![0xFFu8; 160];
|
||||||
|
let frame = G711Codec::decode_mulaw_to_pcm(&mulaw);
|
||||||
|
assert_eq!(frame.samples.len(), SAMPLES_PER_FRAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_silence_yields_all_zero_pcm() {
|
||||||
|
// ITU-T G.711 µ-law silence is the byte 0xFF. The reference decoder's
|
||||||
|
// BIAS add + subtract on the positive branch yields exactly 0 PCM --
|
||||||
|
// this is the "mid-tread zero" property that keeps silences silent.
|
||||||
|
let mulaw = vec![0xFFu8; 160];
|
||||||
|
let frame = G711Codec::decode_mulaw_to_pcm(&mulaw);
|
||||||
|
assert!(
|
||||||
|
frame.samples.iter().all(|&s| s == 0),
|
||||||
|
"decoded µ-law silence (0xFF repeated) must be all-zero PCM; got non-zero"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_pcm_to_mulaw_emits_160_bytes_per_frame() {
|
||||||
|
let frame = PcmFrame::zeroed();
|
||||||
|
let mulaw = G711Codec::encode_pcm_to_mulaw(&frame);
|
||||||
|
assert_eq!(mulaw.len(), 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_silence_emits_mu_law_silence_0xff() {
|
||||||
|
// Encoding a zero PCM frame must yield 160 bytes of 0xFF (the µ-law
|
||||||
|
// silence encoding) -- inverse of `decode_silence_yields_all_zero_pcm`.
|
||||||
|
let frame = PcmFrame::zeroed();
|
||||||
|
let mulaw = G711Codec::encode_pcm_to_mulaw(&frame);
|
||||||
|
assert!(
|
||||||
|
mulaw.iter().all(|&b| b == 0xFF),
|
||||||
|
"encoding zero PCM must yield 0xFF µ-law silence; got non-0xFF"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_then_encode_round_trips_a_loud_signal_within_12pct_energy_drift() {
|
||||||
|
// The wedge claim: a real PSTN caller's voice survives the
|
||||||
|
// µ-law encode -> And the decode round trip with RMS energy preserved
|
||||||
|
// within the spec's 12% budget (slice-5 spec §6.5 -- the barge-in
|
||||||
|
// trigger only needs RMS energy above VAD_RMS_THRESHOLD; µ-law's
|
||||||
|
// quantization error is well below that floor).
|
||||||
|
//
|
||||||
|
// A constant-amplitude loud signal (10_000 amplitude i16, ~30% of
|
||||||
|
// full scale) is the harshest single-tone test case -- the µ-law
|
||||||
|
// quantization step is most visible at flat amplitudes (segment
|
||||||
|
// transitions are zero, so no dynamic range advantage accrues).
|
||||||
|
let mut input_frame = PcmFrame::zeroed();
|
||||||
|
for s in &mut input_frame.samples {
|
||||||
|
*s = 10_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mulaw = G711Codec::encode_pcm_to_mulaw(&input_frame);
|
||||||
|
assert_eq!(mulaw.len(), 160, "encoded frame must be 160 µ-law bytes");
|
||||||
|
|
||||||
|
let decoded = G711Codec::decode_mulaw_to_pcm(&mulaw);
|
||||||
|
|
||||||
|
let orig_rms = rms(&input_frame.samples);
|
||||||
|
let dec_rms = rms(&decoded.samples);
|
||||||
|
let drift = (dec_rms - orig_rms).abs() / orig_rms.max(1.0);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
drift <= 0.12,
|
||||||
|
"µ-law round-trip energy drift {:.2}% > 12% budget (spec §6.5); \
|
||||||
|
orig_rms={}, dec_rms={}",
|
||||||
|
drift * 100.0,
|
||||||
|
orig_rms,
|
||||||
|
dec_rms
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_then_decode_preserves_polarity() {
|
||||||
|
// A loud negative sample (silent cues, DTMF, or echo-cancel tails)
|
||||||
|
// must round-trip without sign flip. Verifies the XOR mask sign
|
||||||
|
// convention (positive -> MSB 1; negative -> MSB 0) is consistent
|
||||||
|
// across encode + decode.
|
||||||
|
let mut input_frame = PcmFrame::zeroed();
|
||||||
|
for s in &mut input_frame.samples {
|
||||||
|
*s = -10_000;
|
||||||
|
}
|
||||||
|
let mulaw = G711Codec::encode_pcm_to_mulaw(&input_frame);
|
||||||
|
let decoded = G711Codec::decode_mulaw_to_pcm(&mulaw);
|
||||||
|
|
||||||
|
// All decoded samples must be negative (no polarity flip).
|
||||||
|
assert!(
|
||||||
|
decoded.samples.iter().all(|&s| s <= 0),
|
||||||
|
"negative-amplitude input must decode to non-positive samples; \
|
||||||
|
observed a positive sample (sign convention bug)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RMS energy over a sample buffer -- the same shape `LocalVadReflex`
|
||||||
|
/// uses for its barge-in trigger (slice-4 spec §3.4). Local helper here
|
||||||
|
/// to keep this test module self-contained + the assertion readable.
|
||||||
|
fn rms(samples: &[i16]) -> f64 {
|
||||||
|
let sum_sq: u64 = samples.iter().map(|&s| (s as i64 * s as i64) as u64).sum();
|
||||||
|
(sum_sq as f64 / samples.len() as f64).sqrt()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,39 @@
|
|||||||
//! canonical tap format and handed to the media plane as a media-leg ingress, parallel to
|
//! canonical tap format and handed to the media plane as a media-leg ingress, parallel to
|
||||||
//! WebRTC.
|
//! WebRTC.
|
||||||
|
|
||||||
|
// slice-5 fills in the FOB-side rented-transport ingress. Module map:
|
||||||
|
//
|
||||||
|
// * g711 -- G711Codec (µ-law encode/decode + 8kHz<->24kHz
|
||||||
|
// linear-interpolated resampling). T1.
|
||||||
|
// * mulaw_decode_table -- 256-entry compile-time-generated ITU-T G.711
|
||||||
|
// µ-law decode table. T1.
|
||||||
|
// * mulaw_encode_table -- 65536-entry compile-time-generated ITU-T G.711
|
||||||
|
// µ-law encode table. T1.
|
||||||
|
//
|
||||||
|
// Future modules landing in subsequent tasks (slice-5 dev-c chain):
|
||||||
|
// * twilio_media_streams -- TwilioMediaStreamsServer (axum WSS handler). T3.
|
||||||
|
// * session -- TrunkSession (per-trunk-leg session struct). T4.
|
||||||
|
// * loop_driver -- trunk_driver::drive (per-tick function). T4.
|
||||||
|
//
|
||||||
|
// Green-zone modules (slice-5 dev-b branch):
|
||||||
|
// * provider/{mod,mock,twilio} -- CallControlClient trait + Mock + live
|
||||||
|
// TwilioCallControlClient (behind `twilio-live` feature). T2/T6 -- lands
|
||||||
|
// in dev-b's branch, rebase-merges here at PR time.
|
||||||
|
|
||||||
|
pub mod g711;
|
||||||
|
mod mulaw_decode_table;
|
||||||
|
mod mulaw_encode_table;
|
||||||
|
pub mod twilio_media_streams;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
/// Stub crates lock boundaries; the compile-test is the lock.
|
use crate::g711::G711Codec;
|
||||||
|
|
||||||
|
/// Stub crates lock boundaries; retained as a smoke test after the FOB
|
||||||
|
/// modules land. It now also exercises the public codec surface once.
|
||||||
#[test]
|
#[test]
|
||||||
fn crate_compiles() {}
|
fn crate_compiles() {
|
||||||
|
let frame = G711Codec::decode_mulaw_to_pcm(&[0xFF; 160]);
|
||||||
|
assert_eq!(frame.samples.len(), 480);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
101
crates/rutster-trunk/src/mulaw_decode_table.rs
Normal file
101
crates/rutster-trunk/src/mulaw_decode_table.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
//! 8-bit µ-law to 16-bit linear decode table -- ITU-T G.711 (1972, reaffirmed 2000).
|
||||||
|
//!
|
||||||
|
//! The table is computed at compile time by [`mulaw_to_linear`], a `const fn`
|
||||||
|
//! that encodes the standard piecewise-linear decoding formula. The 256-entry
|
||||||
|
//! table fits in L1; the table lookup is the decode fast path -- no branchy
|
||||||
|
//! segment arithmetic at decode call time.
|
||||||
|
//!
|
||||||
|
//! # The µ-law decode formula (ITU-T G.711 reference, teachable-moment)
|
||||||
|
//!
|
||||||
|
//! µ-law bytes are stored bit-inverted on the wire (the encoder finishes with
|
||||||
|
//! a bitwise XOR mask, see `mulaw_encode_table.rs`). The decoder's first move
|
||||||
|
//! is therefore `let toggled = !u;` -- undo the encoder's terminal inversion
|
||||||
|
//! and recover the raw exponent/mantissa/sign bits.
|
||||||
|
//!
|
||||||
|
//! After toggling:
|
||||||
|
//! * bit 7 (MSB) = 1 --> original PCM was NEGATIVE.
|
||||||
|
//! * bits 6..4 --> 3-bit segment exponent (0..7); larger = louder.
|
||||||
|
//! * bits 3..0 --> 4-bit mantissa (offset within segment).
|
||||||
|
//!
|
||||||
|
//! The biased magnitude is reconstructed as
|
||||||
|
//! `t = ((mantissa << 3) + BIAS) << exponent` (BIAS = 0x84 = 132),
|
||||||
|
//! then the reference decoder applies a final sign-aware adjustment:
|
||||||
|
//! * positive --> return `t - BIAS`
|
||||||
|
//! * negative --> return `BIAS - t`
|
||||||
|
//!
|
||||||
|
//! This BIAS add/subtract on both sides of the encode/decode is the companding
|
||||||
|
//! "compression mid-tread" property: zero PCM in, zero PCM out (silent input
|
||||||
|
//! encodes to 0xFF, decodes back to 0). The asymmetry `BIAS - t` for the
|
||||||
|
//! negative branch is what gives µ-law its slightly larger dynamic range on
|
||||||
|
//! the negative side (one extra notch below zero that the positive side lacks).
|
||||||
|
//!
|
||||||
|
//! # Why NOT a `g711` crate dep
|
||||||
|
//!
|
||||||
|
//! The codec has been standard since 1972 and is ~30 lines of table-driven
|
||||||
|
//! code. Pulling a dependency for a constant-mapping table violates the FOB
|
||||||
|
//! hygiene rule (ADR-0008 -- link mature OSS only when the complexity is
|
||||||
|
//! non-trivial; this is neither). It's also learner-facing: this file
|
||||||
|
//! TEACHES the telephony history, which earns the project's "learns Rust
|
||||||
|
//! from this codebase" goal a real win.
|
||||||
|
|
||||||
|
/// Decode bias per ITU-T G.711 §2.2 (the Sun Microsystems reference impl uses
|
||||||
|
/// bias = 0x84 = 132 for both decode and encode; the magic number traces
|
||||||
|
/// directly to the µ-law segment boundary at `BIAS << exponent`, which keeps
|
||||||
|
/// segment 0's t-value range `[BIAS, BIAS + 0x70)` exactly proportional to
|
||||||
|
/// the µ-law quantization step).
|
||||||
|
const BIAS: i32 = 0x84;
|
||||||
|
|
||||||
|
/// Convert one 8-bit µ-law byte to a 16-bit linear PCM sample.
|
||||||
|
///
|
||||||
|
/// Generated at compile time so the [`MULAW_TO_LINEAR`] table is a `static`
|
||||||
|
/// array -- no runtime cost beyond the L1 cache for the 256-byte lookup.
|
||||||
|
///
|
||||||
|
/// # Algorithm
|
||||||
|
///
|
||||||
|
/// Mirrors the ITU-T G.711 reference decoder (Sun Microsystems variant,
|
||||||
|
/// public domain). See the module-level docs for the formula walkthrough.
|
||||||
|
const fn mulaw_to_linear(u: u8) -> i16 {
|
||||||
|
// Undo the encoder's terminal bitwise XOR mask (0xFF for positive,
|
||||||
|
// 0x7F for negative). NOT is wholesale inversion -- recovers the raw
|
||||||
|
// sign | exponent | mantissa bits the encoder assembled pre-XOR.
|
||||||
|
let toggled = !u;
|
||||||
|
|
||||||
|
// After NOT: MSB = 1 iff the original PCM sample was NEGATIVE.
|
||||||
|
let is_negative = (toggled & 0x80) != 0;
|
||||||
|
|
||||||
|
let exponent = ((toggled >> 4) & 0x07) as i32;
|
||||||
|
let mantissa = (toggled & 0x0F) as i32;
|
||||||
|
|
||||||
|
// Reconstruct the biased magnitude: `((mant << 3) + BIAS) << exp`.
|
||||||
|
// The `<< 3` aligns the 4-bit mantissa to the segment's 8-sample step;
|
||||||
|
// the `+ BIAS` shifts every segment's range up by BIAS so the lowest
|
||||||
|
// segment (exp=0) covers `[BIAS, BIAS + 0x70)` (the µ-law near-zero band).
|
||||||
|
let t = ((mantissa << 3) + BIAS) << exponent;
|
||||||
|
|
||||||
|
// Sign-aware final adjustment -- the reference decoder does NOT just
|
||||||
|
// subtract the bias from `t` symmetrically: the negative branch inverts
|
||||||
|
// the subtraction (`BIAS - t`), preserving the µ-law "mid-tread" zero.
|
||||||
|
// With this, an input of zero PCM decodes back through `mulaw_to_linear`
|
||||||
|
// (the encoder maps 0 -> 0xFF) and yields zero, not BIAS.
|
||||||
|
if is_negative {
|
||||||
|
(BIAS - t) as i16
|
||||||
|
} else {
|
||||||
|
(t - BIAS) as i16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 256-entry µ-law decode table (one slot per possible 8-bit byte).
|
||||||
|
///
|
||||||
|
/// `#[rustfmt::skip]` keeps rustfmt from reflowing the const-eval initializer
|
||||||
|
/// (a `while` loop initializing 256 entries) -- the canonical idiom for
|
||||||
|
/// compile-time table generation in stable Rust (no `const_for` needed).
|
||||||
|
#[rustfmt::skip]
|
||||||
|
pub static MULAW_TO_LINEAR: [i16; 256] = {
|
||||||
|
let mut t = [0i16; 256];
|
||||||
|
let mut i = 0;
|
||||||
|
while i < 256 {
|
||||||
|
t[i] = mulaw_to_linear(i as u8);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
t
|
||||||
|
};
|
||||||
113
crates/rutster-trunk/src/mulaw_encode_table.rs
Normal file
113
crates/rutster-trunk/src/mulaw_encode_table.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
//! 16-bit linear to 8-bit µ-law encode table -- ITU-T G.711 (1972, reaffirmed 2000).
|
||||||
|
//!
|
||||||
|
//! The 65536-entry / 64 KB table is computed at compile time by
|
||||||
|
//! [`linear_to_mulaw`], a `const fn` encoding the standard piecewise-linear
|
||||||
|
//! companding formula. The encode hot path is a single indexed lookup --
|
||||||
|
//! branchless on the 20 ms tick.
|
||||||
|
//!
|
||||||
|
//! # The µ-law encode formula (ITU-T G.711 reference, teachable-moment)
|
||||||
|
//!
|
||||||
|
//! µ-law compresses 16-bit linear PCM to 8-bit logarithmic PCM via piecewise
|
||||||
|
//! companding -- 8 segments, each twice the quantization step of the prior.
|
||||||
|
//! The fundamental transform tracks a biased magnitude:
|
||||||
|
//!
|
||||||
|
//! * For `s >= 0` (non-negative): `biased = s + BIAS`
|
||||||
|
//! * For `s < 0` (negative): `biased = BIAS - s` (= BIAS + magnitude)
|
||||||
|
//!
|
||||||
|
//! where `BIAS = 0x84 = 132`. The bias has a subtle reason: the lowest
|
||||||
|
//! segment (the "near-zero" band, encoded as mantissa range [0..15]) starts
|
||||||
|
//! at `BIAS`, which guarantees segment 0 has a symmetric, centered range
|
||||||
|
//! around the zero PCM point. Without BIAS, the 8-segment decoder would
|
||||||
|
//! produce an asymmetric "near-zero" band that biases silences toward +0.
|
||||||
|
//!
|
||||||
|
//! The biased magnitude is clipped to `0x7FFF` (32767) to bound the segment
|
||||||
|
//! search -- this matches the upper end of segment 7's range (`seg_uend[7] = 0x7FFF`).
|
||||||
|
//!
|
||||||
|
//! The segment exponent is then `floor(log2(biased)) - 7`, clamped to `[0, 7]`.
|
||||||
|
//! The mantissa is the top 4 bits of `biased >> (exponent + 3)` -- i.e. the
|
||||||
|
//! high-nibble of the segment-local position.
|
||||||
|
//!
|
||||||
|
//! Finally the encoder assembles `uval = (exponent << 4) | mantissa` (7 bits,
|
||||||
|
//! MSB unset) and applies a terminal XOR mask that inverts 7 (negative) or
|
||||||
|
//! 8 (positive) bits. The mask trick is what makes µ-law "self-clocking" on
|
||||||
|
//! the wire: every consecutive byte has at least one transition (no 0x00
|
||||||
|
//! runs dominate silence on T1/E1 trunks historically).
|
||||||
|
//!
|
||||||
|
//! # Why NOT a `g711` crate dep
|
||||||
|
//!
|
||||||
|
//! Same argument as `mulaw_decode_table.rs` -- standard, ~30 lines, no
|
||||||
|
//! complexity a maintained dep would solve; learner-facing.
|
||||||
|
|
||||||
|
/// Encode bias per ITU-T G.711 §2.2 (matches `mulaw_decode_table::BIAS`).
|
||||||
|
const BIAS: i32 = 0x84;
|
||||||
|
|
||||||
|
/// Convert one 16-bit linear PCM sample to an 8-bit µ-law byte.
|
||||||
|
///
|
||||||
|
/// `const fn` so the [`LINEAR_TO_MULAW`] table is built at compile time and
|
||||||
|
/// stored as a 64 KB `static` -- the encode hot path is one indexed lookup,
|
||||||
|
/// no branchy segment search at call time.
|
||||||
|
const fn linear_to_mulaw(s: i16) -> u8 {
|
||||||
|
let is_negative = s < 0;
|
||||||
|
|
||||||
|
// Bias the magnitude: positive samples get `s + BIAS`; negatives get
|
||||||
|
// `BIAS - s` = `BIAS + |s|`. Both branches yield `biased >= BIAS` (>= 132),
|
||||||
|
// so the segment search below never sees the degenerate biased = 0 case.
|
||||||
|
let biased: i32 = if is_negative {
|
||||||
|
BIAS - (s as i32)
|
||||||
|
} else {
|
||||||
|
(s as i32) + BIAS
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clip to 0x7FFF (32767) per the Sun reference impl. After clipping the
|
||||||
|
// largest biased value lives in segment 7's range (`seg_uend[7] = 0x7FFF`),
|
||||||
|
// bounding the segment search to [0, 7].
|
||||||
|
let biased: i32 = if biased > 0x7FFF { 0x7FFF } else { biased };
|
||||||
|
|
||||||
|
// Segment exponent = floor(log2(biased)) - 7, clamped to [0, 7].
|
||||||
|
//
|
||||||
|
// `leading_zeros` on a positive `u32` returns `31 - floor(log2(x))` for
|
||||||
|
// x > 0; we cast `biased` (provably positive -- no sign-bit ambiguity)
|
||||||
|
// to `u32` first to avoid clippy::cast_sign_loss on the i32 path.
|
||||||
|
let log2_biased = 31 - (biased as u32).leading_zeros() as i32;
|
||||||
|
// `i32::max` / `i32::min` aren't const fn stable as of Rust 1.85; the
|
||||||
|
// equivalent clamp via if/else is the canonical const-eval workaround
|
||||||
|
// (and unavoidable here -- no const trait-method dispatch available
|
||||||
|
// yet without unstable features).
|
||||||
|
let exponent = if log2_biased < 7 {
|
||||||
|
0
|
||||||
|
} else if log2_biased > 14 {
|
||||||
|
7
|
||||||
|
} else {
|
||||||
|
log2_biased - 7
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mantissa = top 4 bits of the segment-local position. Shifting right by
|
||||||
|
// `exponent + 3` aligns the segment's lowest 3 bits to the bottom of the
|
||||||
|
// u32 (discarded), then the next 4 bits are the mantissa.
|
||||||
|
let mantissa = ((biased >> (exponent + 3)) & 0x0F) as u8;
|
||||||
|
let uval = ((exponent as u8) << 4) | mantissa;
|
||||||
|
|
||||||
|
// Terminal XOR mask: 0xFF for positive (inverts all 8 bits -- MSB
|
||||||
|
// becomes 1), 0x7F for negative (inverts bits 0..6, preserves MSB as 0).
|
||||||
|
// This is the µ-law wire convention: stored byte MSB = 1 iff the original
|
||||||
|
// PCM sample was non-negative (the encoder's guaranteed-transition trick
|
||||||
|
// keeps T1/E1 line codes alive on silence runs).
|
||||||
|
let mask: u8 = if is_negative { 0x7F } else { 0xFF };
|
||||||
|
uval ^ mask
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 65536-entry µ-law encode table (one slot per possible 16-bit PCM value).
|
||||||
|
///
|
||||||
|
/// The 64 KB binary footprint is trivial on modern hardware; the L1 trade-off
|
||||||
|
/// favors hot-path speed (one indexed lookup vs ~8 iterations of a segment
|
||||||
|
/// search). The const-eval initializer runs at compile time -- no runtime cost.
|
||||||
|
#[rustfmt::skip]
|
||||||
|
pub static LINEAR_TO_MULAW: [u8; 65536] = {
|
||||||
|
let mut t = [0u8; 65536];
|
||||||
|
let mut i = 0;
|
||||||
|
while i < 65536 {
|
||||||
|
t[i] = linear_to_mulaw(i as i16);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
t
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user