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

335
Cargo.lock generated
View File

@@ -34,9 +34,9 @@ dependencies = [
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.7" version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]] [[package]]
name = "asn1-rs" name = "asn1-rs"
@@ -113,9 +113,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "aws-lc-rs" name = "aws-lc-rs"
version = "1.17.0" version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
dependencies = [ dependencies = [
"aws-lc-sys", "aws-lc-sys",
"untrusted 0.7.1", "untrusted 0.7.1",
@@ -124,14 +124,15 @@ dependencies = [
[[package]] [[package]]
name = "aws-lc-sys" name = "aws-lc-sys"
version = "0.41.0" version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
dependencies = [ dependencies = [
"cc", "cc",
"cmake", "cmake",
"dunce", "dunce",
"fs_extra", "fs_extra",
"pkg-config",
] ]
[[package]] [[package]]
@@ -143,6 +144,7 @@ dependencies = [
"async-trait", "async-trait",
"axum-core", "axum-core",
"axum-macros", "axum-macros",
"base64",
"bytes", "bytes",
"futures-util", "futures-util",
"http", "http",
@@ -161,8 +163,10 @@ dependencies = [
"serde_json", "serde_json",
"serde_path_to_error", "serde_path_to_error",
"serde_urlencoded", "serde_urlencoded",
"sha1",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-tungstenite",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
@@ -219,6 +223,15 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.0" version = "2.13.0"
@@ -254,9 +267,9 @@ checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.65" version = "1.2.66"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -452,12 +465,9 @@ dependencies = [
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.4.0" version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]] [[package]]
name = "digest" name = "digest"
@@ -471,9 +481,9 @@ dependencies = [
[[package]] [[package]]
name = "dimpl" name = "dimpl"
version = "0.7.0" version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a11091ebc139b18d6b5878d8769ca43ad5ea8a7e2f5ed1e1f25e172951ef0ce" checksum = "bb2b8390bf0021eae92cf96a0e3a54c531b6315c47edfe9220f5fc61bc9e1f63"
dependencies = [ dependencies = [
"aes", "aes",
"arrayvec", "arrayvec",
@@ -767,13 +777,106 @@ dependencies = [
] ]
[[package]] [[package]]
name = "idna" name = "icu_collections"
version = "0.5.0" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [ dependencies = [
"unicode-bidi", "displaydoc",
"unicode-normalization", "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]] [[package]]
@@ -849,6 +952,12 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -944,9 +1053,9 @@ dependencies = [
[[package]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.6" version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [ dependencies = [
"num-integer", "num-integer",
"num-traits", "num-traits",
@@ -954,9 +1063,9 @@ dependencies = [
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-integer" name = "num-integer"
@@ -1060,6 +1169,15 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" 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]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.0"
@@ -1248,9 +1366,9 @@ dependencies = [
[[package]] [[package]]
name = "rcgen" name = "rcgen"
version = "0.14.7" version = "0.14.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"rustls-pki-types", "rustls-pki-types",
@@ -1339,9 +1457,9 @@ dependencies = [
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]] [[package]]
name = "rusticata-macros" name = "rusticata-macros"
@@ -1381,9 +1499,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.14.1" version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [ dependencies = [
"web-time", "web-time",
"zeroize", "zeroize",
@@ -1511,9 +1629,17 @@ name = "rutster-trunk"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum",
"base64",
"reqwest", "reqwest",
"rutster-call-model",
"rutster-media",
"rutster-tap",
"serde",
"serde_json", "serde_json",
"thiserror 1.0.69",
"tokio", "tokio",
"tower",
"tracing", "tracing",
"url", "url",
] ]
@@ -1532,9 +1658,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "sctp-proto" name = "sctp-proto"
version = "0.10.0" version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5376eaf8a764118abd6bee29673c68eab91f0913b448fe2944e74488b63c37b5" checksum = "199c5c38008c2c151e27afd228230b1b69f849b1f7629f5df86662ee1f456187"
dependencies = [ dependencies = [
"bytes", "bytes",
"crc", "crc",
@@ -1697,6 +1823,12 @@ dependencies = [
"der", "der",
] ]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]] [[package]]
name = "str0m" name = "str0m"
version = "0.21.0" version = "0.21.0"
@@ -1845,35 +1977,44 @@ dependencies = [
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.41" version = "0.3.53"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa",
"num-conv", "num-conv",
"powerfmt", "powerfmt",
"serde", "serde_core",
"time-core", "time-core",
"time-macros", "time-macros",
] ]
[[package]] [[package]]
name = "time-core" name = "time-core"
version = "0.1.4" version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]] [[package]]
name = "time-macros" name = "time-macros"
version = "0.2.22" version = "0.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
dependencies = [ dependencies = [
"num-conv", "num-conv",
"time-core", "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]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.11.0" version = "1.11.0"
@@ -2077,27 +2218,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 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]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.7.1" version = "0.7.1"
@@ -2112,13 +2238,14 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.2" version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [ dependencies = [
"form_urlencoded", "form_urlencoded",
"idna", "idna",
"percent-encoding", "percent-encoding",
"serde",
] ]
[[package]] [[package]]
@@ -2127,6 +2254,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.4" version = "1.23.4"
@@ -2352,6 +2485,12 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]] [[package]]
name = "x509-cert" name = "x509-cert"
version = "0.2.5" version = "0.2.5"
@@ -2383,13 +2522,37 @@ dependencies = [
[[package]] [[package]]
name = "yasna" name = "yasna"
version = "0.5.2" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
dependencies = [ dependencies = [
"bit-vec",
"time", "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]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.52" version = "0.8.52"
@@ -2410,12 +2573,66 @@ dependencies = [
"syn", "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]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.9.0" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" 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]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"

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)." description = "Rented carrier transport — CPaaS media-leg ingress; no first-party SIP (spearhead step 5, ADR-0007)."
[dependencies] [dependencies]
# async-trait: lets us declare `async fn` in the CallControlClient trait while # FOB-side (dev-c, T1/T3/T4/T5):
# still using it as a trait object (`Box<dyn CallControlClient>`) in the route rutster-media = { path = "../rutster-media" }
# handlers. Native async-fn-in-traits (stable since 1.75) does NOT support rutster-call-model = { path = "../rutster-call-model" }
# `dyn` dispatch without manual desugaring — async_trait rewrites the signature rutster-tap = { path = "../rutster-tap" }
# to `fn -> Pin<Box<dyn Future + Send>>` for us (spec §3.4). 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 } 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 } url = { workspace = true }
# reqwest + tracing + serde_json are OPTIONAL — only pulled in when the # reqwest is OPTIONAL — only pulled in when `twilio-live` is enabled (the
# `twilio-live` feature is enabled (the live `TwilioCallControlClient`, T6). # live `TwilioCallControlClient`, T6). Keeps default CI build lean.
# 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 = { workspace = true, optional = true } reqwest = { workspace = true, optional = true }
tracing = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
[dev-dependencies] [dev-dependencies]
# tokio: only the dev-dep is needed for the mock's `#[tokio::test]` attribute tower = { workspace = true }
# (`#[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 }
[features] [features]
default = [] default = []
# The live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live` # The live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live`.
# so the routine CI gate stays feature-default-off: `MockCallControlClient` is # `MockCallControlClient` is the per-PR test surface; the maintainer runs
# the per-PR test surface; the maintainer runs `cargo test --features=twilio-live` # `cargo test --features=twilio-live` manually pre-release (ADR-0009).
# + the e2e suite only when validating a release (against real Twilio creds). twilio-live = ["dep:reqwest"]
# 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"]

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; 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)] #[cfg(test)]
mod tests { mod tests {
use crate::provider::MockCallControlClient; use crate::provider::MockCallControlClient;
use crate::g711::G711Codec;
/// Stub crates lock boundaries; the compile-test is the lock. Now that the /// Stub crates lock boundaries; the compile-test is the lock. Now that
/// provider module is populated (T2), this test also guards the crate-level /// the provider module (T2, dev-b) + FOB modules (T1, dev-c) both land,
/// re-exports compile against the public API surface. /// this test guards the crate-level re-exports compile against the
/// combined public API surface.
#[test] #[test]
fn crate_compiles() { 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(); 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
};