From 8fe7ce7ee57b4f613689034f05b995e8ab3ea29a Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sun, 5 Jul 2026 03:27:00 -0400 Subject: [PATCH] =?UTF-8?q?feat(trunk):=20G711Codec=20=E2=80=94=20=C2=B5-l?= =?UTF-8?q?aw=20encode/decode=20+=208kHz=E2=86=9424kHz=20linear-interpolat?= =?UTF-8?q?ed=20resampling=20(slice-5=20T1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 17 ++ crates/rutster-trunk/Cargo.toml | 36 ++- crates/rutster-trunk/src/g711.rs | 266 ++++++++++++++++++ crates/rutster-trunk/src/lib.rs | 34 ++- .../rutster-trunk/src/mulaw_decode_table.rs | 101 +++++++ .../rutster-trunk/src/mulaw_encode_table.rs | 113 ++++++++ 6 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 crates/rutster-trunk/src/g711.rs create mode 100644 crates/rutster-trunk/src/mulaw_decode_table.rs create mode 100644 crates/rutster-trunk/src/mulaw_encode_table.rs diff --git a/Cargo.lock b/Cargo.lock index 8e504bf..ed6d026 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,7 @@ dependencies = [ "async-trait", "axum-core", "axum-macros", + "base64", "bytes", "futures-util", "http", @@ -161,8 +162,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -1280,6 +1283,20 @@ dependencies = [ [[package]] name = "rutster-trunk" 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]] name = "ryu" diff --git a/crates/rutster-trunk/Cargo.toml b/crates/rutster-trunk/Cargo.toml index bd92d0d..f433896 100644 --- a/crates/rutster-trunk/Cargo.toml +++ b/crates/rutster-trunk/Cargo.toml @@ -5,4 +5,38 @@ version = "0.0.0" license.workspace = true edition.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 } diff --git a/crates/rutster-trunk/src/g711.rs b/crates/rutster-trunk/src/g711.rs new file mode 100644 index 0000000..9164286 --- /dev/null +++ b/crates/rutster-trunk/src/g711.rs @@ -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`, 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 { + 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() + } +} diff --git a/crates/rutster-trunk/src/lib.rs b/crates/rutster-trunk/src/lib.rs index 96aee3b..8fa5c24 100644 --- a/crates/rutster-trunk/src/lib.rs +++ b/crates/rutster-trunk/src/lib.rs @@ -16,9 +16,39 @@ //! canonical tap format and handed to the media plane as a media-leg ingress, parallel to //! 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)] 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] - fn crate_compiles() {} + fn crate_compiles() { + let frame = G711Codec::decode_mulaw_to_pcm(&[0xFF; 160]); + assert_eq!(frame.samples.len(), 480); + } } diff --git a/crates/rutster-trunk/src/mulaw_decode_table.rs b/crates/rutster-trunk/src/mulaw_decode_table.rs new file mode 100644 index 0000000..bb69d5b --- /dev/null +++ b/crates/rutster-trunk/src/mulaw_decode_table.rs @@ -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 +}; diff --git a/crates/rutster-trunk/src/mulaw_encode_table.rs b/crates/rutster-trunk/src/mulaw_encode_table.rs new file mode 100644 index 0000000..fd979af --- /dev/null +++ b/crates/rutster-trunk/src/mulaw_encode_table.rs @@ -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 +};