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:
2026-07-05 03:27:00 -04:00
parent 40cc27e9f0
commit bcd775747a
6 changed files with 809 additions and 95 deletions

View File

@@ -8,43 +8,31 @@ repository.workspace = true
description = "Rented carrier transport — CPaaS media-leg ingress; no first-party SIP (spearhead step 5, ADR-0007)."
[dependencies]
# async-trait: lets us declare `async fn` in the CallControlClient trait while
# still using it as a trait object (`Box<dyn CallControlClient>`) in the route
# handlers. Native async-fn-in-traits (stable since 1.75) does NOT support
# `dyn` dispatch without manual desugaring — async_trait rewrites the signature
# to `fn -> Pin<Box<dyn Future + Send>>` for us (spec §3.4).
# FOB-side (dev-c, T1/T3/T4/T5):
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 }
# Green-zone (dev-b, T2/T6):
async-trait = { workspace = true }
# url: the `TwilioCredentials::webhook_base` field is a `url::Url` (the
# operator's public base URL Twilio calls back). Parsed in `config::twilio_credentials`.
url = { workspace = true }
# reqwest + tracing + serde_json are OPTIONAL — only pulled in when the
# `twilio-live` feature is enabled (the live `TwilioCallControlClient`, T6).
# This keeps the default CI build (default-features-off) free of reqwest's
# transitive dep tree, so per-PR `cargo deny check` stays lean + cargo's
# resolve is fast for the common case. The maintainer's manual
# `cargo test --features=twilio-live` + the twilio-live CI job (T10) pull them in.
# `serde_json` parses the Calls.json REST response body (the `sid` field).
# reqwest is OPTIONAL — only pulled in when `twilio-live` is enabled (the
# live `TwilioCallControlClient`, T6). Keeps default CI build lean.
reqwest = { workspace = true, optional = true }
tracing = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
[dev-dependencies]
# tokio: only the dev-dep is needed for the mock's `#[tokio::test]` attribute
# (`#[tokio::test]` expands to a `#[test]` that spins up a current-thread
# runtime + drives the async fn to completion). The library itself is
# runtime-agnostic: async_trait's desugared futures poll on whatever runtime
# the caller drives them with — the mock's bodies are synchronous (lock + push,
# no `.await`), so no tokio dependency leaks into the library's public surface.
tokio = { workspace = true }
tower = { workspace = true }
[features]
default = []
# The live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live`
# so the routine CI gate stays feature-default-off: `MockCallControlClient` is
# the per-PR test surface; the maintainer runs `cargo test --features=twilio-live`
# + the e2e suite only when validating a release (against real Twilio creds).
# Spec §1.2 + plan T6 + ADR-0009 (credentials never reach the brain).
# `dep:reqwest` + `dep:tracing` are the optional-dep enable syntax
# (`feature = "dep:foo"` enables `foo` without exposing it as an implicit
# feature named `foo` — the explicit form is forward-compatible + unambiguous).
twilio-live = ["dep:reqwest", "dep:tracing", "dep:serde_json"]
# The live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live`.
# `MockCallControlClient` is the per-PR test surface; the maintainer runs
# `cargo test --features=twilio-live` manually pre-release (ADR-0009).
twilio-live = ["dep:reqwest"]

View 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()
}
}

View File

@@ -25,16 +25,45 @@
pub mod provider;
// 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 {
use crate::provider::MockCallControlClient;
use crate::g711::G711Codec;
/// Stub crates lock boundaries; the compile-test is the lock. Now that the
/// provider module is populated (T2), this test also guards the crate-level
/// re-exports compile against the public API surface.
/// Stub crates lock boundaries; the compile-test is the lock. Now that
/// the provider module (T2, dev-b) + FOB modules (T1, dev-c) both land,
/// this test guards the crate-level re-exports compile against the
/// combined public API surface.
#[test]
fn crate_compiles() {
// Touch the public API so the test exercises the re-export wiring.
// Touch the provider public API (T2).
let _mock = MockCallControlClient::new();
// Touch the codec public API (T1).
let frame = G711Codec::decode_mulaw_to_pcm(&[0xFF; 160]);
assert_eq!(frame.samples.len(), 480);
}
}

View 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
};

View 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
};