From bcd775747a094bccebad3f815ae8115f6807fb97 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 | 335 +++++++++++++++--- crates/rutster-trunk/Cargo.toml | 52 ++- crates/rutster-trunk/src/g711.rs | 266 ++++++++++++++ crates/rutster-trunk/src/lib.rs | 37 +- .../rutster-trunk/src/mulaw_decode_table.rs | 101 ++++++ .../rutster-trunk/src/mulaw_encode_table.rs | 113 ++++++ 6 files changed, 809 insertions(+), 95 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 8c19b1f..ec5e4f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "asn1-rs" @@ -113,9 +113,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -124,14 +124,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -143,6 +144,7 @@ dependencies = [ "async-trait", "axum-core", "axum-macros", + "base64", "bytes", "futures-util", "http", @@ -161,8 +163,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -219,6 +223,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.13.0" @@ -254,9 +267,9 @@ checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "cc" -version = "1.2.65" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", @@ -452,12 +465,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" -dependencies = [ - "powerfmt", -] +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "digest" @@ -471,9 +481,9 @@ dependencies = [ [[package]] name = "dimpl" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a11091ebc139b18d6b5878d8769ca43ad5ea8a7e2f5ed1e1f25e172951ef0ce" +checksum = "bb2b8390bf0021eae92cf96a0e3a54c531b6315c47edfe9220f5fc61bc9e1f63" dependencies = [ "aes", "arrayvec", @@ -767,13 +777,106 @@ dependencies = [ ] [[package]] -name = "idna" -version = "0.5.0" +name = "icu_collections" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] @@ -849,6 +952,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -944,9 +1053,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -954,9 +1063,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -1060,6 +1169,15 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1248,9 +1366,9 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.7" +version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "aws-lc-rs", "rustls-pki-types", @@ -1339,9 +1457,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rusticata-macros" @@ -1381,9 +1499,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -1511,9 +1629,17 @@ name = "rutster-trunk" version = "0.0.0" dependencies = [ "async-trait", + "axum", + "base64", "reqwest", + "rutster-call-model", + "rutster-media", + "rutster-tap", + "serde", "serde_json", + "thiserror 1.0.69", "tokio", + "tower", "tracing", "url", ] @@ -1532,9 +1658,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sctp-proto" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5376eaf8a764118abd6bee29673c68eab91f0913b448fe2944e74488b63c37b5" +checksum = "199c5c38008c2c151e27afd228230b1b69f849b1f7629f5df86662ee1f456187" dependencies = [ "bytes", "crc", @@ -1697,6 +1823,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "str0m" version = "0.21.0" @@ -1845,35 +1977,44 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -2077,27 +2218,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - [[package]] name = "untrusted" version = "0.7.1" @@ -2112,13 +2238,14 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.2" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -2127,6 +2254,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.23.4" @@ -2352,6 +2485,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "x509-cert" version = "0.2.5" @@ -2383,13 +2522,37 @@ dependencies = [ [[package]] name = "yasna" -version = "0.5.2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ + "bit-vec", "time", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.52" @@ -2410,12 +2573,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/crates/rutster-trunk/Cargo.toml b/crates/rutster-trunk/Cargo.toml index 0ec7af3..4b3c0de 100644 --- a/crates/rutster-trunk/Cargo.toml +++ b/crates/rutster-trunk/Cargo.toml @@ -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`) 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>` 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"] 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 16e7ed6..7d2a816 100644 --- a/crates/rutster-trunk/src/lib.rs +++ b/crates/rutster-trunk/src/lib.rs @@ -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); } } 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 +};