diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c627743..d05ecb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,8 @@ name: CI +permissions: + contents: read + on: push: branches: @@ -11,7 +14,7 @@ jobs: name: Rustfmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable - name: cargo fmt @@ -21,7 +24,7 @@ jobs: name: Clippy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable - name: cargo clippy @@ -31,7 +34,7 @@ jobs: name: Cargo Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable - name: cargo check @@ -41,7 +44,7 @@ jobs: name: MSRV Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@1.87.0 - name: cargo check @@ -53,9 +56,9 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-latest, windows-2025-vs2026] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable - name: Install ffmpeg (Ubuntu) @@ -67,18 +70,39 @@ jobs: if: matrix.os == 'macos-latest' run: brew install ffmpeg - name: Install ffmpeg (Windows) - if: matrix.os == 'windows-latest' + if: startsWith(matrix.os, 'windows-') shell: pwsh run: choco install ffmpeg --no-progress -y - name: cargo test run: cargo test + windows-static-crt: + name: Windows Static CRT + runs-on: windows-2025-vs2026 + env: + CARGO_TARGET_DIR: target/ci-windows-static-crt + CRT_VERIFY_TARGET: x86_64-pc-windows-msvc + RUSTFLAGS: -C target-feature=+crt-static + steps: + - uses: actions/checkout@v6 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + - name: Install ffmpeg + shell: pwsh + run: choco install ffmpeg --no-progress -y + - name: Verify static CRT imports + run: python scripts/verify_windows_static_crt.py + - name: cargo test (static CRT) + run: cargo test --target x86_64-pc-windows-msvc + avx-presume: name: AVX presume feature runs-on: ubuntu-latest needs: [fmt, clippy, check, tests] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable - name: Verify AVX presume gating @@ -88,23 +112,73 @@ jobs: name: System Library runs-on: ubuntu-latest needs: [fmt, clippy, check, tests] + env: + CARGO_TARGET_DIR: target/ci-system-lib steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable - name: Install build dependencies run: | sudo apt-get update - sudo apt-get install -y pkg-config ffmpeg curl build-essential autoconf automake libtool cmake + sudo apt-get install -y build-essential cmake pkg-config ffmpeg + - name: Install pinned Opus system library + env: + OPUS_SYSTEM_BUILD_DIR: ${{ runner.temp }}/opus-system-build + OPUS_SYSTEM_PREFIX: ${{ runner.temp }}/opus-system + run: | + # The git subtree has no standalone metadata for CMake to derive its release version. + cmake \ + -S opus \ + -B "$OPUS_SYSTEM_BUILD_DIR" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$OPUS_SYSTEM_PREFIX" \ + -DCMAKE_INSTALL_LIBDIR=lib \ + -DOPUS_PACKAGE_VERSION=1.5.2 \ + -DOPUS_BUILD_SHARED_LIBRARY=OFF \ + -DOPUS_BUILD_TESTING=OFF \ + -DOPUS_BUILD_PROGRAMS=OFF \ + -DOPUS_DRED=OFF \ + -DBUILD_SHARED_LIBS=OFF + cmake --build "$OPUS_SYSTEM_BUILD_DIR" --parallel + cmake --install "$OPUS_SYSTEM_BUILD_DIR" - name: Verify system lib build and tests + env: + PKG_CONFIG_PATH: ${{ runner.temp }}/opus-system/lib/pkgconfig run: python scripts/verify_system_lib.py + asan: + name: ASan + LeakSanitizer (bundled) + runs-on: ubuntu-latest + env: + ASAN_OPTIONS: detect_leaks=1:halt_on_error=1 + CARGO_TARGET_DIR: target/ci-asan + CC: clang + CFLAGS: -fsanitize=address -fno-omit-frame-pointer + RUSTFLAGS: -Zsanitizer=address + steps: + - uses: actions/checkout@v6 + - name: Install nightly Rust + uses: dtolnay/rust-toolchain@nightly + with: + targets: x86_64-unknown-linux-gnu + - name: Install sanitizer test dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang llvm ffmpeg wget + - name: Test Rust and bundled C with ASan and leak detection + run: >- + cargo +nightly test + --target x86_64-unknown-linux-gnu + --features dred + --all-targets + dred: name: DRED (bundled) runs-on: ubuntu-latest needs: [fmt, clippy, check, tests] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable - name: Install deps (ffmpeg + wget for model download) diff --git a/Cargo.lock b/Cargo.lock index f1f2e3d..3f2d1a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,24 +27,33 @@ dependencies = [ "quote", "regex", "rustc-hash", - "shlex", + "shlex 1.3.0", "syn", ] [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] [[package]] name = "cc" -version = "1.2.50" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -62,6 +71,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -75,18 +95,53 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "errno" @@ -100,26 +155,26 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "getrandom" -version = "0.3.4" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi", - "wasip2", + "rand_core", ] [[package]] @@ -128,6 +183,15 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "itertools" version = "0.13.0" @@ -139,9 +203,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.178" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" @@ -155,21 +219,21 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "minimal-lexical" @@ -189,25 +253,27 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "opus-codec" -version = "0.1.2" +version = "0.2.0" dependencies = [ "bindgen", "cmake", "pkg-config", + "rand", + "sha2", "tempfile", ] [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "prettyplease" @@ -221,33 +287,50 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.3.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "regex" -version = "1.12.2" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -257,9 +340,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -268,21 +351,21 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", @@ -291,17 +374,34 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "syn" -version = "2.0.111" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -310,9 +410,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom", @@ -322,19 +422,16 @@ dependencies = [ ] [[package]] -name = "unicode-ident" -version = "1.0.22" +name = "typenum" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" +name = "unicode-ident" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "windows-link" @@ -350,9 +447,3 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/Cargo.toml b/Cargo.toml index fb11bb0..67dbed6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opus-codec" -version = "0.1.2" +version = "0.2.0" edition = "2024" authors = ["Denis Avvakumov"] description = "Safe Rust bindings for the Opus audio codec" @@ -11,13 +11,37 @@ homepage = "https://github.com/Deniskore" keywords = ["audio", "codec", "compression", "opus", "voice"] categories = ["api-bindings", "encoding", "compression", "multimedia::audio"] readme = "README.md" +exclude = [ + "opus/opus_data-*.tar.gz", + "opus/dnn/models/**", + "opus/dnn/dred_rdovae_constants.h", + "opus/dnn/dred_rdovae_dec_data.c", + "opus/dnn/dred_rdovae_dec_data.h", + "opus/dnn/dred_rdovae_enc_data.c", + "opus/dnn/dred_rdovae_enc_data.h", + "opus/dnn/dred_rdovae_stats_data.c", + "opus/dnn/dred_rdovae_stats_data.h", + "opus/dnn/fargan_data.c", + "opus/dnn/fargan_data.h", + "opus/dnn/lace_data.c", + "opus/dnn/lace_data.h", + "opus/dnn/lossgen_data.c", + "opus/dnn/lossgen_data.h", + "opus/dnn/nolace_data.c", + "opus/dnn/nolace_data.h", + "opus/dnn/pitchdnn_data.c", + "opus/dnn/pitchdnn_data.h", + "opus/dnn/plc_data.c", + "opus/dnn/plc_data.h", +] [dependencies] [build-dependencies] cmake = { version = "0.1" } bindgen = "0.72.1" -pkg-config = "0.3" +pkg-config = "0.3.33" +sha2 = "0.11.0" [features] default = [] @@ -26,4 +50,5 @@ system-lib = [] presume-avx2 = [] [dev-dependencies] -tempfile = "3.23.0" +rand = "0.10.2" +tempfile = "3.27.0" diff --git a/README.md b/README.md index 82ef0bf..81d545b 100644 --- a/README.md +++ b/README.md @@ -31,3 +31,9 @@ at your option. The upstream libopus sources are vendored via `git subtree` at tag **v1.5.2** (split commit `ddbe48383984d56acd9e1ab6a090c54ca6b735a6`). You can verify the copy is pristine by diffing `opus/` against that upstream commit. + +## Windows MSVC + +Bundled builds follow Cargo's selected C runtime automatically. By default, `opus-codec` builds the vendored `libopus` with the dynamic MSVC runtime. If you build with `RUSTFLAGS="-C target-feature=+crt-static"`, the bundled `libopus` build switches to the static MSVC runtime as well. + +If you enable the `system-lib` feature, `opus-codec` links against an already installed `libopus` instead of the vendored copy. In that case, the installed `libopus` must use the same CRT mode as the final binary. diff --git a/build.rs b/build.rs index 21c6d57..84f74a3 100644 --- a/build.rs +++ b/build.rs @@ -1,17 +1,25 @@ +use sha2::{Digest, Sha256}; +use std::borrow::Cow; use std::env; +use std::path::{Path, PathBuf}; -fn main() { - emit_rerun_directives(); - let opts = BuildOptions::from_env(); - - if opts.use_system_lib { - handle_system_lib(&opts); - } else { - build_bundled_and_link(&opts); - } - - generate_bindings(); -} +const BUNDLED_PACKET_OPS_FINGERPRINTS: &[SourceFingerprint] = &[ + SourceFingerprint { + path: "src/opus.c", + len: 10_051, + sha256: "f5ae5ff3e9cef998addeee777dcb283cffcaf0f6ee4452108127e9157cdb2458", + }, + SourceFingerprint { + path: "src/repacketizer.c", + len: 13_550, + sha256: "ad6df845cdcd4e8a61a43069f2ee6f34a9ae7fa27c534935ebeda0f4d1903fa3", + }, + SourceFingerprint { + path: "src/extensions.c", + len: 9_373, + sha256: "f1458c7d257400b025181dfae96a5ec02d9fc76566a9b8f1ea65714e3dbb3459", + }, +]; struct BuildOptions { use_system_lib: bool, @@ -19,6 +27,7 @@ struct BuildOptions { presume_avx: bool, target_arch: String, avx_allowed: bool, + msvc_runtime: Option, } impl BuildOptions { @@ -28,6 +37,7 @@ impl BuildOptions { let presume_avx = env::var("CARGO_FEATURE_PRESUME_AVX2").is_ok(); let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); let avx_allowed = presume_avx && matches!(target_arch.as_str(), "x86" | "x86_64"); + let msvc_runtime = MsvcRuntime::from_cargo(); Self { use_system_lib, @@ -35,20 +45,93 @@ impl BuildOptions { presume_avx, target_arch, avx_allowed, + msvc_runtime, + } + } +} + +#[derive(Clone, Copy)] +enum MsvcRuntime { + Dynamic, + Static, +} + +#[derive(Clone, Copy)] +struct PacketOpsCompatibility { + rust_packet_ops: bool, + frame_bounded_extensions: bool, +} + +#[derive(Clone, Copy)] +struct SourceFingerprint { + path: &'static str, + len: u64, + sha256: &'static str, +} + +impl MsvcRuntime { + fn from_cargo() -> Option { + if !target_is_windows_msvc() { + return None; + } + + let uses_static_runtime = target_feature_enabled("crt-static"); + + Some(if uses_static_runtime { + Self::Static + } else { + Self::Dynamic + }) + } + + fn is_static(self) -> bool { + matches!(self, Self::Static) + } + + fn opus_static_runtime(self) -> &'static str { + match self { + Self::Dynamic => "OFF", + Self::Static => "ON", } } } +fn main() { + emit_rerun_directives(); + let opts = BuildOptions::from_env(); + + if opts.use_system_lib { + println!("cargo:rustc-cfg=opus_codec_system_lib"); + } + + if opts.use_system_lib { + handle_system_lib(&opts); + } else { + build_bundled_and_link(&opts); + } + + generate_bindings(); +} + fn emit_rerun_directives() { + println!("cargo:rustc-check-cfg=cfg(opus_codec_system_lib)"); + println!("cargo:rustc-check-cfg=cfg(opus_codec_rust_packet_ops)"); + println!("cargo:rustc-check-cfg=cfg(opus_codec_frame_bounded_extensions)"); println!("cargo:rerun-if-changed=opus/include/opus.h"); println!("cargo:rerun-if-changed=opus/include/opus_defines.h"); println!("cargo:rerun-if-changed=opus/include/opus_types.h"); println!("cargo:rerun-if-changed=opus/include/opus_multistream.h"); println!("cargo:rerun-if-changed=opus/include/opus_projection.h"); + println!("cargo:rerun-if-changed=opus/src/opus.c"); + println!("cargo:rerun-if-changed=opus/src/repacketizer.c"); + println!("cargo:rerun-if-changed=opus/src/extensions.c"); println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed=opus/dnn/download_model.sh"); + println!("cargo:rerun-if-changed=opus/opus_data-735117b.tar.gz"); println!("cargo:rerun-if-env-changed=CARGO_FEATURE_SYSTEM_LIB"); println!("cargo:rerun-if-env-changed=CARGO_FEATURE_PRESUME_AVX2"); + println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_ENV"); + println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_FAMILY"); + println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_FEATURE"); } fn handle_system_lib(opts: &BuildOptions) { @@ -62,13 +145,11 @@ fn handle_system_lib(opts: &BuildOptions) { "cargo:warning=presume-avx2 feature enabled; ensure the system libopus was built with OPUS_X86_PRESUME_AVX2" ); } - link_system_lib(); + let lib = link_system_lib(); + emit_system_libopus_cfg(&lib.version); } fn build_bundled_and_link(opts: &BuildOptions) { - if opts.dred_enabled { - ensure_dred_assets(); - } if opts.presume_avx && !opts.avx_allowed { println!( "cargo:warning=presume-avx2 feature only applies to x86/x86_64 targets; ignoring for {}", @@ -76,36 +157,42 @@ fn build_bundled_and_link(opts: &BuildOptions) { ); } - let dst = build_bundled(opts.dred_enabled, opts.avx_allowed); + let opus_source = bundled_opus_source(opts); + let dst = build_bundled(opts, &opus_source); + emit_bundled_libopus_cfg(&opus_source); println!("cargo:rustc-link-search=native={}/lib", dst.display()); + println!("cargo:rustc-link-search=native={}/lib64", dst.display()); println!("cargo:rustc-link-lib=static=opus"); } -fn build_bundled(dred_enabled: bool, presume_avx: bool) -> std::path::PathBuf { - let mut config = cmake::Config::new("opus"); +fn bundled_opus_source(opts: &BuildOptions) -> PathBuf { + if opts.dred_enabled { + prepare_dred_opus_source() + } else { + PathBuf::from("opus") + } +} + +fn build_bundled(opts: &BuildOptions, opus_source: &Path) -> std::path::PathBuf { + let mut config = cmake::Config::new(opus_source); config.profile("Release"); - if should_use_msvc_crt_flag() { - let profile = env::var("PROFILE").unwrap_or_default(); - let crt_flag = if profile.eq_ignore_ascii_case("debug") { - "/MDd" - } else { - "/MD" - }; - config.cflag(crt_flag); + if let Some(runtime) = opts.msvc_runtime { + config.static_crt(runtime.is_static()); + config.define("OPUS_STATIC_RUNTIME", runtime.opus_static_runtime()); } config .define("OPUS_BUILD_SHARED_LIBRARY", "OFF") .define("OPUS_BUILD_TESTING", "OFF") .define("OPUS_BUILD_PROGRAMS", "OFF") - .define("OPUS_DRED", if dred_enabled { "ON" } else { "OFF" }) + .define("OPUS_DRED", if opts.dred_enabled { "ON" } else { "OFF" }) .define("BUILD_SHARED_LIBS", "OFF") .define("OPUS_DISABLE_INTRINSICS", "OFF") .define("CMAKE_POSITION_INDEPENDENT_CODE", "ON"); - if presume_avx { + if opts.presume_avx { config .define("OPUS_X86_PRESUME_AVX2", "ON") .define("OPUS_X86_MAY_HAVE_AVX2", "ON"); @@ -114,11 +201,258 @@ fn build_bundled(dred_enabled: bool, presume_avx: bool) -> std::path::PathBuf { config.build() } -fn link_system_lib() { +fn link_system_lib() -> pkg_config::Library { pkg_config::Config::new() .atleast_version("1.5.2") .probe("opus") - .expect("system-lib feature requested but pkg-config couldn't find libopus"); + .expect("system-lib feature requested but pkg-config couldn't find libopus") +} + +fn emit_system_libopus_cfg(version: &str) { + match version { + "1.5.2" => emit_packet_ops_cfg(PacketOpsCompatibility { + rust_packet_ops: true, + frame_bounded_extensions: false, + }), + "1.6.1" => emit_packet_ops_cfg(PacketOpsCompatibility { + rust_packet_ops: true, + frame_bounded_extensions: true, + }), + _ => println!( + "cargo:warning=system libopus {version} is not one of the exact packet-op versions \ + supported by opus-codec (1.5.2, 1.6.1); packet padding and repacketizer emission \ + will delegate to the linked C libopus" + ), + } +} + +fn emit_bundled_libopus_cfg(opus_source: &Path) { + if bundled_packet_ops_match(opus_source) { + emit_packet_ops_cfg(PacketOpsCompatibility { + rust_packet_ops: true, + frame_bounded_extensions: false, + }); + } else { + println!( + "cargo:warning=vendored libopus packet-op sources do not match the audited \ + compatibility fingerprints; packet padding and repacketizer emission will \ + delegate to bundled C libopus" + ); + } +} + +fn emit_packet_ops_cfg(compatibility: PacketOpsCompatibility) { + if compatibility.rust_packet_ops { + emit_rust_packet_ops_cfg(); + } + if compatibility.frame_bounded_extensions { + emit_frame_bounded_extensions_cfg(); + } +} + +fn emit_rust_packet_ops_cfg() { + println!("cargo:rustc-cfg=opus_codec_rust_packet_ops"); +} + +fn emit_frame_bounded_extensions_cfg() { + println!("cargo:rustc-cfg=opus_codec_frame_bounded_extensions"); +} + +fn bundled_packet_ops_match(opus_source: &Path) -> bool { + BUNDLED_PACKET_OPS_FINGERPRINTS + .iter() + .all(|fingerprint| source_fingerprint_matches(opus_source, *fingerprint)) +} + +fn source_fingerprint_matches(opus_source: &Path, fingerprint: SourceFingerprint) -> bool { + let path = opus_source.join(fingerprint.path); + let bytes = std::fs::read(&path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + let bytes = normalize_source_line_endings(&bytes); + let actual_len = u64::try_from(bytes.len()).expect("source file length does not fit in u64"); + let actual_hash = sha256_hex_bytes(&bytes); + if actual_len == fingerprint.len && actual_hash == fingerprint.sha256 { + return true; + } + + println!( + "cargo:warning=vendored libopus packet-op source fingerprint mismatch for {}: \ + expected normalized len {}, sha256 {}; got normalized len {}, sha256 {}", + path.display(), + fingerprint.len, + fingerprint.sha256, + actual_len, + actual_hash + ); + false +} + +fn normalize_source_line_endings(bytes: &[u8]) -> Cow<'_, [u8]> { + if !bytes.windows(2).any(|window| window == b"\r\n") { + return Cow::Borrowed(bytes); + } + + let mut normalized = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'\r' && bytes.get(index + 1) == Some(&b'\n') { + normalized.push(b'\n'); + index += 2; + } else { + normalized.push(bytes[index]); + index += 1; + } + } + Cow::Owned(normalized) +} + +fn prepare_dred_opus_source() -> PathBuf { + let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is not set by Cargo")); + let opus_source = out_dir.join("opus-dred-src"); + if opus_source.exists() { + std::fs::remove_dir_all(&opus_source) + .unwrap_or_else(|err| panic!("failed to remove {}: {err}", opus_source.display())); + } + copy_opus_source_tree(Path::new("opus"), &opus_source) + .unwrap_or_else(|err| panic!("failed to copy vendored opus source: {err}")); + ensure_dred_assets(&opus_source, &out_dir); + opus_source +} + +fn copy_opus_source_tree(src: &Path, dst: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if should_skip_dred_generated_path(&src_path) { + continue; + } + let metadata = entry.metadata()?; + if metadata.is_dir() { + copy_opus_source_tree(&src_path, &dst_path)?; + } else if metadata.is_file() { + std::fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +fn should_skip_dred_generated_path(path: &Path) -> bool { + let Ok(rel) = path.strip_prefix("opus") else { + return false; + }; + let rel = rel.to_string_lossy().replace('\\', "/"); + matches!( + rel.as_str(), + "opus_data-735117b.tar.gz" + | "dnn/dred_rdovae_constants.h" + | "dnn/dred_rdovae_dec_data.c" + | "dnn/dred_rdovae_dec_data.h" + | "dnn/dred_rdovae_enc_data.c" + | "dnn/dred_rdovae_enc_data.h" + | "dnn/dred_rdovae_stats_data.c" + | "dnn/dred_rdovae_stats_data.h" + | "dnn/fargan_data.c" + | "dnn/fargan_data.h" + | "dnn/lace_data.c" + | "dnn/lace_data.h" + | "dnn/lossgen_data.c" + | "dnn/lossgen_data.h" + | "dnn/nolace_data.c" + | "dnn/nolace_data.h" + | "dnn/pitchdnn_data.c" + | "dnn/pitchdnn_data.h" + | "dnn/plc_data.c" + | "dnn/plc_data.h" + | "dnn/models" + ) || rel.starts_with("dnn/models/") +} + +fn ensure_dred_assets(opus_source: &Path, out_dir: &Path) { + use std::path::Component; + use std::process::Command; + + const MODEL_REV: &str = "735117b"; + const MODEL_ARCHIVE: &str = "opus_data-735117b.tar.gz"; + const MODEL_SHA256: &str = "8f34305a299183509d22c7ba66790f67916a0fc56028ebd4c8f7b938458f2801"; + const REQUIRED_FILE: &str = "dnn/fargan_data.h"; + if opus_source.join(REQUIRED_FILE).exists() { + return; + } + + let cached_archive_path = Path::new("opus").join(MODEL_ARCHIVE); + let archive_path = if cached_archive_path.exists() { + std::fs::canonicalize(&cached_archive_path).unwrap_or_else(|err| { + panic!( + "failed to canonicalize cached DRED archive {}: {err}", + cached_archive_path.display() + ) + }) + } else { + out_dir.join(MODEL_ARCHIVE) + }; + if !archive_path.exists() { + let status = Command::new("wget") + .arg("-O") + .arg(&archive_path) + .arg(format!( + "https://media.xiph.org/opus/models/opus_data-{MODEL_REV}.tar.gz" + )) + .status() + .expect("failed to spawn wget for DRED model download"); + + if !status.success() { + panic!("downloading DRED model assets failed (exit status: {status})"); + } + } + + let actual = sha256_hex(&archive_path); + if actual != MODEL_SHA256 { + panic!( + "DRED model archive checksum mismatch for {}: expected {}, got {}", + archive_path.display(), + MODEL_SHA256, + actual + ); + } + + let listing = Command::new("tar") + .arg("tf") + .arg(&archive_path) + .output() + .expect("failed to list DRED model archive"); + if !listing.status.success() { + panic!( + "listing DRED model archive failed (exit status: {})", + listing.status + ); + } + for entry in String::from_utf8_lossy(&listing.stdout).lines() { + let path = Path::new(entry); + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + panic!("DRED model archive contains unsafe path: {entry}"); + } + } + + let status = Command::new("tar") + .arg("xvomf") + .arg(&archive_path) + .current_dir(opus_source) + .status() + .expect("failed to extract DRED model archive"); + if !status.success() { + panic!("extracting DRED model assets failed (exit status: {status})"); + } + + if !opus_source.join(REQUIRED_FILE).exists() { + panic!("DRED model download completed but {REQUIRED_FILE} is still missing"); + } } fn generate_bindings() { @@ -146,39 +480,35 @@ fn generate_bindings() { .expect("Couldn't write bindings!"); } -fn should_use_msvc_crt_flag() -> bool { +fn target_is_windows_msvc() -> bool { matches!( env::var("CARGO_CFG_TARGET_FAMILY").as_deref(), Ok("windows") ) && matches!(env::var("CARGO_CFG_TARGET_ENV").as_deref(), Ok("msvc")) } -fn ensure_dred_assets() { - use std::path::Path; - use std::process::Command; - - const REQUIRED_FILE: &str = "opus/dnn/fargan_data.h"; - if Path::new(REQUIRED_FILE).exists() { - return; - } - - let script = Path::new("opus/dnn/download_model.sh"); - if !script.exists() { - panic!("DRED feature requires {script:?}, but it was not found"); +fn target_feature_enabled(feature_name: &str) -> bool { + match env::var("CARGO_CFG_TARGET_FEATURE") { + Ok(features) => features + .split(',') + .map(str::trim) + .any(|feature| feature == feature_name), + Err(_) => false, } +} - let status = Command::new("sh") - .arg("dnn/download_model.sh") - .arg("735117b") - .current_dir("opus") - .status() - .expect("failed to spawn DRED model download script"); - - if !status.success() { - panic!("downloading DRED model assets failed (exit status: {status})"); - } +fn sha256_hex(path: &Path) -> String { + let bytes = std::fs::read(path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + sha256_hex_bytes(&bytes) +} - if !Path::new(REQUIRED_FILE).exists() { - panic!("DRED model download completed but {REQUIRED_FILE} is still missing"); +fn sha256_hex_bytes(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut hex = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + write!(&mut hex, "{byte:02x}").expect("writing to String should not fail"); } + hex } diff --git a/scripts/ci_utils.py b/scripts/ci_utils.py index 836d3e9..abcaf0b 100644 --- a/scripts/ci_utils.py +++ b/scripts/ci_utils.py @@ -1,20 +1,39 @@ +import json import os +import shlex import subprocess import sys from contextlib import contextmanager from pathlib import Path -from typing import Dict, List, Optional, Union +from typing import Any, Dict, Iterable, List, Optional, Union + @contextmanager def group(name: str): """Group output in GitHub Actions.""" - # Only print group markers if running in GitHub Actions or if forced - # But for now, we'll always print them as they are harmless in local terminals - print(f"::group::{name}") + print(f"::group::{name}", flush=True) try: yield finally: - print("::endgroup::") + print("::endgroup::", flush=True) + + +def _display_command(cmd: List[str]) -> str: + if os.name == "nt": + return subprocess.list2cmdline(cmd) + return shlex.join(cmd) + + +def _print_captured_failure(exc: subprocess.CalledProcessError) -> None: + if exc.stdout: + print(exc.stdout, end="" if exc.stdout.endswith("\n") else "\n") + if exc.stderr: + print( + exc.stderr, + end="" if exc.stderr.endswith("\n") else "\n", + file=sys.stderr, + ) + def run( cmd: List[str], @@ -24,35 +43,86 @@ def run( capture_output: bool = False, ) -> subprocess.CompletedProcess: """Run a command with optional grouping and error handling.""" - cmd_str = " ".join(str(c) for c in cmd) - - # Don't group if capturing output, as it's likely an internal check + cmd_str = _display_command(cmd) should_group = not capture_output - + if should_group: - print(f"::group::{cmd_str}") - + print(f"::group::{cmd_str}", flush=True) + try: run_env = os.environ.copy() if env: run_env.update(env) - + result = subprocess.run( cmd, env=run_env, cwd=cwd, check=check, text=True, - capture_output=capture_output + capture_output=capture_output, ) return result - except subprocess.CalledProcessError as e: + except subprocess.CalledProcessError as exc: if should_group: - print(f"Command failed with exit code {e.returncode}") + print(f"Command failed with exit code {exc.returncode}", flush=True) + elif capture_output: + _print_captured_failure(exc) raise finally: if should_group: - print("::endgroup::") + print("::endgroup::", flush=True) + + +def cargo_json_messages(output: str) -> Iterable[Dict[str, Any]]: + """Yield Cargo JSON messages, ignoring non-JSON tool and test output.""" + for line in output.splitlines(): + line = line.strip() + if not line.startswith("{"): + continue + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(message, dict) and "reason" in message: + yield message + + +def cargo_root_package_id(cwd: Optional[Union[str, Path]] = None) -> str: + """Return the package ID for the workspace's single default package.""" + output = run( + ["cargo", "metadata", "--no-deps", "--format-version=1"], + cwd=cwd, + capture_output=True, + ).stdout + metadata = json.loads(output) + default_members = metadata.get("workspace_default_members", []) + if len(default_members) != 1: + fail( + "Expected exactly one default workspace package, found " + f"{len(default_members)}" + ) + return str(default_members[0]) + + +def cargo_root_build_script( + output: str, cwd: Optional[Union[str, Path]] = None +) -> Dict[str, Any]: + """Return the root package's unique build-script-executed message.""" + package_id = cargo_root_package_id(cwd) + matches = [ + message + for message in cargo_json_messages(output) + if message.get("reason") == "build-script-executed" + and message.get("package_id") == package_id + ] + if len(matches) != 1: + fail( + "Expected one root build-script message from Cargo, found " + f"{len(matches)}" + ) + return matches[0] + def fail(msg: str) -> None: """Exit with an error message.""" diff --git a/scripts/verify_avx.py b/scripts/verify_avx.py index 6673422..f0b3d2a 100644 --- a/scripts/verify_avx.py +++ b/scripts/verify_avx.py @@ -2,83 +2,181 @@ """ Build and verify AVX-presume gating for the bundled opus library. -- Builds a generic target (no presume feature) and expects no AVX flag/instructions. -- Builds a presume target (presume-avx2 feature) and expects AVX flag/instructions. +- The generic build must retain AVX2 runtime dispatch while keeping baseline + source files free of AVX instructions. +- The presume build must define OPUS_X86_PRESUME_AVX2, apply AVX2/FMA flags to + every Opus C source, and emit AVX instructions in a baseline source file. + +Generic libopus archives intentionally contain separately compiled AVX2 +dispatch objects, so the archive as a whole must not be required to be AVX-free. """ +import platform +import re +import shlex +import shutil from pathlib import Path -from typing import Optional, Tuple -import ci_utils - - -PRESUME_FLAG = "OPUS_X86_PRESUME_AVX2:BOOL=ON" - -def newest_build_dir(target_dir: Path) -> Optional[Path]: - build_root = target_dir / "release" / "build" - if not build_root.exists(): - return None - # Find all opus-codec-* directories - candidates = [p for p in build_root.glob("opus-codec-*") if p.is_dir()] - if not candidates: - return None - # Return the most recently modified one - return max(candidates, key=lambda p: p.stat().st_mtime) +import ci_utils -def find_artifacts(base: Path) -> Tuple[Optional[Path], Optional[Path]]: - # Recursive search for artifacts - caches = list(base.rglob("CMakeCache.txt")) - objs = list(base.rglob("bands.c.o")) - return (caches[0] if caches else None), (objs[0] if objs else None) +MAY_HAVE_CACHE_KEY = "OPUS_X86_MAY_HAVE_AVX2" +PRESUME_CACHE_KEY = "OPUS_X86_PRESUME_AVX2" +MAY_HAVE_DEFINE = "-DOPUS_X86_MAY_HAVE_AVX2" +PRESUME_DEFINE = "-DOPUS_X86_PRESUME_AVX2" +RTCD_DEFINE = "-DOPUS_HAVE_RTCD" +REQUIRED_AVX_FLAGS = {"-mavx", "-mavx2", "-mfma"} +BASELINE_OBJECT = Path("CMakeFiles/opus.dir/celt/bands.c.o") +DISPATCH_OBJECT = Path("CMakeFiles/opus.dir/celt/x86/pitch_avx.c.o") + + +def cache_bool(cache: Path, key: str) -> bool: + pattern = re.compile(rf"^{re.escape(key)}:BOOL=(ON|OFF)$", re.MULTILINE) + match = pattern.search(cache.read_text(encoding="utf-8")) + if not match: + ci_utils.fail(f"{key}:BOOL was not found in {cache}") + return match.group(1) == "ON" + + +def make_variable(path: Path, name: str) -> list[str]: + lines = path.read_text(encoding="utf-8").splitlines() + prefix = f"{name} =" + for index, line in enumerate(lines): + if not line.startswith(prefix): + continue + value = line[len(prefix) :].strip() + while value.endswith("\\") and index + 1 < len(lines): + index += 1 + value = f"{value[:-1]} {lines[index].strip()}" + return shlex.split(value) + ci_utils.fail(f"{name} was not found in {path}") + + +def avx_mnemonics(objdump: str, obj: Path) -> set[str]: + output = ci_utils.run( + [objdump, "-d", "--no-show-raw-insn", str(obj)], + capture_output=True, + ).stdout + mnemonics = set() + instruction = re.compile(r"^\s*[0-9a-f]+:\s+([a-z][a-z0-9.]*)", re.IGNORECASE) + for line in output.splitlines(): + match = instruction.match(line) + if match and match.group(1).lower().startswith("v"): + mnemonics.add(match.group(1).lower()) + return mnemonics -def verify(target_dir: str, features: str, expect_flag: bool, expect_avx: bool) -> None: - # Build - cmd = ["cargo", "build", "--release"] +def build(target_dir: str, features: str) -> Path: + cmd = ["cargo", "build", "--release", "--message-format=json-render-diagnostics"] if features: cmd += ["--features", features] - - ci_utils.run(cmd, env={"CARGO_TARGET_DIR": target_dir}) - - # Verify - target = Path(target_dir) - build_dir = newest_build_dir(target) - if not build_dir: - ci_utils.fail(f"build dir not found under {target_dir}") - - print(f"Checking build dir: {build_dir}") - - cache, obj = find_artifacts(target) - if not cache or not obj: - print(f"Artifacts missing for {target_dir}") - print("Found CMakeCache.txt:", [str(p) for p in target.rglob("CMakeCache.txt")]) - print("Found bands.c.o:", [str(p) for p in target.rglob("bands.c.o")]) - ci_utils.fail("Missing required build artifacts") - - # Check CMake cache for flag - cache_content = cache.read_text() - flag_present = PRESUME_FLAG in cache_content - if flag_present != expect_flag: + result = ci_utils.run( + cmd, + env={ + "CARGO_TARGET_DIR": target_dir, + "CMAKE_GENERATOR": "Unix Makefiles", + }, + capture_output=True, + ) + message = ci_utils.cargo_root_build_script(result.stdout) + out_dir = Path(message["out_dir"]) + print(f"Cargo reported Opus OUT_DIR: {out_dir}") + return out_dir + + +def verify( + ar: str, objdump: str, target_dir: str, features: str, expect_presume: bool +) -> None: + with ci_utils.group(f"Build {'presume' if expect_presume else 'generic'} Opus"): + out_dir = build(target_dir, features) + + cmake_build = out_dir / "build" + cache = cmake_build / "CMakeCache.txt" + flags_file = cmake_build / "CMakeFiles/opus.dir/flags.make" + baseline_obj = cmake_build / BASELINE_OBJECT + dispatch_obj = cmake_build / DISPATCH_OBJECT + archive = out_dir / "lib" / "libopus.a" + missing = [ + path + for path in (cache, flags_file, baseline_obj, dispatch_obj, archive) + if not path.is_file() + ] + if missing: + ci_utils.fail("Missing expected build artifacts: " + ", ".join(map(str, missing))) + + archive_members = set( + ci_utils.run([ar, "t", str(archive)], capture_output=True).stdout.splitlines() + ) + for member in (BASELINE_OBJECT.name, DISPATCH_OBJECT.name): + if member not in archive_members: + ci_utils.fail(f"{member} is missing from final archive {archive}") + + if not cache_bool(cache, MAY_HAVE_CACHE_KEY): + ci_utils.fail(f"{MAY_HAVE_CACHE_KEY} must be ON in {cache}") + actual_presume = cache_bool(cache, PRESUME_CACHE_KEY) + if actual_presume != expect_presume: ci_utils.fail( - f"AVX presume flag mismatch in {cache}: expected={expect_flag}, got={flag_present}" + f"{PRESUME_CACHE_KEY} mismatch in {cache}: " + f"expected={expect_presume}, got={actual_presume}" ) - # Check object file for AVX instructions - disasm = ci_utils.run( - ["objdump", "-d", str(obj)], capture_output=True - ).stdout - - has_avx = "ymm" in disasm - if has_avx != expect_avx: + definitions = set(make_variable(flags_file, "C_DEFINES")) + c_flags = set(make_variable(flags_file, "C_FLAGS")) + if MAY_HAVE_DEFINE not in definitions: + ci_utils.fail(f"{MAY_HAVE_DEFINE} missing from {flags_file}") + if (PRESUME_DEFINE in definitions) != expect_presume: + ci_utils.fail( + f"{PRESUME_DEFINE} mismatch in {flags_file}: expected={expect_presume}" + ) + if not expect_presume and RTCD_DEFINE not in definitions: + ci_utils.fail(f"Generic build is missing runtime dispatch define {RTCD_DEFINE}") + + global_avx_flags = REQUIRED_AVX_FLAGS.intersection(c_flags) + if expect_presume and global_avx_flags != REQUIRED_AVX_FLAGS: + ci_utils.fail( + f"Presume build is missing global flags: " + f"{sorted(REQUIRED_AVX_FLAGS - global_avx_flags)}" + ) + if not expect_presume and global_avx_flags: + ci_utils.fail( + f"Generic baseline unexpectedly has global AVX flags: {sorted(global_avx_flags)}" + ) + + baseline_mnemonics = avx_mnemonics(objdump, baseline_obj) + dispatch_mnemonics = avx_mnemonics(objdump, dispatch_obj) + if not dispatch_mnemonics: + ci_utils.fail(f"No AVX/VEX instructions found in dispatch object {dispatch_obj}") + if expect_presume and not baseline_mnemonics: + ci_utils.fail( + f"No AVX/VEX instructions found in presume baseline object {baseline_obj}" + ) + if not expect_presume and baseline_mnemonics: ci_utils.fail( - f"AVX instructions mismatch in {obj}: expected={expect_avx}, got={has_avx}" + f"Generic baseline object {baseline_obj} contains AVX/VEX instructions: " + f"{sorted(baseline_mnemonics)}" ) + print( + f"Verified {'presume' if expect_presume else 'generic'} build; " + f"baseline AVX mnemonic count={len(baseline_mnemonics)}, " + f"dispatch AVX mnemonic count={len(dispatch_mnemonics)}" + ) def main() -> None: - verify("target/ci-generic", "", False, False) - verify("target/ci-presume", "presume-avx2", True, True) + if platform.system() != "Linux" or platform.machine().lower() not in { + "amd64", + "x86_64", + }: + ci_utils.fail("verify_avx.py requires x86-64 Linux") + objdump = shutil.which("objdump") + if not objdump: + ci_utils.fail("GNU objdump was not found on PATH") + ar = shutil.which("ar") + if not ar: + ci_utils.fail("GNU ar was not found on PATH") + + verify(ar, objdump, "target/ci-generic", "", False) + verify(ar, objdump, "target/ci-presume", "presume-avx2", True) if __name__ == "__main__": diff --git a/scripts/verify_system_lib.py b/scripts/verify_system_lib.py index c54add1..6519bed 100644 --- a/scripts/verify_system_lib.py +++ b/scripts/verify_system_lib.py @@ -2,118 +2,72 @@ """ Verify system libopus usage: -- Ensures pkg-config reports libopus 1.5.2. -- Builds and tests with the `system-lib` feature. +- Requires pkg-config to find libopus 1.5.2 or newer. +- Verifies Cargo selected the system-lib build-script path rather than CMake. +- Builds and tests every Rust target with the `system-lib` feature. """ -import subprocess +import os from pathlib import Path -from typing import Optional import ci_utils -DEB_URLS = { - "dev": [ - "https://deb.debian.org/debian/pool/main/o/opus/libopus-dev_1.5.2-2_amd64.deb", - "https://mirrors.edge.kernel.org/debian/pool/main/o/opus/libopus-dev_1.5.2-2_amd64.deb", - ], - "runtime": [ - "https://deb.debian.org/debian/pool/main/o/opus/libopus0_1.5.2-2_amd64.deb", - "https://mirrors.edge.kernel.org/debian/pool/main/o/opus/libopus0_1.5.2-2_amd64.deb", - ], -} - -EXPECTED_VERSION = "1.5.2" - - -def pkg_config_version() -> Optional[str]: - try: - out = ci_utils.run( - ["pkg-config", "--modversion", "opus"], capture_output=True - ).stdout - return out.strip() - except subprocess.CalledProcessError as exc: - print(f"pkg-config failed: {exc}") - return None - - -def download_first(urls, dest: Path) -> bool: - for u in urls: - try: - ci_utils.run(["curl", "-fLsS", u, "-o", str(dest)]) - print(f"Downloaded {u}") - return True - except subprocess.CalledProcessError: - print(f"Download failed from {u}, trying next mirror...") - return False - - -def install_debs_if_needed() -> None: - ver = pkg_config_version() - if ver == EXPECTED_VERSION: - print(f"libopus already at {EXPECTED_VERSION}") - return - - # Fast path: try the packaged version first. - with ci_utils.group("Install libopus-dev from apt"): - try: - ci_utils.run(["sudo", "apt-get", "update"]) - ci_utils.run(["sudo", "apt-get", "install", "-y", "libopus-dev"]) - except subprocess.CalledProcessError: - print("apt-get install libopus-dev failed, will try deb mirrors") - - ver_after_apt = pkg_config_version() - if ver_after_apt == EXPECTED_VERSION: - print(f"libopus at {EXPECTED_VERSION} after apt install") - return - - # Try downloading Debian packages on Ubuntu runners. - # Only proceed if /etc/os-release indicates Ubuntu. - os_release = Path("/etc/os-release") - if os_release.exists(): - data = os_release.read_text().lower() - if "ubuntu" not in data: - ci_utils.fail( - f"Expected libopus {EXPECTED_VERSION} but found {ver_after_apt}; not on Ubuntu, aborting" - ) - else: - ci_utils.fail( - f"Expected libopus {EXPECTED_VERSION} but found {ver_after_apt}; /etc/os-release missing" - ) - - runtime_deb = Path("/tmp/libopus0.deb") - dev_deb = Path("/tmp/libopus-dev.deb") - - with ci_utils.group("Download libopus debs"): - ok = download_first(DEB_URLS["runtime"], runtime_deb) and download_first( - DEB_URLS["dev"], dev_deb - ) - if not ok: - ci_utils.fail("Failed to download libopus debs from all mirrors") - - with ci_utils.group("Install libopus debs"): - try: - ci_utils.run(["sudo", "dpkg", "-i", str(runtime_deb), str(dev_deb)]) - except subprocess.CalledProcessError: - print("dpkg failed, trying apt-get install -f") - ci_utils.run(["sudo", "apt-get", "install", "-f", "-y"]) - - ver_after = pkg_config_version() - if ver_after != EXPECTED_VERSION: - ci_utils.fail( - f"After deb install, expected libopus {EXPECTED_VERSION} but found {ver_after}" - ) +MINIMUM_VERSION = "1.5.2" +TARGET_DIR = os.environ.get("CARGO_TARGET_DIR", "target/ci-system-lib") + + +def verify_pkg_config() -> str: + ci_utils.run( + ["pkg-config", "--print-errors", f"--atleast-version={MINIMUM_VERSION}", "opus"], + capture_output=True, + ) + version = ci_utils.run( + ["pkg-config", "--modversion", "opus"], capture_output=True + ).stdout.strip() + print(f"pkg-config opus version: {version}") + return version + + +def verify_system_build() -> None: + env = {"CARGO_TARGET_DIR": TARGET_DIR} + result = ci_utils.run( + [ + "cargo", + "test", + "--no-run", + "--all-targets", + "--features", + "system-lib", + "--message-format=json-render-diagnostics", + ], + env=env, + capture_output=True, + ) + message = ci_utils.cargo_root_build_script(result.stdout) + cfgs = set(message.get("cfgs", [])) + linked_libs = set(message.get("linked_libs", [])) + linked_names = {lib.split("=", 1)[-1] for lib in linked_libs} + linked_paths = [str(path) for path in message.get("linked_paths", [])] + out_dir = Path(message["out_dir"]) + + if "opus_codec_system_lib" not in cfgs: + ci_utils.fail("Cargo build script did not emit opus_codec_system_lib") + if "opus" not in linked_names: + ci_utils.fail(f"Unexpected system-lib link directives: {sorted(linked_libs)}") + if any(str(out_dir) in path for path in linked_paths): + ci_utils.fail(f"System-lib build linked a bundled OUT_DIR path: {linked_paths}") + if (out_dir / "build" / "CMakeCache.txt").exists(): + ci_utils.fail(f"System-lib build unexpectedly configured bundled CMake in {out_dir}") + + print(f"Verified system-lib build-script path in {out_dir}") + ci_utils.run( + ["cargo", "test", "--all-targets", "--features", "system-lib"], env=env + ) def main() -> None: - install_debs_if_needed() - ver = pkg_config_version() - print(f"pkg-config opus version: {ver}") - if ver != EXPECTED_VERSION: - ci_utils.fail(f"Expected libopus {EXPECTED_VERSION} but found {ver}") - - ci_utils.run(["cargo", "build", "--features", "system-lib"]) - ci_utils.run(["cargo", "test", "--features", "system-lib"]) + verify_pkg_config() + verify_system_build() if __name__ == "__main__": diff --git a/scripts/verify_windows_static_crt.py b/scripts/verify_windows_static_crt.py new file mode 100644 index 0000000..ce53cbf --- /dev/null +++ b/scripts/verify_windows_static_crt.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Build Windows static-CRT test executables and verify they do not import +dynamic CRT DLLs. +""" + +import os +import re +import shutil +from pathlib import Path +from typing import Iterable, List + +import ci_utils + + +TARGET = os.environ.get("CRT_VERIFY_TARGET", "x86_64-pc-windows-msvc") +TARGET_DIR = Path(os.environ.get("CARGO_TARGET_DIR", "target/ci-windows-static-crt")) +FORBIDDEN_DLL_PREFIXES = ( + "api-ms-win-crt-", + "msvcp", + "msvcr", + "ucrtbase", + "vcruntime", +) +TARGET_ARCHES = { + "aarch64-pc-windows-msvc": "arm64", + "i686-pc-windows-msvc": "x86", + "x86_64-pc-windows-msvc": "x64", +} + + +def target_arch() -> str: + arch = TARGET_ARCHES.get(TARGET) + if not arch: + ci_utils.fail(f"Unsupported MSVC verification target: {TARGET}") + return arch + + +def find_dumpbin() -> Path: + dumpbin = shutil.which("dumpbin") + if dumpbin: + return Path(dumpbin) + + program_files_x86 = Path( + os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") + ) + vswhere = ( + program_files_x86 + / "Microsoft Visual Studio" + / "Installer" + / "vswhere.exe" + ) + if not vswhere.exists(): + ci_utils.fail("vswhere.exe not found and dumpbin.exe is not on PATH") + + install_path = ci_utils.run( + [ + str(vswhere), + "-latest", + "-products", + "*", + "-requires", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "-property", + "installationPath", + ], + capture_output=True, + ).stdout.strip() + if not install_path: + ci_utils.fail("Could not locate a Visual Studio installation with VC tools") + + candidates = sorted( + Path(install_path).glob( + f"VC/Tools/MSVC/*/bin/Hostx64/{target_arch()}/dumpbin.exe" + ) + ) + if not candidates: + ci_utils.fail("dumpbin.exe not found inside the Visual Studio installation") + return candidates[-1] + + +def cache_bool(cache: Path, key: str) -> bool: + pattern = re.compile(rf"^{re.escape(key)}:BOOL=(ON|OFF)$", re.MULTILINE) + match = pattern.search(cache.read_text(encoding="utf-8")) + if not match: + ci_utils.fail(f"{key}:BOOL was not found in {cache}") + return match.group(1) == "ON" + + +def build_test_executables() -> List[Path]: + result = ci_utils.run( + [ + "cargo", + "test", + "--no-run", + "--all-targets", + "--message-format=json", + "--target", + TARGET, + "--target-dir", + str(TARGET_DIR), + ], + capture_output=True, + ) + + build_script = ci_utils.cargo_root_build_script(result.stdout) + out_dir = Path(build_script["out_dir"]) + cache = out_dir / "build" / "CMakeCache.txt" + if not cache.is_file(): + ci_utils.fail(f"Bundled Opus CMake cache not found at {cache}") + if not cache_bool(cache, "OPUS_STATIC_RUNTIME"): + ci_utils.fail(f"Bundled Opus was not configured with a static CRT in {cache}") + print(f"Verified OPUS_STATIC_RUNTIME=ON in {cache}") + + target_root = (TARGET_DIR / TARGET).resolve() + executables: List[Path] = [] + for message in ci_utils.cargo_json_messages(result.stdout): + if message.get("reason") != "compiler-artifact": + continue + + executable = message.get("executable") + if not executable: + continue + + path = Path(executable).resolve() + if path.suffix.lower() != ".exe": + continue + if target_root not in path.parents: + continue + executables.append(path) + + deduped = list(dict.fromkeys(executables)) + if not deduped: + ci_utils.fail("No target test executables were produced for import inspection") + return deduped + + +def imported_dlls(dumpbin: Path, executable: Path) -> List[str]: + output = ci_utils.run( + [str(dumpbin), "/nologo", "/dependents", str(executable)], + capture_output=True, + ).stdout + imports = [] + for line in output.splitlines(): + candidate = line.strip().lower() + if candidate.endswith(".dll"): + imports.append(candidate) + if not imports: + ci_utils.fail( + f"dumpbin reported no recognizable DLL imports for {executable}; " + "refusing to treat an empty parse as success" + ) + return imports + + +def forbidden_imports(imports: Iterable[str]) -> List[str]: + return sorted( + { + dll + for dll in imports + if dll.startswith(FORBIDDEN_DLL_PREFIXES) + } + ) + + +def main() -> None: + if os.name != "nt": + ci_utils.fail("verify_windows_static_crt.py must run on Windows") + + dumpbin = find_dumpbin() + print(f"Using dumpbin at: {dumpbin}") + + executables = build_test_executables() + print(f"Inspecting {len(executables)} test executable(s)") + + failures = [] + for executable in executables: + with ci_utils.group(f"Inspect {executable.name}"): + imports = imported_dlls(dumpbin, executable) + for dll in imports: + print(f" {dll}") + forbidden = forbidden_imports(imports) + if forbidden: + failures.append((executable, forbidden)) + + if failures: + for executable, forbidden in failures: + print(f"{executable} imports forbidden CRT DLLs: {', '.join(forbidden)}") + ci_utils.fail("Static CRT verification failed") + + print("Static CRT verification passed: no dynamic CRT DLL imports found.") + + +if __name__ == "__main__": + main() diff --git a/src/constants.rs b/src/constants.rs index 245c353..ab89f6a 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -17,3 +17,18 @@ pub const fn max_frame_samples_for(sample_rate: SampleRate) -> usize { // sample_rate.as_i32() is always positive given valid SampleRate enum values (MAX_FRAME_SAMPLES_48KHZ * (sample_rate as usize)) / 48_000 } + +/// Number of samples per channel in a 2.5 ms frame at the given `sample_rate`. +/// +/// libopus requires PLC/FEC and DRED frame sizes to be multiples of this value. +#[must_use] +pub const fn samples_per_2_5ms(sample_rate: SampleRate) -> usize { + (sample_rate as usize) / 400 +} + +/// Returns `true` when `frame_size` is a multiple of 2.5 ms at `sample_rate`. +#[must_use] +pub const fn is_frame_size_2_5ms_aligned(frame_size: usize, sample_rate: SampleRate) -> bool { + let quant = samples_per_2_5ms(sample_rate); + quant > 0 && frame_size.is_multiple_of(quant) +} diff --git a/src/decoder.rs b/src/decoder.rs index 3fb4e83..52783ed 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -1,9 +1,7 @@ //! Opus decoder implementation with safe wrappers #[cfg(feature = "dred")] -use crate::bindings::{ - OPUS_GET_DRED_DURATION_REQUEST, OPUS_SET_DNN_BLOB_REQUEST, OPUS_SET_DRED_DURATION_REQUEST, -}; +use crate::bindings::OPUS_SET_DNN_BLOB_REQUEST; use crate::bindings::{ OPUS_GET_FINAL_RANGE_REQUEST, OPUS_GET_GAIN_REQUEST, OPUS_GET_LAST_PACKET_DURATION_REQUEST, OPUS_GET_PHASE_INVERSION_DISABLED_REQUEST, OPUS_GET_PITCH_REQUEST, @@ -12,7 +10,7 @@ use crate::bindings::{ opus_decoder_create, opus_decoder_ctl, opus_decoder_destroy, opus_decoder_get_nb_samples, opus_decoder_get_size, opus_decoder_init, }; -use crate::constants::max_frame_samples_for; +use crate::constants::{is_frame_size_2_5ms_aligned, max_frame_samples_for}; use crate::error::{Error, Result}; use crate::packet; use crate::types::{Bandwidth, Channels, SampleRate}; @@ -30,7 +28,6 @@ pub struct Decoder { } unsafe impl Send for Decoder {} -unsafe impl Sync for Decoder {} /// Borrowed wrapper around a decoder state. pub struct DecoderRef<'a> { @@ -39,7 +36,6 @@ pub struct DecoderRef<'a> { } unsafe impl Send for DecoderRef<'_> {} -unsafe impl Sync for DecoderRef<'_> {} impl Decoder { fn from_raw( @@ -155,6 +151,12 @@ impl Decoder { if frame_size.get() > max_frame { return Err(Error::BadArg); } + // libopus requires PLC/FEC frame sizes to be multiples of 2.5 ms. + if (input.is_empty() || fec) + && !is_frame_size_2_5ms_aligned(frame_size.get(), self.sample_rate) + { + return Err(Error::BadArg); + } let input_len_i32 = if input.is_empty() { 0 @@ -210,6 +212,12 @@ impl Decoder { if frame_size.get() > max_frame { return Err(Error::BadArg); } + // libopus requires PLC/FEC frame sizes to be multiples of 2.5 ms. + if (input.is_empty() || fec) + && !is_frame_size_2_5ms_aligned(frame_size.get(), self.sample_rate) + { + return Err(Error::BadArg); + } let input_len_i32 = if input.is_empty() { 0 @@ -247,6 +255,9 @@ impl Decoder { /// overlong input, or a mapped libopus error. pub fn packet_samples(&self, packet: &[u8]) -> Result { // Errors: InvalidState or libopus error mapped. + if packet.is_empty() { + return Err(Error::BadArg); + } if packet.len() > i32::MAX as usize { return Err(Error::BadArg); } @@ -392,22 +403,6 @@ impl Decoder { ) } - #[cfg(feature = "dred")] - /// Set DRED duration in ms (if libopus built with DRED). - /// - /// # Errors - /// Returns [`Error::InvalidState`] if the decoder is invalid, or a mapped libopus error. - pub fn set_dred_duration(&mut self, ms: i32) -> Result<()> { - self.simple_ctl(OPUS_SET_DRED_DURATION_REQUEST as i32, ms) - } - #[cfg(feature = "dred")] - /// Query DRED duration in ms. - /// - /// # Errors - /// Returns [`Error::InvalidState`] if the decoder is invalid, or a mapped libopus error. - pub fn dred_duration(&mut self) -> Result { - self.get_int_ctl(OPUS_GET_DRED_DURATION_REQUEST as i32) - } #[cfg(feature = "dred")] /// Set DNN blob for DRED (feature-gated; will error if unsupported). /// @@ -459,9 +454,16 @@ impl<'a> DecoderRef<'a> { /// # Safety /// - `ptr` must point to valid, initialized memory of at least [`Decoder::size()`] bytes /// - `ptr` must be aligned to at least `align_of::()` (malloc-style alignment) + /// - `sample_rate` and `channels` must exactly match the decoder state already stored at `ptr` /// - The memory must remain valid for the lifetime `'a` /// - Caller is responsible for freeing the memory after this wrapper is dropped /// + /// Passing mismatched metadata is undefined behavior: later safe methods may validate buffer + /// sizes against the wrong channel/rate and then call libopus with out-of-bounds buffers. + /// + /// # Panics + /// Panics if `ptr` is null or not pointer-aligned. + /// /// Use [`Decoder::init_in_place`] to initialize the memory before calling this. #[must_use] pub unsafe fn from_raw( @@ -469,10 +471,8 @@ impl<'a> DecoderRef<'a> { sample_rate: SampleRate, channels: Channels, ) -> Self { - debug_assert!(!ptr.is_null(), "from_raw called with null ptr"); - debug_assert!(crate::opus_ptr_is_aligned(ptr.cast())); let decoder = Decoder::from_raw( - unsafe { NonNull::new_unchecked(ptr) }, + crate::checked_non_null(ptr, "DecoderRef::from_raw"), sample_rate, channels, Ownership::Borrowed, diff --git a/src/dred.rs b/src/dred.rs index 7a9f188..14f96fc 100644 --- a/src/dred.rs +++ b/src/dred.rs @@ -2,12 +2,12 @@ //! This module is available when the `dred` Cargo feature is enabled. use crate::bindings::{ - OpusDRED, OpusDREDDecoder, opus_decoder_dred_decode, opus_decoder_dred_decode_float, - opus_dred_alloc, opus_dred_decoder_create, opus_dred_decoder_ctl, opus_dred_decoder_destroy, - opus_dred_decoder_get_size, opus_dred_decoder_init, opus_dred_free, opus_dred_get_size, - opus_dred_parse, opus_dred_process, + OPUS_SET_DNN_BLOB_REQUEST, OpusDRED, OpusDREDDecoder, opus_decoder_dred_decode, + opus_decoder_dred_decode_float, opus_dred_alloc, opus_dred_decoder_create, + opus_dred_decoder_ctl, opus_dred_decoder_destroy, opus_dred_decoder_get_size, + opus_dred_decoder_init, opus_dred_free, opus_dred_get_size, opus_dred_parse, opus_dred_process, }; -use crate::constants::max_frame_samples_for; +use crate::constants::{is_frame_size_2_5ms_aligned, max_frame_samples_for}; use crate::decoder::Decoder; use crate::error::{Error, Result}; use crate::types::SampleRate; @@ -16,13 +16,15 @@ use std::marker::PhantomData; use std::ops::{Deref, DerefMut}; use std::ptr::NonNull; +// libopus computes `100 * max_dred_samples / sampling_rate` in signed 32-bit math. +const MAX_SAFE_DRED_SAMPLES: usize = (i32::MAX as usize) / 100; + /// Managed handle for libopus `OpusDREDDecoder`. pub struct DredDecoder { raw: RawHandle, } unsafe impl Send for DredDecoder {} -unsafe impl Sync for DredDecoder {} /// Borrowed wrapper around an externally allocated DRED decoder. pub struct DredDecoderRef<'a> { @@ -31,7 +33,6 @@ pub struct DredDecoderRef<'a> { } unsafe impl Send for DredDecoderRef<'_> {} -unsafe impl Sync for DredDecoderRef<'_> {} impl DredDecoder { fn from_raw(ptr: NonNull, ownership: Ownership) -> Self { @@ -88,29 +89,45 @@ impl DredDecoder { /// /// # Errors /// - /// Returns [`Error::InternalError`] if libopus reports an invalid (negative) - /// size, indicating a mismatch with the bundled headers. + /// Returns [`Error::InternalError`] if libopus reports a non-positive size, + /// indicating an unexpected ABI/runtime mismatch. pub fn size() -> Result { let raw = unsafe { opus_dred_decoder_get_size() }; + if raw <= 0 { + return Err(Error::InternalError); + } usize::try_from(raw).map_err(|_| Error::InternalError) } - /// Run a control request directly. + /// Load an external DNN model blob into this DRED decoder. /// /// # Safety /// - /// The caller must ensure the request and argument combination is valid for the - /// underlying libopus build and that `arg` satisfies libopus expectations. + /// - `data` must contain a complete, correctly formatted libopus DNN weights blob. Some + /// external-weight libopus builds do not safely handle malformed model records. + /// - The backing allocation must remain at the same address and valid for reads until this + /// decoder is dropped, even when this method returns an error. Libopus model layers may + /// retain pointers into the blob and model loading is not guaranteed to be transactional. /// /// # Errors /// - /// Returns [`Error::InvalidState`] if the decoder is invalid, or a mapped libopus - /// error when the control call fails. - pub unsafe fn control(&mut self, request: i32, arg: T) -> Result<()> - where - T: Copy, - { - let r = unsafe { opus_dred_decoder_ctl(self.raw.as_ptr(), request, arg) }; + /// Returns [`Error::BadArg`] for an empty, misaligned, or overlong blob, + /// [`Error::Unimplemented`] when the linked libopus was not built for external model + /// weights, or another mapped libopus error when loading fails. + pub unsafe fn set_dnn_blob(&mut self, data: &[u8]) -> Result<()> { + if data.is_empty() || !(data.as_ptr() as usize).is_multiple_of(std::mem::align_of::()) + { + return Err(Error::BadArg); + } + let len = i32::try_from(data.len()).map_err(|_| Error::BadArg)?; + let r = unsafe { + opus_dred_decoder_ctl( + self.raw.as_ptr(), + OPUS_SET_DNN_BLOB_REQUEST as i32, + data.as_ptr(), + len, + ) + }; if r != 0 { return Err(Error::from_code(r)); } @@ -133,7 +150,7 @@ impl DredDecoder { defer_processing: bool, ) -> Result { let len = i32::try_from(data.len()).map_err(|_| Error::BadArg)?; - let max_samples = i32::try_from(max_dred_samples).map_err(|_| Error::BadArg)?; + let max_samples = checked_max_dred_samples(max_dred_samples)?; let result = unsafe { opus_dred_parse( self.raw.as_ptr(), @@ -229,6 +246,13 @@ impl DredDecoder { } } +fn checked_max_dred_samples(max_dred_samples: usize) -> Result { + if max_dred_samples > MAX_SAFE_DRED_SAMPLES { + return Err(Error::BadArg); + } + i32::try_from(max_dred_samples).map_err(|_| Error::BadArg) +} + impl<'a> DredDecoderRef<'a> { /// Wrap an externally-initialized DRED decoder without taking ownership. /// @@ -238,12 +262,15 @@ impl<'a> DredDecoderRef<'a> { /// - Caller is responsible for freeing the memory after this wrapper is dropped /// /// Use [`DredDecoder::init_in_place`] to initialize the memory before calling this. + /// + /// # Panics + /// Panics if `ptr` is null or not pointer-aligned. #[must_use] pub unsafe fn from_raw(ptr: *mut OpusDREDDecoder) -> Self { - debug_assert!(!ptr.is_null(), "from_raw called with null ptr"); - debug_assert!(crate::opus_ptr_is_aligned(ptr.cast())); - let decoder = - DredDecoder::from_raw(unsafe { NonNull::new_unchecked(ptr) }, Ownership::Borrowed); + let decoder = DredDecoder::from_raw( + crate::checked_non_null(ptr, "DredDecoderRef::from_raw"), + Ownership::Borrowed, + ); Self { inner: decoder, _marker: PhantomData, @@ -297,6 +324,10 @@ fn validate_pcm_frame_len( if frame_size_per_ch == 0 || frame_size_per_ch > max_frame_samples_for(sample_rate) { return Err(Error::BadArg); } + // libopus requires DRED decode frame sizes to be multiples of 2.5 ms. + if !is_frame_size_2_5ms_aligned(frame_size_per_ch, sample_rate) { + return Err(Error::BadArg); + } i32::try_from(frame_size_per_ch).map_err(|_| Error::BadArg) } @@ -306,7 +337,6 @@ pub struct DredState { } unsafe impl Send for DredState {} -unsafe impl Sync for DredState {} impl DredState { /// Allocate a new DRED state. @@ -316,12 +346,15 @@ impl DredState { /// Returns [`Error::AllocFail`] if allocation fails or a mapped libopus error when /// creation does not succeed. pub fn new() -> Result { + let size = Self::size()?; let mut err = 0; let ptr = unsafe { opus_dred_alloc(std::ptr::addr_of_mut!(err)) }; if err != 0 { return Err(Error::from_code(err)); } let ptr = NonNull::new(ptr).ok_or(Error::AllocFail)?; + // opus_dred_alloc() is malloc-like and does not initialize OpusDRED. + unsafe { std::ptr::write_bytes(ptr.as_ptr().cast::(), 0, size) }; Ok(Self { raw: ptr }) } @@ -360,7 +393,8 @@ mod tests { #[test] fn validate_pcm_frame_len_checks_arguments() { - let pcm = vec![0i16; 4]; + // 2.5 ms at 48 kHz = 120 samples/ch, so 240 total for stereo. + let pcm = vec![0i16; 240]; assert!(validate_pcm_frame_len(&pcm, 2, SampleRate::Hz48000).is_ok()); let err = validate_pcm_frame_len(&pcm, 0, SampleRate::Hz48000).unwrap_err(); @@ -371,5 +405,58 @@ mod tests { let err = validate_pcm_frame_len(&[] as &[i16], 2, SampleRate::Hz48000).unwrap_err(); assert_eq!(err, Error::BadArg); + + // Non-2.5ms-aligned frame size must be rejected. + let bad_pcm = vec![0i16; 4]; + let err = validate_pcm_frame_len(&bad_pcm, 2, SampleRate::Hz48000).unwrap_err(); + assert_eq!(err, Error::BadArg); + } + + #[test] + fn checked_max_dred_samples_blocks_overflow_inputs() { + assert_eq!( + checked_max_dred_samples(MAX_SAFE_DRED_SAMPLES), + Ok(i32::MAX / 100) + ); + assert_eq!( + checked_max_dred_samples(MAX_SAFE_DRED_SAMPLES + 1), + Err(Error::BadArg) + ); + } + + #[test] + fn fresh_dred_state_is_inactive() { + let mut decoder = match DredDecoder::new() { + Ok(decoder) => decoder, + Err(Error::Unimplemented) => return, + Err(err) => panic!("unexpected DRED decoder error: {err:?}"), + }; + let state = match DredState::new() { + Ok(state) => state, + Err(Error::Unimplemented) => return, + Err(err) => panic!("unexpected DRED state error: {err:?}"), + }; + let mut dst = DredState::new().unwrap(); + + assert_eq!(decoder.process(&state, &mut dst), Err(Error::BadArg)); + } + + #[cfg(not(opus_codec_system_lib))] + #[test] + fn typed_dnn_blob_ctl_has_checked_input_and_exact_abi() { + let model_word = 0u32; + let mut decoder = DredDecoder::new().expect("create bundled DRED decoder"); + assert_eq!(unsafe { decoder.set_dnn_blob(&[]) }, Err(Error::BadArg)); + + let aligned_blob = unsafe { + std::slice::from_raw_parts( + std::ptr::addr_of!(model_word).cast::(), + std::mem::size_of_val(&model_word), + ) + }; + assert_eq!( + unsafe { decoder.set_dnn_blob(aligned_blob) }, + Err(Error::Unimplemented) + ); } } diff --git a/src/encoder.rs b/src/encoder.rs index 0b65bd9..b1f83e7 100644 --- a/src/encoder.rs +++ b/src/encoder.rs @@ -17,6 +17,8 @@ use crate::bindings::{ opus_encode, opus_encode_float, opus_encoder_create, opus_encoder_ctl, opus_encoder_destroy, opus_encoder_get_size, opus_encoder_init, }; +#[cfg(feature = "dred")] +use crate::bindings::{OPUS_GET_DRED_DURATION_REQUEST, OPUS_SET_DRED_DURATION_REQUEST}; use crate::constants::max_frame_samples_for; use crate::error::{Error, Result}; use crate::types::{ @@ -36,7 +38,6 @@ pub struct Encoder { } unsafe impl Send for Encoder {} -unsafe impl Sync for Encoder {} /// Borrowed wrapper around an encoder state. pub struct EncoderRef<'a> { @@ -45,7 +46,6 @@ pub struct EncoderRef<'a> { } unsafe impl Send for EncoderRef<'_> {} -unsafe impl Sync for EncoderRef<'_> {} impl Encoder { fn from_raw( @@ -198,7 +198,9 @@ impl Encoder { /// Encode 16-bit PCM, capping output to `max_data_bytes`. /// - /// Note: This does not itself enable FEC; use `set_inband_fec(true)` and + /// This uses the normal libopus `opus_encode` path and passes `max_data_bytes` + /// as the output limit, so it constrains packet size without enabling a separate + /// encoder mode. It does not itself enable FEC; use `set_inband_fec(true)` and /// `set_packet_loss_perc(…)` to actually make the encoder produce FEC. /// /// # Errors @@ -448,7 +450,7 @@ impl Encoder { } } - /// Encoder algorithmic lookahead (in samples at 48 kHz domain). + /// Encoder algorithmic lookahead in samples at this encoder's configured sample rate. /// /// # Errors /// Returns [`Error::InvalidState`] if the encoder is invalid, or a mapped libopus error. @@ -559,6 +561,27 @@ impl Encoder { self.get_bool_ctl(OPUS_GET_PHASE_INVERSION_DISABLED_REQUEST as i32) } + #[cfg(feature = "dred")] + /// Set the maximum number of 10-ms Deep Redundancy (DRED) frames. + /// + /// A value of zero disables DRED. Libopus validates the supported upper bound. + /// + /// # Errors + /// Returns [`Error::BadArg`] if `frames_10ms` is outside the supported range, or a mapped + /// libopus error if DRED is unavailable in the linked encoder. + pub fn set_dred_duration(&mut self, frames_10ms: i32) -> Result<()> { + self.simple_ctl(OPUS_SET_DRED_DURATION_REQUEST as i32, frames_10ms) + } + + #[cfg(feature = "dred")] + /// Query the configured maximum number of 10-ms DRED frames. + /// + /// # Errors + /// Returns a mapped libopus error if the encoder is invalid or DRED is unavailable. + pub fn dred_duration(&mut self) -> Result { + self.get_int_ctl(OPUS_GET_DRED_DURATION_REQUEST as i32) + } + // --- internal helpers --- fn simple_ctl(&mut self, req: i32, val: i32) -> Result<()> { let r = unsafe { opus_encoder_ctl(self.raw.as_ptr(), req, val) }; @@ -674,9 +697,8 @@ impl Encoder { return Err(Error::from_code(result)); } - Ok(Complexity::new( - u32::try_from(complexity).map_err(|_| Error::InternalError)?, - )) + let complexity = u32::try_from(complexity).map_err(|_| Error::InternalError)?; + Complexity::try_new(complexity).ok_or(Error::InternalError) } /// Enable or disable VBR. @@ -744,9 +766,16 @@ impl<'a> EncoderRef<'a> { /// # Safety /// - `ptr` must point to valid, initialized memory of at least [`Encoder::size()`] bytes /// - `ptr` must be aligned to at least `align_of::()` (malloc-style alignment) + /// - `sample_rate` and `channels` must exactly match the encoder state already stored at `ptr` /// - The memory must remain valid for the lifetime `'a` /// - Caller is responsible for freeing the memory after this wrapper is dropped /// + /// Passing mismatched metadata is undefined behavior: later safe methods may validate buffer + /// sizes against the wrong channel/rate and then call libopus with out-of-bounds buffers. + /// + /// # Panics + /// Panics if `ptr` is null or not pointer-aligned. + /// /// Use [`Encoder::init_in_place`] to initialize the memory before calling this. #[must_use] pub unsafe fn from_raw( @@ -754,10 +783,8 @@ impl<'a> EncoderRef<'a> { sample_rate: SampleRate, channels: Channels, ) -> Self { - debug_assert!(!ptr.is_null(), "from_raw called with null ptr"); - debug_assert!(crate::opus_ptr_is_aligned(ptr.cast())); let encoder = Encoder::from_raw( - unsafe { NonNull::new_unchecked(ptr) }, + crate::checked_non_null(ptr, "EncoderRef::from_raw"), sample_rate, channels, Ownership::Borrowed, diff --git a/src/lib.rs b/src/lib.rs index d5480fa..5c480fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,7 +38,7 @@ pub use error::{Error, Result}; pub use multistream::{Mapping, MultistreamDecoder, MultistreamEncoder}; pub use packet::{ packet_bandwidth, packet_channels, packet_frame_count, packet_has_lbrr, packet_parse, - packet_sample_count, packet_samples_per_frame, soft_clip, + packet_parse_into, packet_sample_count, packet_samples_per_frame, soft_clip, }; pub use projection::{ProjectionDecoder, ProjectionEncoder}; pub use repacketizer::Repacketizer; @@ -50,7 +50,7 @@ pub use types::{ #[doc(hidden)] pub use bindings::*; -pub(crate) use raw::RawHandle; +pub(crate) use raw::{RawHandle, checked_non_null}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Ownership { diff --git a/src/multistream.rs b/src/multistream.rs index 21e00db..5e0e36c 100644 --- a/src/multistream.rs +++ b/src/multistream.rs @@ -17,16 +17,16 @@ use crate::bindings::{ OPUS_SET_MAX_BANDWIDTH_REQUEST, OPUS_SET_PACKET_LOSS_PERC_REQUEST, OPUS_SET_PHASE_INVERSION_DISABLED_REQUEST, OPUS_SET_SIGNAL_REQUEST, OPUS_SET_VBR_CONSTRAINT_REQUEST, OPUS_SET_VBR_REQUEST, OPUS_SIGNAL_MUSIC, OPUS_SIGNAL_VOICE, - OpusDecoder, OpusEncoder, OpusMSDecoder, OpusMSEncoder, opus_multistream_decode, - opus_multistream_decode_float, opus_multistream_decoder_create, opus_multistream_decoder_ctl, - opus_multistream_decoder_destroy, opus_multistream_decoder_get_size, - opus_multistream_decoder_init, opus_multistream_encode, opus_multistream_encode_float, - opus_multistream_encoder_create, opus_multistream_encoder_ctl, + OpusDecoder, OpusEncoder, OpusMSDecoder, OpusMSEncoder, opus_decoder_ctl, opus_encoder_ctl, + opus_multistream_decode, opus_multistream_decode_float, opus_multistream_decoder_create, + opus_multistream_decoder_ctl, opus_multistream_decoder_destroy, + opus_multistream_decoder_get_size, opus_multistream_decoder_init, opus_multistream_encode, + opus_multistream_encode_float, opus_multistream_encoder_create, opus_multistream_encoder_ctl, opus_multistream_encoder_destroy, opus_multistream_encoder_get_size, opus_multistream_encoder_init, opus_multistream_surround_encoder_create, opus_multistream_surround_encoder_get_size, opus_multistream_surround_encoder_init, }; -use crate::constants::max_frame_samples_for; +use crate::constants::{is_frame_size_2_5ms_aligned, max_frame_samples_for}; use crate::error::{Error, Result}; use crate::types::{Application, Bandwidth, Bitrate, Channels, Complexity, SampleRate, Signal}; use crate::{AlignedBuffer, Ownership, RawHandle}; @@ -90,9 +90,9 @@ impl Mapping<'_> { return Err(Error::BadArg); } - let mut has_left = vec![false; coupled]; - let mut has_right = vec![false; coupled]; - let mut has_mono = vec![false; streams.saturating_sub(coupled)]; + let mut has_left = [false; u8::MAX as usize]; + let mut has_right = [false; u8::MAX as usize]; + let mut has_mono = [false; u8::MAX as usize]; for &entry in self.mapping { if entry == u8::MAX { continue; @@ -113,16 +113,101 @@ impl Mapping<'_> { } } - if has_left.iter().any(|has| !has) || has_right.iter().any(|has| !has) { + if has_left[..coupled].iter().any(|has| !has) || has_right[..coupled].iter().any(|has| !has) + { return Err(Error::BadArg); } - if has_mono.iter().any(|has| !has) { + if has_mono[..streams.saturating_sub(coupled)] + .iter() + .any(|has| !has) + { return Err(Error::BadArg); } Ok(()) } } +// Keep these rules synchronized with `vorbis_mappings`, +// `validate_ambisonics()`, and `opus_multistream_surround_encoder_init()` in +// opus/src/opus_multistream_encoder.c. Tests compare every valid family 1 and +// family 2 configuration against the linked libopus implementation. +const VORBIS_SURROUND_MAPPINGS: [(&[u8], u8, u8); 8] = [ + (&[0], 1, 0), + (&[0, 1], 1, 1), + (&[0, 2, 1], 2, 1), + (&[0, 1, 2, 3], 2, 2), + (&[0, 4, 1, 2, 3], 3, 2), + (&[0, 4, 1, 2, 3, 5], 4, 2), + (&[0, 4, 1, 2, 3, 5, 6], 4, 3), + (&[0, 6, 1, 2, 3, 4, 5, 7], 5, 3), +]; + +fn surround_mapping(channels: u8, mapping_family: i32) -> Result<(Vec, u8, u8)> { + let channel_count = usize::from(NonZeroU8::new(channels).ok_or(Error::BadArg)?.get()); + match mapping_family { + 0 => match channels { + 1 => Ok((vec![0], 1, 0)), + 2 => Ok((vec![0, 1], 1, 1)), + _ => Err(Error::BadArg), + }, + 1 if channel_count <= VORBIS_SURROUND_MAPPINGS.len() => { + let (mapping, streams, coupled) = VORBIS_SURROUND_MAPPINGS[channel_count - 1]; + Ok((mapping.to_vec(), streams, coupled)) + } + 2 => { + if channels > 227 { + return Err(Error::BadArg); + } + let order_plus_one = (1usize..=15) + .take_while(|value| value * value <= channel_count) + .last() + .ok_or(Error::BadArg)?; + let ambisonic_channels = order_plus_one * order_plus_one; + let nondiegetic_channels = channel_count - ambisonic_channels; + if nondiegetic_channels != 0 && nondiegetic_channels != 2 { + return Err(Error::BadArg); + } + + let coupled = u8::from(nondiegetic_channels != 0); + let streams = u8::try_from(ambisonic_channels + usize::from(coupled)) + .map_err(|_| Error::BadArg)?; + let mut mapping = Vec::with_capacity(channel_count); + for channel in 0..ambisonic_channels { + mapping.push( + u8::try_from(channel + 2 * usize::from(coupled)).map_err(|_| Error::BadArg)?, + ); + } + mapping.extend(0..2 * coupled); + Ok((mapping, streams, coupled)) + } + 255 => Ok(((0..channels).collect(), channels, 0)), + _ => Err(Error::BadArg), + } +} + +fn validate_stream_counts(streams: u8, coupled_streams: u8) -> Result<()> { + let streams = NonZeroU8::new(streams).ok_or(Error::BadArg)?; + if coupled_streams > streams.get() { + return Err(Error::BadArg); + } + if usize::from(streams.get()) + usize::from(coupled_streams) > u8::MAX as usize { + return Err(Error::BadArg); + } + Ok(()) +} + +fn bandwidth_from_ctl(value: i32) -> Result { + let value = u32::try_from(value).map_err(|_| Error::InternalError)?; + match value { + x if x == OPUS_BANDWIDTH_NARROWBAND => Ok(Bandwidth::Narrowband), + x if x == OPUS_BANDWIDTH_MEDIUMBAND => Ok(Bandwidth::Mediumband), + x if x == OPUS_BANDWIDTH_WIDEBAND => Ok(Bandwidth::Wideband), + x if x == OPUS_BANDWIDTH_SUPERWIDEBAND => Ok(Bandwidth::SuperWideband), + x if x == OPUS_BANDWIDTH_FULLBAND => Ok(Bandwidth::Fullband), + _ => Err(Error::InternalError), + } +} + /// Safe wrapper around `OpusMSEncoder`. pub struct MultistreamEncoder { raw: RawHandle, @@ -133,7 +218,6 @@ pub struct MultistreamEncoder { } unsafe impl Send for MultistreamEncoder {} -unsafe impl Sync for MultistreamEncoder {} /// Borrowed wrapper around a multistream encoder. pub struct MultistreamEncoderRef<'a> { @@ -142,7 +226,6 @@ pub struct MultistreamEncoderRef<'a> { } unsafe impl Send for MultistreamEncoderRef<'_> {} -unsafe impl Sync for MultistreamEncoderRef<'_> {} impl MultistreamEncoder { fn from_raw( @@ -168,6 +251,7 @@ impl MultistreamEncoder { /// Returns [`Error::BadArg`] if the stream counts are invalid or libopus reports /// an impossible size. pub fn size(streams: u8, coupled_streams: u8) -> Result { + validate_stream_counts(streams, coupled_streams)?; let raw = unsafe { opus_multistream_encoder_get_size(i32::from(streams), i32::from(coupled_streams)) }; @@ -182,6 +266,9 @@ impl MultistreamEncoder { /// # Errors /// Returns [`Error::BadArg`] if the channel/mapping configuration is invalid. pub fn surround_size(channels: u8, mapping_family: i32) -> Result { + if channels == 0 { + return Err(Error::BadArg); + } let raw = unsafe { opus_multistream_surround_encoder_get_size(i32::from(channels), mapping_family) }; @@ -191,6 +278,18 @@ impl MultistreamEncoder { usize::try_from(raw).map_err(|_| Error::InternalError) } + /// Return the standard channel mapping and stream counts for a surround family. + /// + /// This mirrors the family 0, 1, 2, and 255 rules used by + /// `opus_multistream_surround_encoder_init()`, without allocating an + /// encoder state. + /// + /// # Errors + /// Returns [`Error::BadArg`] if the channel count or mapping family is invalid. + pub fn surround_mapping(channels: u8, mapping_family: i32) -> Result<(Vec, u8, u8)> { + surround_mapping(channels, mapping_family) + } + /// Initialize a previously allocated multistream encoder state. /// /// # Safety @@ -251,6 +350,7 @@ impl MultistreamEncoder { if !crate::opus_ptr_is_aligned(ptr.cast()) { return Err(Error::BadArg); } + Self::surround_size(channels, mapping_family)?; let mut streams = 0i32; let mut coupled = 0i32; let mut mapping = vec![0u8; channels as usize]; @@ -448,9 +548,8 @@ impl MultistreamEncoder { /// if the response is outside the valid range, or propagates any error reported by libopus. pub fn complexity(&mut self) -> Result { let v = self.get_int_ctl(OPUS_GET_COMPLEXITY_REQUEST as i32)?; - Ok(Complexity::new( - u32::try_from(v).map_err(|_| Error::InternalError)?, - )) + let complexity = u32::try_from(v).map_err(|_| Error::InternalError)?; + Complexity::try_new(complexity).ok_or(Error::InternalError) } /// Enable/disable discontinuous transmission (DTX). @@ -471,13 +570,21 @@ impl MultistreamEncoder { self.get_bool_ctl(OPUS_GET_DTX_REQUEST as i32) } - /// Query whether the encoder is currently in DTX. + /// Query whether every coded stream is currently in DTX. + /// + /// Libopus does not forward `OPUS_GET_IN_DTX` through its multistream dispatcher, so this + /// queries each underlying encoder state and returns true only when all streams report DTX. /// /// # Errors /// Returns [`Error::InvalidState`] if the encoder handle is null or propagates any error /// reported by libopus. pub fn in_dtx(&mut self) -> Result { - self.get_bool_ctl(OPUS_GET_IN_DTX_REQUEST as i32) + for stream_index in 0..i32::from(self.streams) { + if self.encoder_int_ctl(stream_index, OPUS_GET_IN_DTX_REQUEST as i32)? == 0 { + return Ok(false); + } + } + Ok(true) } /// Enable/disable in-band FEC generation. @@ -567,13 +674,17 @@ impl MultistreamEncoder { self.simple_ctl(OPUS_SET_MAX_BANDWIDTH_REQUEST as i32, bw as i32) } - /// Query the configured maximum bandwidth. + /// Query the configured maximum bandwidth of the first coded stream. + /// + /// [`Self::set_max_bandwidth`] applies the value to every stream. Direct mutation through + /// [`Self::encoder_state_ptr`] can make per-stream values differ; this getter then reports + /// stream zero, matching libopus' convention for supported multistream integer getters. /// /// # Errors /// Returns [`Error::InvalidState`] if the encoder handle is null, [`Error::InternalError`] /// if the value cannot be represented, or propagates any error reported by libopus. pub fn max_bandwidth(&mut self) -> Result { - self.get_bandwidth_ctl(OPUS_GET_MAX_BANDWIDTH_REQUEST as i32) + bandwidth_from_ctl(self.encoder_int_ctl(0, OPUS_GET_MAX_BANDWIDTH_REQUEST as i32)?) } /// Force a specific output bandwidth (overrides automatic selection). @@ -647,7 +758,7 @@ impl MultistreamEncoder { } } - /// Query the algorithmic lookahead in samples at 48 kHz. + /// Query the algorithmic lookahead in samples at this encoder's configured sample rate. /// /// # Errors /// Returns [`Error::InvalidState`] if the encoder handle is null or propagates any error @@ -679,7 +790,7 @@ impl MultistreamEncoder { pub const fn sample_rate(&self) -> SampleRate { self.sample_rate } - /// Number of mono streams. + /// Total number of coded streams, including coupled streams. #[must_use] pub const fn streams(&self) -> u8 { self.streams @@ -701,9 +812,7 @@ impl MultistreamEncoder { mapping_family: i32, app: Application, ) -> Result<(Self, Vec)> { - if channels == 0 { - return Err(Error::BadArg); - } + Self::surround_size(channels, mapping_family)?; let mut err = 0i32; let mut streams = 0i32; let mut coupled = 0i32; @@ -742,6 +851,13 @@ impl MultistreamEncoder { /// Returns [`Error::InvalidState`] if the encoder handle is invalid or propagates the /// libopus error if retrieving the state fails. pub unsafe fn encoder_state_ptr(&mut self, stream_index: i32) -> Result<*mut OpusEncoder> { + Ok(self.encoder_state(stream_index)?.as_ptr()) + } + + fn encoder_state(&mut self, stream_index: i32) -> Result> { + if stream_index < 0 || stream_index >= i32::from(self.streams) { + return Err(Error::BadArg); + } let mut state: *mut OpusEncoder = std::ptr::null_mut(); let r = unsafe { opus_multistream_encoder_ctl( @@ -754,10 +870,17 @@ impl MultistreamEncoder { if r != 0 { return Err(Error::from_code(r)); } - if state.is_null() { - return Err(Error::InternalError); + NonNull::new(state).ok_or(Error::InternalError) + } + + fn encoder_int_ctl(&mut self, stream_index: i32, req: i32) -> Result { + let state = self.encoder_state(stream_index)?; + let mut value = 0i32; + let r = unsafe { opus_encoder_ctl(state.as_ptr(), req, &mut value) }; + if r != 0 { + return Err(Error::from_code(r)); } - Ok(state) + Ok(value) } fn simple_ctl(&mut self, req: i32, val: i32) -> Result<()> { @@ -782,44 +905,69 @@ impl MultistreamEncoder { } fn get_bandwidth_ctl(&mut self, req: i32) -> Result { - let v = u32::try_from(self.get_int_ctl(req)?).map_err(|_| Error::InternalError)?; - match v { - x if x == OPUS_BANDWIDTH_NARROWBAND => Ok(Bandwidth::Narrowband), - x if x == OPUS_BANDWIDTH_MEDIUMBAND => Ok(Bandwidth::Mediumband), - x if x == OPUS_BANDWIDTH_WIDEBAND => Ok(Bandwidth::Wideband), - x if x == OPUS_BANDWIDTH_SUPERWIDEBAND => Ok(Bandwidth::SuperWideband), - x if x == OPUS_BANDWIDTH_FULLBAND => Ok(Bandwidth::Fullband), - _ => Err(Error::InternalError), - } + bandwidth_from_ctl(self.get_int_ctl(req)?) } } impl<'a> MultistreamEncoderRef<'a> { + unsafe fn from_raw_parts( + ptr: *mut OpusMSEncoder, + sr: SampleRate, + channels: u8, + streams: u8, + coupled_streams: u8, + caller: &str, + ) -> Self { + assert!(channels != 0, "{caller} called with zero channels"); + assert!( + validate_stream_counts(streams, coupled_streams).is_ok(), + "{caller} called with invalid stream counts" + ); + let encoder = MultistreamEncoder::from_raw( + crate::checked_non_null(ptr, caller), + sr, + channels, + streams, + coupled_streams, + Ownership::Borrowed, + ); + Self { + inner: encoder, + _marker: PhantomData, + } + } + /// Wrap an externally-initialized multistream encoder without taking ownership. /// /// # Safety /// - `ptr` must point to valid, initialized memory of at least [`MultistreamEncoder::size()`] bytes /// - `ptr` must be aligned to at least `align_of::()` (malloc-style alignment) + /// - `sr` and `mapping` must exactly match the encoder state already stored at `ptr` /// - The memory must remain valid for the lifetime `'a` /// - Caller is responsible for freeing the memory after this wrapper is dropped /// + /// Passing mismatched metadata is undefined behavior: later safe methods may validate buffer + /// sizes with the wrong layout and then call libopus with out-of-bounds buffers. + /// + /// # Panics + /// Panics if `ptr` is null, not pointer-aligned, or `mapping` is invalid. + /// /// Use [`MultistreamEncoder::init_in_place`] to initialize the memory before calling this. #[must_use] pub unsafe fn from_raw(ptr: *mut OpusMSEncoder, sr: SampleRate, mapping: Mapping<'_>) -> Self { - debug_assert!(!ptr.is_null(), "from_raw called with null ptr"); - debug_assert!(crate::opus_ptr_is_aligned(ptr.cast())); - debug_assert!(mapping.validate_for_encoder().is_ok()); - let encoder = MultistreamEncoder::from_raw( - unsafe { NonNull::new_unchecked(ptr) }, - sr, - mapping.channels, - mapping.streams, - mapping.coupled_streams, - Ownership::Borrowed, + assert!( + mapping.validate_for_encoder().is_ok(), + "MultistreamEncoderRef::from_raw called with invalid mapping" ); - Self { - inner: encoder, - _marker: PhantomData, + unsafe { + Self::from_raw_parts( + ptr, + sr, + mapping.channels, + mapping.streams, + mapping.coupled_streams, + "MultistreamEncoderRef::from_raw", + ) } } @@ -861,13 +1009,16 @@ impl<'a> MultistreamEncoderRef<'a> { let (streams, coupled, mapping) = unsafe { MultistreamEncoder::init_surround_in_place(ptr, sr, channels, mapping_family, app)? }; - let mapping_ref = Mapping { - channels, - streams, - coupled_streams: coupled, - mapping: &mapping, + let encoder = unsafe { + Self::from_raw_parts( + ptr, + sr, + channels, + streams, + coupled, + "MultistreamEncoderRef::init_in_surround", + ) }; - let encoder = unsafe { Self::from_raw(ptr, sr, mapping_ref) }; Ok((encoder, mapping)) } } @@ -894,7 +1045,6 @@ pub struct MultistreamDecoder { } unsafe impl Send for MultistreamDecoder {} -unsafe impl Sync for MultistreamDecoder {} /// Borrowed wrapper around a multistream decoder. pub struct MultistreamDecoderRef<'a> { @@ -903,7 +1053,6 @@ pub struct MultistreamDecoderRef<'a> { } unsafe impl Send for MultistreamDecoderRef<'_> {} -unsafe impl Sync for MultistreamDecoderRef<'_> {} impl MultistreamDecoder { fn from_raw( @@ -925,6 +1074,7 @@ impl MultistreamDecoder { /// Returns [`Error::BadArg`] if the stream counts are invalid or libopus reports /// an impossible size. pub fn size(streams: u8, coupled_streams: u8) -> Result { + validate_stream_counts(streams, coupled_streams)?; let raw = unsafe { opus_multistream_decoder_get_size(i32::from(streams), i32::from(coupled_streams)) }; @@ -1015,6 +1165,12 @@ impl MultistreamDecoder { if out.len() != frame_size_per_ch.get() * self.channels as usize { return Err(Error::BadArg); } + // libopus requires PLC/FEC frame sizes to be multiples of 2.5 ms. + if (packet.is_empty() || fec) + && !is_frame_size_2_5ms_aligned(frame_size_per_ch.get(), self.sample_rate) + { + return Err(Error::BadArg); + } let n = unsafe { opus_multistream_decode( self.raw.as_ptr(), @@ -1058,6 +1214,12 @@ impl MultistreamDecoder { if out.len() != frame_size_per_ch.get() * self.channels as usize { return Err(Error::BadArg); } + // libopus requires PLC/FEC frame sizes to be multiples of 2.5 ms. + if (packet.is_empty() || fec) + && !is_frame_size_2_5ms_aligned(frame_size_per_ch.get(), self.sample_rate) + { + return Err(Error::BadArg); + } let n = unsafe { opus_multistream_decode_float( self.raw.as_ptr(), @@ -1163,13 +1325,17 @@ impl MultistreamDecoder { self.get_int_ctl(OPUS_GET_SAMPLE_RATE_REQUEST as i32) } - /// Query the pitch (fundamental period) of the last decoded frame. + /// Query the pitch of the first coded stream's last decoded frame. + /// + /// The value is the fundamental period in samples at 48 kHz. Libopus does not forward + /// `OPUS_GET_PITCH` through its multistream dispatcher, so this follows the dispatcher's + /// convention for scalar getters and queries stream zero explicitly. /// /// # Errors /// Returns [`Error::InvalidState`] if the decoder handle is null or propagates any error /// reported by libopus. pub fn get_pitch(&mut self) -> Result { - self.get_int_ctl(OPUS_GET_PITCH_REQUEST as i32) + self.decoder_int_ctl(0, OPUS_GET_PITCH_REQUEST as i32) } /// Query the duration (per channel) of the last decoded packet. @@ -1192,7 +1358,7 @@ impl MultistreamDecoder { self.sample_rate } - /// Create a multistream decoder using libopus surround mapping helpers. + /// Create a multistream decoder using the standard libopus surround mapping. /// /// # Errors /// Returns [`Error::BadArg`] for invalid channel counts or the mapped libopus @@ -1202,39 +1368,16 @@ impl MultistreamDecoder { channels: u8, mapping_family: i32, ) -> Result<(Self, Vec, u8, u8)> { - if channels == 0 { - return Err(Error::BadArg); - } + MultistreamEncoder::surround_size(channels, mapping_family)?; + let (mapping, streams, coupled) = + MultistreamEncoder::surround_mapping(channels, mapping_family)?; let mut err = 0i32; - let mut streams = 0i32; - let mut coupled = 0i32; - let mut mapping = vec![0u8; channels as usize]; - // libopus exposes surround helper creation only for encoders; callers - // should use the returned mapping/stream counts to configure this decoder. - let enc = unsafe { - opus_multistream_surround_encoder_create( - sr as i32, - i32::from(channels), - mapping_family, - std::ptr::addr_of_mut!(streams), - std::ptr::addr_of_mut!(coupled), - mapping.as_mut_ptr(), - Application::Audio as i32, - std::ptr::addr_of_mut!(err), - ) - }; - if !enc.is_null() { - unsafe { opus_multistream_encoder_destroy(enc) }; - } - if err != 0 { - return Err(Error::from_code(err)); - } let dec = unsafe { opus_multistream_decoder_create( sr as i32, i32::from(channels), - streams, - coupled, + i32::from(streams), + i32::from(coupled), mapping.as_ptr(), std::ptr::addr_of_mut!(err), ) @@ -1246,8 +1389,8 @@ impl MultistreamDecoder { Ok(( Self::from_raw(dec, sr, channels, Ownership::Owned), mapping, - u8::try_from(streams).map_err(|_| Error::BadArg)?, - u8::try_from(coupled).map_err(|_| Error::BadArg)?, + streams, + coupled, )) } @@ -1261,6 +1404,10 @@ impl MultistreamDecoder { /// Returns [`Error::InvalidState`] if the decoder handle is invalid or propagates the /// libopus error when retrieving the per-stream state fails. pub unsafe fn decoder_state_ptr(&mut self, stream_index: i32) -> Result<*mut OpusDecoder> { + Ok(self.decoder_state(stream_index)?.as_ptr()) + } + + fn decoder_state(&mut self, stream_index: i32) -> Result> { let mut state: *mut OpusDecoder = std::ptr::null_mut(); let r = unsafe { opus_multistream_decoder_ctl( @@ -1273,10 +1420,17 @@ impl MultistreamDecoder { if r != 0 { return Err(Error::from_code(r)); } - if state.is_null() { - return Err(Error::InternalError); + NonNull::new(state).ok_or(Error::InternalError) + } + + fn decoder_int_ctl(&mut self, stream_index: i32, req: i32) -> Result { + let state = self.decoder_state(stream_index)?; + let mut value = 0i32; + let r = unsafe { opus_decoder_ctl(state.as_ptr(), req, &mut value) }; + if r != 0 { + return Err(Error::from_code(r)); } - Ok(state) + Ok(value) } fn simple_ctl(&mut self, req: i32, val: i32) -> Result<()> { @@ -1307,17 +1461,25 @@ impl<'a> MultistreamDecoderRef<'a> { /// # Safety /// - `ptr` must point to valid, initialized memory of at least [`MultistreamDecoder::size()`] bytes /// - `ptr` must be aligned to at least `align_of::()` (malloc-style alignment) + /// - `sr` and `mapping` must exactly match the decoder state already stored at `ptr` /// - The memory must remain valid for the lifetime `'a` /// - Caller is responsible for freeing the memory after this wrapper is dropped /// + /// Passing mismatched metadata is undefined behavior: later safe methods may validate buffer + /// sizes with the wrong layout and then call libopus with out-of-bounds buffers. + /// + /// # Panics + /// Panics if `ptr` is null, not pointer-aligned, or `mapping` is invalid. + /// /// Use [`MultistreamDecoder::init_in_place`] to initialize the memory before calling this. #[must_use] pub unsafe fn from_raw(ptr: *mut OpusMSDecoder, sr: SampleRate, mapping: Mapping<'_>) -> Self { - debug_assert!(!ptr.is_null(), "from_raw called with null ptr"); - debug_assert!(crate::opus_ptr_is_aligned(ptr.cast())); - debug_assert!(mapping.validate_for_decoder().is_ok()); + assert!( + mapping.validate_for_decoder().is_ok(), + "MultistreamDecoderRef::from_raw called with invalid mapping" + ); let decoder = MultistreamDecoder::from_raw( - unsafe { NonNull::new_unchecked(ptr) }, + crate::checked_non_null(ptr, "MultistreamDecoderRef::from_raw"), sr, mapping.channels, Ownership::Borrowed, diff --git a/src/packet.rs b/src/packet.rs index d1f4246..020a5c4 100644 --- a/src/packet.rs +++ b/src/packet.rs @@ -2,18 +2,31 @@ #![allow(clippy::cast_possible_truncation)] #![allow(clippy::cast_possible_wrap)] +#![cfg_attr(not(opus_codec_rust_packet_ops), allow(dead_code))] use crate::bindings::{ OPUS_BANDWIDTH_FULLBAND, OPUS_BANDWIDTH_MEDIUMBAND, OPUS_BANDWIDTH_NARROWBAND, - OPUS_BANDWIDTH_SUPERWIDEBAND, OPUS_BANDWIDTH_WIDEBAND, opus_multistream_packet_pad, - opus_multistream_packet_unpad, opus_packet_get_bandwidth, opus_packet_get_nb_channels, - opus_packet_get_nb_frames, opus_packet_get_nb_samples, opus_packet_get_samples_per_frame, - opus_packet_has_lbrr, opus_packet_pad, opus_packet_parse, opus_packet_unpad, - opus_pcm_soft_clip, + OPUS_BANDWIDTH_SUPERWIDEBAND, OPUS_BANDWIDTH_WIDEBAND, opus_multistream_packet_unpad, + opus_packet_get_bandwidth, opus_packet_get_nb_channels, opus_packet_get_nb_frames, + opus_packet_get_nb_samples, opus_packet_get_samples_per_frame, opus_packet_has_lbrr, + opus_packet_parse, opus_packet_unpad, opus_pcm_soft_clip, }; +#[cfg(not(opus_codec_rust_packet_ops))] +use crate::bindings::{opus_multistream_packet_pad, opus_packet_pad}; use crate::error::{Error, Result}; use crate::types::{Bandwidth, Channels, SampleRate}; +mod layout; + +pub(crate) use layout::MAX_FRAMES_PER_PACKET; +#[cfg(opus_codec_rust_packet_ops)] +pub(crate) use layout::{ + PacketPadding, PacketRepacketizerLayout, packet_repacketizer_layout, repacketize_frames, + repacketize_frames_range, +}; +#[cfg(opus_codec_rust_packet_ops)] +use layout::{multistream_last_stream_offset, pad_single_packet}; + /// Get bandwidth from a packet. /// /// # Errors @@ -154,20 +167,27 @@ pub fn soft_clip( Ok(()) } -/// Parse packet into frame pointers and sizes. Returns (toc, `payload_offset`, `frame_sizes`). -/// Note: Returned frame slices borrow from `packet` and are valid as long as `packet` lives. +/// Parse a packet into caller-provided frame storage. +/// +/// Returns `(toc, payload_offset, frame_count)`. The first `frame_count` +/// entries in `frames` are replaced with slices that borrow from `packet`. /// /// # Errors -/// Returns an error if the packet cannot be parsed. -pub fn packet_parse(packet: &[u8]) -> Result<(u8, usize, Vec<&[u8]>)> { +/// Returns [`Error::BufferTooSmall`] if `frames` cannot hold every parsed +/// frame, or another error if the packet cannot be parsed. +pub fn packet_parse_into<'packet>( + packet: &'packet [u8], + frames: &mut [&'packet [u8]], +) -> Result<(u8, usize, usize)> { if packet.is_empty() { return Err(Error::BadArg); } let mut out_toc: u8 = 0; let mut payload_offset: i32 = 0; - // libopus caps frames at 48 according to docs - let mut frames_ptrs: [*const u8; 48] = [std::ptr::null(); 48]; - let mut sizes: [i16; 48] = [0; 48]; + // libopus caps frames at MAX_FRAMES_PER_PACKET according to docs. + let mut frames_ptrs: [*const u8; MAX_FRAMES_PER_PACKET] = + [std::ptr::null(); MAX_FRAMES_PER_PACKET]; + let mut sizes: [i16; MAX_FRAMES_PER_PACKET] = [0; MAX_FRAMES_PER_PACKET]; let len_i32 = i32::try_from(packet.len()).map_err(|_| Error::BadArg)?; let n = unsafe { opus_packet_parse( @@ -183,7 +203,11 @@ pub fn packet_parse(packet: &[u8]) -> Result<(u8, usize, Vec<&[u8]>)> { return Err(Error::from_code(n)); } let count = usize::try_from(n).map_err(|_| Error::InternalError)?; - let mut frames = Vec::with_capacity(count); + if count > frames.len() { + return Err(Error::BufferTooSmall); + } + let mut starts = [0usize; MAX_FRAMES_PER_PACKET]; + let mut lengths = [0usize; MAX_FRAMES_PER_PACKET]; for i in 0..count { let size = usize::try_from(sizes[i]).map_err(|_| Error::InternalError)?; let ptr = frames_ptrs[i]; @@ -197,27 +221,67 @@ pub fn packet_parse(packet: &[u8]) -> Result<(u8, usize, Vec<&[u8]>)> { } // SAFETY: pointers are into `packet`; derive offset via pointer arithmetic let start = ptr_addr - base_addr; - let end = start + size; + let end = start.checked_add(size).ok_or(Error::InternalError)?; if end > packet.len() { return Err(Error::InvalidPacket); } - frames.push(&packet[start..end]); + starts[i] = start; + lengths[i] = size; + } + for i in 0..count { + frames[i] = &packet[starts[i]..starts[i] + lengths[i]]; } Ok(( out_toc, usize::try_from(payload_offset).map_err(|_| Error::InternalError)?, - frames, + count, )) } +/// Parse packet into frame slices. Returns `(toc, payload_offset, frames)`. +/// +/// Returned frame slices borrow from `packet` and are valid as long as +/// `packet` lives. Use [`packet_parse_into`] to supply reusable storage and +/// avoid allocating the returned vector. +/// +/// # Errors +/// Returns an error if the packet cannot be parsed. +pub fn packet_parse(packet: &[u8]) -> Result<(u8, usize, Vec<&[u8]>)> { + let mut frames = [&[][..]; MAX_FRAMES_PER_PACKET]; + let (toc, payload_offset, frame_count) = packet_parse_into(packet, &mut frames)?; + Ok((toc, payload_offset, frames[..frame_count].to_vec())) +} + +/// Increase a packet's size by adding padding to reach `new_len`. +/// +/// # Errors +/// Returns [`Error::BadArg`] for invalid lengths or another error if padding fails. +#[cfg(opus_codec_rust_packet_ops)] +pub fn packet_pad(packet: &mut [u8], len: usize, new_len: usize) -> Result<()> { + if new_len < len || new_len > packet.len() { + return Err(Error::BadArg); + } + if len == 0 { + return Err(Error::BadArg); + } + if len == new_len { + return Ok(()); + } + pad_single_packet(packet, len, new_len) +} + /// Increase a packet's size by adding padding to reach `new_len`. /// /// # Errors /// Returns [`Error::BadArg`] for invalid lengths or a mapped libopus error if padding fails. +#[cfg(not(opus_codec_rust_packet_ops))] pub fn packet_pad(packet: &mut [u8], len: usize, new_len: usize) -> Result<()> { if new_len < len || new_len > packet.len() { return Err(Error::BadArg); } + if len == 0 { + return Err(Error::BadArg); + } let len_i32 = i32::try_from(len).map_err(|_| Error::BadArg)?; let new_len_i32 = i32::try_from(new_len).map_err(|_| Error::BadArg)?; let r = unsafe { opus_packet_pad(packet.as_mut_ptr(), len_i32, new_len_i32) }; @@ -235,6 +299,9 @@ pub fn packet_unpad(packet: &mut [u8], len: usize) -> Result { if len > packet.len() { return Err(Error::BadArg); } + if len == 0 { + return Err(Error::BadArg); + } let len_i32 = i32::try_from(len).map_err(|_| Error::BadArg)?; let n = unsafe { opus_packet_unpad(packet.as_mut_ptr(), len_i32) }; if n < 0 { @@ -243,10 +310,47 @@ pub fn packet_unpad(packet: &mut [u8], len: usize) -> Result { usize::try_from(n).map_err(|_| Error::InternalError) } +/// Pad a multistream packet to `new_len` given `nb_streams`. +/// +/// # Errors +/// Returns [`Error::BadArg`] for invalid lengths or another error if padding fails. +#[cfg(opus_codec_rust_packet_ops)] +pub fn multistream_packet_pad( + packet: &mut [u8], + len: usize, + new_len: usize, + nb_streams: i32, +) -> Result<()> { + if new_len < len || new_len > packet.len() { + return Err(Error::BadArg); + } + if len == 0 { + return Err(Error::BadArg); + } + // The public API requires at least one stream. Reject invalid counts + // before delegating to libopus' multistream packet walker. + if nb_streams < 1 { + return Err(Error::BadArg); + } + if len == new_len { + return Ok(()); + } + let nb_streams = usize::try_from(nb_streams).map_err(|_| Error::BadArg)?; + let last_stream_offset = multistream_last_stream_offset(&packet[..len], nb_streams)?; + if last_stream_offset == len { + return Err(Error::BadArg); + } + let amount = new_len - len; + let last_len = len - last_stream_offset; + let last_new_len = last_len.checked_add(amount).ok_or(Error::BadArg)?; + pad_single_packet(&mut packet[last_stream_offset..], last_len, last_new_len) +} + /// Pad a multistream packet to `new_len` given `nb_streams`. /// /// # Errors /// Returns [`Error::BadArg`] for invalid lengths or a mapped libopus error if padding fails. +#[cfg(not(opus_codec_rust_packet_ops))] pub fn multistream_packet_pad( packet: &mut [u8], len: usize, @@ -256,6 +360,12 @@ pub fn multistream_packet_pad( if new_len < len || new_len > packet.len() { return Err(Error::BadArg); } + if len == 0 { + return Err(Error::BadArg); + } + if nb_streams < 1 { + return Err(Error::BadArg); + } let len_i32 = i32::try_from(len).map_err(|_| Error::BadArg)?; let new_len_i32 = i32::try_from(new_len).map_err(|_| Error::BadArg)?; let r = unsafe { @@ -275,6 +385,14 @@ pub fn multistream_packet_unpad(packet: &mut [u8], len: usize, nb_streams: i32) if len > packet.len() { return Err(Error::BadArg); } + if len == 0 { + return Err(Error::BadArg); + } + // The public API requires at least one stream. Reject invalid counts + // before delegating to libopus' multistream packet walker. + if nb_streams < 1 { + return Err(Error::BadArg); + } let len_i32 = i32::try_from(len).map_err(|_| Error::BadArg)?; let n = unsafe { opus_multistream_packet_unpad(packet.as_mut_ptr(), len_i32, nb_streams) }; if n < 0 { diff --git a/src/packet/layout.rs b/src/packet/layout.rs new file mode 100644 index 0000000..5259e07 --- /dev/null +++ b/src/packet/layout.rs @@ -0,0 +1,923 @@ +#![cfg_attr(not(opus_codec_rust_packet_ops), allow(dead_code))] + +use crate::bindings::opus_packet_get_samples_per_frame; +use crate::error::{Error, Result}; + +mod extensions; + +use extensions::{generate_extensions, parse_existing_extensions}; + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct FrameSpan { + pub(crate) start: usize, + pub(crate) len: usize, +} + +impl FrameSpan { + pub(crate) fn slice(self, packet: &[u8]) -> Result<&[u8]> { + let end = self + .start + .checked_add(self.len) + .ok_or(Error::InternalError)?; + packet.get(self.start..end).ok_or(Error::InvalidPacket) + } +} + +#[derive(Debug)] +pub(crate) struct PacketRepacketizerLayout { + pub(crate) toc: u8, + frames: [FrameSpan; MAX_FRAMES_PER_PACKET], + frame_count: usize, + pub(crate) padding_start: usize, + pub(crate) packet_offset: usize, + extension_spans: Option>>, +} + +#[derive(Debug)] +struct PacketExtension<'a> { + id: u8, + frame: u8, + data: &'a [u8], +} + +#[derive(Clone, Copy, Debug)] +struct PacketExtensionSpan { + id: u8, + frame: u8, + data_start: usize, + data_len: usize, +} + +impl PacketExtensionSpan { + fn from_extension(extension: &PacketExtension<'_>, padding: &[u8]) -> Result { + let data_address = extension.data.as_ptr() as usize; + let padding_address = padding.as_ptr() as usize; + let data_start = data_address + .checked_sub(padding_address) + .ok_or(Error::InternalError)?; + let data_end = data_start + .checked_add(extension.data.len()) + .ok_or(Error::InternalError)?; + if data_end > padding.len() { + return Err(Error::InternalError); + } + Ok(Self { + id: extension.id, + frame: extension.frame, + data_start, + data_len: extension.data.len(), + }) + } + + fn extension(self, padding: &[u8]) -> Result> { + let data_end = self + .data_start + .checked_add(self.data_len) + .ok_or(Error::InternalError)?; + let data = padding + .get(self.data_start..data_end) + .ok_or(Error::InternalError)?; + Ok(PacketExtension { + id: self.id, + frame: self.frame, + data, + }) + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct PacketPadding<'a> { + pub(crate) data: &'a [u8], + pub(crate) frame_count: usize, + cached_extensions: Option<&'a Result>>, +} + +impl PacketRepacketizerLayout { + pub(crate) fn frames(&self) -> &[FrameSpan] { + &self.frames[..self.frame_count] + } + + pub(crate) fn packet_padding<'a>(&'a self, packet: &'a [u8]) -> PacketPadding<'a> { + PacketPadding { + data: &packet[self.padding_start..self.packet_offset], + frame_count: self.frame_count, + cached_extensions: self.extension_spans.as_ref(), + } + } +} + +impl PacketPadding<'static> { + pub(crate) const EMPTY: Self = Self { + data: &[], + frame_count: 0, + cached_extensions: None, + }; +} + +// The packet writer/parser below mirrors the libopus packet layout helpers: +// opus.c::opus_packet_parse_impl(), repacketizer.c::opus_repacketizer_out_range_impl(), +// and extensions.c::opus_packet_extensions_*(). +const OPUS_REFERENCE_SAMPLE_RATE: i32 = 48_000; +pub(crate) const MAX_FRAMES_PER_PACKET: usize = 48; +const MAX_FRAME_PAYLOAD_BYTES: usize = 1275; +const MAX_PACKET_SAMPLES_48KHZ: usize = 5760; + +const TOC_CODE_MASK: u8 = 0x03; +const TOC_CONFIG_MASK: u8 = 0xFC; +const PACKET_CODE_1: u8 = 0x01; +const PACKET_CODE_2: u8 = 0x02; +const PACKET_CODE_3: u8 = 0x03; + +const CODE_3_FRAME_COUNT_MASK: u8 = 0x3F; +const CODE_3_PADDING_FLAG: u8 = 0x40; +const CODE_3_VBR_FLAG: u8 = 0x80; + +const SIZE_TWO_BYTE_THRESHOLD: usize = 252; +const SIZE_LOW_BITS_MASK: usize = 0x03; + +const PADDING_CONTINUATION_BYTE: u8 = 255; +const PADDING_CONTINUATION_AMOUNT: usize = 254; + +const EXTENSION_PADDING_ID: u8 = 0; +const EXTENSION_FRAME_SEPARATOR_ID: u8 = 1; +#[cfg(not(opus_codec_frame_bounded_extensions))] +const EXTENSION_MIN_DATA_ID: u8 = 2; +#[cfg(opus_codec_frame_bounded_extensions)] +const EXTENSION_REPEAT_ID: u8 = 2; +#[cfg(opus_codec_frame_bounded_extensions)] +const EXTENSION_FRAME_BOUNDED_MIN_DATA_ID: u8 = 3; +const EXTENSION_SHORT_ID_MAX: u8 = 32; +const EXTENSION_MAX_ID: u8 = 127; +const EXTENSION_PADDING_BYTE: u8 = 0x01; +const EXTENSION_ONE_FRAME_SEPARATOR: u8 = 0x02; +const EXTENSION_MULTI_FRAME_SEPARATOR: u8 = 0x03; +const EXTENSION_LENGTH_CHUNK: usize = 255; +const EXTENSION_LENGTH_CHUNK_BYTE: u8 = 255; + +#[derive(Debug, Default)] +struct PacketWritePlan { + total_size: usize, + cursor: usize, + extension_padding_begin: usize, + extension_padding_end: usize, + extensions_begin: usize, + extension_bytes: Vec, +} + +#[derive(Debug)] +struct PacketLayoutParser<'a> { + data: &'a [u8], + self_delimited: bool, + sizes: [usize; MAX_FRAMES_PER_PACKET], + cursor: usize, + remaining: usize, + count: usize, + cbr: bool, + last_size: usize, + padding_len: usize, +} + +fn encoded_size_len(size: usize) -> usize { + if size < SIZE_TWO_BYTE_THRESHOLD { 1 } else { 2 } +} + +fn encode_size(size: usize, out: &mut [u8]) -> Result { + if out.is_empty() { + return Err(Error::BufferTooSmall); + } + if size < SIZE_TWO_BYTE_THRESHOLD { + out[0] = u8::try_from(size).map_err(|_| Error::InternalError)?; + return Ok(1); + } + if out.len() < 2 { + return Err(Error::BufferTooSmall); + } + let low = SIZE_TWO_BYTE_THRESHOLD + (size & SIZE_LOW_BITS_MASK); + out[0] = u8::try_from(low).map_err(|_| Error::InternalError)?; + out[1] = u8::try_from((size - low) >> 2).map_err(|_| Error::InternalError)?; + Ok(2) +} + +fn parse_size(data: &[u8]) -> Option<(usize, usize)> { + if data.is_empty() { + None + } else if usize::from(data[0]) < SIZE_TWO_BYTE_THRESHOLD { + Some((1, usize::from(data[0]))) + } else if data.len() < 2 { + None + } else { + Some((2, 4 * usize::from(data[1]) + usize::from(data[0]))) + } +} + +impl<'a> PacketLayoutParser<'a> { + fn new(data: &'a [u8], self_delimited: bool) -> Result { + if data.is_empty() { + return Err(Error::InvalidPacket); + } + Ok(Self { + data, + self_delimited, + sizes: [0usize; MAX_FRAMES_PER_PACKET], + cursor: 1, + remaining: data.len() - 1, + count: 0, + cbr: false, + last_size: data.len() - 1, + padding_len: 0, + }) + } + + fn parse(mut self) -> Result { + let toc = self.data[0]; + let frame_samples = usize::try_from(unsafe { + opus_packet_get_samples_per_frame(self.data.as_ptr(), OPUS_REFERENCE_SAMPLE_RATE) + }) + .map_err(|_| Error::InvalidPacket)?; + self.parse_header(toc, frame_samples)?; + self.finish_last_frame_size()?; + let (frames, padding_start) = self.collect_frames()?; + let packet_offset = padding_start + .checked_add(self.padding_len) + .ok_or(Error::InternalError)?; + if packet_offset > self.data.len() { + return Err(Error::InvalidPacket); + } + + Ok(PacketRepacketizerLayout { + toc, + frames, + frame_count: self.count, + padding_start, + packet_offset, + extension_spans: None, + }) + } + + fn parse_header(&mut self, toc: u8, frame_samples: usize) -> Result<()> { + match toc & TOC_CODE_MASK { + 0 => self.parse_single_frame(), + PACKET_CODE_1 => self.parse_two_frame_cbr()?, + PACKET_CODE_2 => self.parse_two_frame_vbr()?, + _ => self.parse_many_frame_packet(frame_samples)?, + } + Ok(()) + } + + fn parse_single_frame(&mut self) { + self.count = 1; + } + + fn parse_two_frame_cbr(&mut self) -> Result<()> { + self.count = 2; + self.cbr = true; + if !self.self_delimited { + if !self.remaining.is_multiple_of(2) { + return Err(Error::InvalidPacket); + } + self.last_size = self.remaining / 2; + self.sizes[0] = self.last_size; + } + Ok(()) + } + + fn parse_two_frame_vbr(&mut self) -> Result<()> { + self.count = 2; + let (_, size) = self.read_size_field()?; + self.sizes[0] = size; + self.last_size = self.remaining - size; + Ok(()) + } + + fn parse_many_frame_packet(&mut self, frame_samples: usize) -> Result<()> { + let ch = self.read_byte()?; + self.count = usize::from(ch & CODE_3_FRAME_COUNT_MASK); + self.validate_frame_count(frame_samples)?; + if ch & CODE_3_PADDING_FLAG != 0 { + self.parse_padding_length()?; + } + + self.cbr = ch & CODE_3_VBR_FLAG == 0; + if self.cbr { + if !self.self_delimited { + self.parse_many_frame_cbr()?; + } + } else { + self.parse_many_frame_vbr()?; + } + Ok(()) + } + + fn validate_frame_count(&self, frame_samples: usize) -> Result<()> { + let total_duration = frame_samples + .checked_mul(self.count) + .ok_or(Error::InvalidPacket)?; + if self.count == 0 + || self.count > MAX_FRAMES_PER_PACKET + || total_duration > MAX_PACKET_SAMPLES_48KHZ + { + return Err(Error::InvalidPacket); + } + Ok(()) + } + + fn parse_padding_length(&mut self) -> Result<()> { + loop { + let byte = self.read_byte()?; + let chunk = if byte == PADDING_CONTINUATION_BYTE { + PADDING_CONTINUATION_AMOUNT + } else { + usize::from(byte) + }; + if chunk > self.remaining { + return Err(Error::InvalidPacket); + } + self.remaining -= chunk; + self.padding_len = self + .padding_len + .checked_add(chunk) + .ok_or(Error::InternalError)?; + if byte != PADDING_CONTINUATION_BYTE { + return Ok(()); + } + } + } + + fn parse_many_frame_vbr(&mut self) -> Result<()> { + self.last_size = self.remaining; + for index in 0..(self.count - 1) { + let (bytes, size) = self.read_size_field()?; + self.sizes[index] = size; + let consumed = bytes.checked_add(size).ok_or(Error::InternalError)?; + if consumed > self.last_size { + return Err(Error::InvalidPacket); + } + self.last_size -= consumed; + } + Ok(()) + } + + fn parse_many_frame_cbr(&mut self) -> Result<()> { + self.last_size = self.remaining / self.count; + if self + .last_size + .checked_mul(self.count) + .ok_or(Error::InternalError)? + != self.remaining + { + return Err(Error::InvalidPacket); + } + for size in self.sizes.iter_mut().take(self.count - 1) { + *size = self.last_size; + } + Ok(()) + } + + fn finish_last_frame_size(&mut self) -> Result<()> { + if self.self_delimited { + let (bytes, last) = self.read_size_field()?; + if self.cbr { + if last.checked_mul(self.count).ok_or(Error::InternalError)? > self.remaining { + return Err(Error::InvalidPacket); + } + for size in self.sizes.iter_mut().take(self.count - 1) { + *size = last; + } + } else if bytes.checked_add(last).ok_or(Error::InternalError)? > self.last_size { + return Err(Error::InvalidPacket); + } + self.sizes[self.count - 1] = last; + return Ok(()); + } + + if self.last_size > MAX_FRAME_PAYLOAD_BYTES { + return Err(Error::InvalidPacket); + } + self.sizes[self.count - 1] = self.last_size; + Ok(()) + } + + fn read_byte(&mut self) -> Result { + let byte = *self.data.get(self.cursor).ok_or(Error::InvalidPacket)?; + self.consume_header_bytes(1)?; + Ok(byte) + } + + fn read_size_field(&mut self) -> Result<(usize, usize)> { + let (bytes, size) = parse_size(&self.data[self.cursor..]).ok_or(Error::InvalidPacket)?; + self.consume_header_bytes(bytes)?; + if size > self.remaining { + return Err(Error::InvalidPacket); + } + Ok((bytes, size)) + } + + fn consume_header_bytes(&mut self, bytes: usize) -> Result<()> { + if bytes > self.remaining { + return Err(Error::InvalidPacket); + } + self.cursor = self.cursor.checked_add(bytes).ok_or(Error::InternalError)?; + self.remaining -= bytes; + Ok(()) + } + + fn collect_frames(&self) -> Result<([FrameSpan; MAX_FRAMES_PER_PACKET], usize)> { + let mut frame_cursor = self.cursor; + let mut frames = [FrameSpan::default(); MAX_FRAMES_PER_PACKET]; + for (index, &len) in self.sizes.iter().take(self.count).enumerate() { + let end = frame_cursor.checked_add(len).ok_or(Error::InternalError)?; + if end > self.data.len() { + return Err(Error::InvalidPacket); + } + frames[index] = FrameSpan { + start: frame_cursor, + len, + }; + frame_cursor = end; + } + Ok((frames, frame_cursor)) + } +} + +fn parse_packet_layout(data: &[u8], self_delimited: bool) -> Result { + PacketLayoutParser::new(data, self_delimited)?.parse() +} + +fn ensure_output_capacity(required: usize, out: &[u8]) -> Result<()> { + if required > out.len() { + return Err(Error::BufferTooSmall); + } + Ok(()) +} + +fn write_compact_packet_header( + toc: u8, + frame_lengths: &[usize], + out: &mut [u8], +) -> Result { + match frame_lengths { + [] => Err(Error::InvalidPacket), + [first] => { + let total_size = first.checked_add(1).ok_or(Error::InternalError)?; + ensure_output_capacity(total_size, out)?; + out[0] = toc & TOC_CONFIG_MASK; + Ok(PacketWritePlan { + total_size, + cursor: 1, + ..PacketWritePlan::default() + }) + } + [first, second] if second == first => { + let total_size = first + .checked_mul(2) + .and_then(|size| size.checked_add(1)) + .ok_or(Error::InternalError)?; + ensure_output_capacity(total_size, out)?; + out[0] = (toc & TOC_CONFIG_MASK) | PACKET_CODE_1; + Ok(PacketWritePlan { + total_size, + cursor: 1, + ..PacketWritePlan::default() + }) + } + [first, second] => { + let total_size = first + .checked_add(*second) + .and_then(|size| size.checked_add(1 + encoded_size_len(*first))) + .ok_or(Error::InternalError)?; + ensure_output_capacity(total_size, out)?; + out[0] = (toc & TOC_CONFIG_MASK) | PACKET_CODE_2; + let mut plan = PacketWritePlan { + total_size, + cursor: 1, + ..PacketWritePlan::default() + }; + plan.cursor += encode_size(*first, &mut out[plan.cursor..])?; + Ok(plan) + } + _ => Ok(PacketWritePlan::default()), + } +} + +fn frame_lengths_are_vbr(frame_lengths: &[usize]) -> bool { + frame_lengths[1..] + .iter() + .any(|&len| len != frame_lengths[0]) +} + +fn write_general_packet_header( + toc: u8, + frame_lengths: &[usize], + extension_bytes: Vec, + out: &mut [u8], +) -> Result { + let count = frame_lengths.len(); + let vbr = frame_lengths_are_vbr(frame_lengths); + let mut plan = PacketWritePlan::default(); + + if vbr { + plan.total_size = 2; + for &len in frame_lengths.iter().take(count - 1) { + plan.total_size = plan + .total_size + .checked_add(encoded_size_len(len)) + .and_then(|size| size.checked_add(len)) + .ok_or(Error::InternalError)?; + } + plan.total_size = plan + .total_size + .checked_add(frame_lengths[count - 1]) + .ok_or(Error::InternalError)?; + ensure_output_capacity(plan.total_size, out)?; + out[0] = (toc & TOC_CONFIG_MASK) | PACKET_CODE_3; + out[1] = u8::try_from(count).map_err(|_| Error::InternalError)? | CODE_3_VBR_FLAG; + } else { + plan.total_size = frame_lengths[0] + .checked_mul(count) + .and_then(|size| size.checked_add(2)) + .ok_or(Error::InternalError)?; + ensure_output_capacity(plan.total_size, out)?; + out[0] = (toc & TOC_CONFIG_MASK) | PACKET_CODE_3; + out[1] = u8::try_from(count).map_err(|_| Error::InternalError)?; + } + plan.cursor = 2; + plan.extension_bytes = extension_bytes; + + let pad_amount = out.len() - plan.total_size; + if pad_amount != 0 { + let nb_255s = (pad_amount - 1) / usize::from(PADDING_CONTINUATION_BYTE); + let overhead = plan + .total_size + .checked_add(plan.extension_bytes.len()) + .and_then(|size| size.checked_add(nb_255s + 1)) + .ok_or(Error::InternalError)?; + ensure_output_capacity(overhead, out)?; + out[1] |= CODE_3_PADDING_FLAG; + plan.extensions_begin = plan.total_size + pad_amount - plan.extension_bytes.len(); + plan.extension_padding_begin = plan.total_size + nb_255s + 1; + plan.extension_padding_end = out.len() - plan.extension_bytes.len(); + out[plan.cursor..plan.cursor + nb_255s].fill(PADDING_CONTINUATION_BYTE); + plan.cursor += nb_255s; + out[plan.cursor] = + u8::try_from(pad_amount - usize::from(PADDING_CONTINUATION_BYTE) * nb_255s - 1) + .map_err(|_| Error::InternalError)?; + plan.cursor += 1; + plan.total_size += pad_amount; + } + + if vbr { + for &len in frame_lengths.iter().take(count - 1) { + plan.cursor += encode_size(len, &mut out[plan.cursor..])?; + } + } + Ok(plan) +} + +fn general_packet_frame_offset( + frame_lengths: &[usize], + extension_len: usize, + out_len: usize, +) -> Result { + let base_len = general_packet_base_len(frame_lengths)?; + if base_len > out_len { + return Err(Error::BufferTooSmall); + } + let pad_amount = out_len - base_len; + let mut cursor = 2usize; + if pad_amount != 0 { + let padding_header_len = (pad_amount - 1) / usize::from(PADDING_CONTINUATION_BYTE) + 1; + let overhead = base_len + .checked_add(extension_len) + .and_then(|size| size.checked_add(padding_header_len)) + .ok_or(Error::InternalError)?; + if overhead > out_len { + return Err(Error::BufferTooSmall); + } + cursor = cursor + .checked_add(padding_header_len) + .ok_or(Error::InternalError)?; + } else if extension_len != 0 { + return Err(Error::BufferTooSmall); + } + if frame_lengths_are_vbr(frame_lengths) { + for &len in frame_lengths.iter().take(frame_lengths.len() - 1) { + cursor = cursor + .checked_add(encoded_size_len(len)) + .ok_or(Error::InternalError)?; + } + } + Ok(cursor) +} + +fn compact_packet_len(frame_lengths: &[usize]) -> Result> { + match frame_lengths { + [] => Err(Error::InvalidPacket), + [first] => first.checked_add(1).map(Some).ok_or(Error::InternalError), + [first, second] if second == first => first + .checked_mul(2) + .and_then(|size| size.checked_add(1)) + .map(Some) + .ok_or(Error::InternalError), + [first, second] => first + .checked_add(*second) + .and_then(|size| size.checked_add(1 + encoded_size_len(*first))) + .map(Some) + .ok_or(Error::InternalError), + _ => Ok(None), + } +} + +fn general_packet_base_len(frame_lengths: &[usize]) -> Result { + let count = frame_lengths.len(); + if count == 0 { + return Err(Error::InvalidPacket); + } + + if frame_lengths_are_vbr(frame_lengths) { + let mut total_size = 2usize; + for &len in frame_lengths.iter().take(count - 1) { + total_size = total_size + .checked_add(encoded_size_len(len)) + .and_then(|size| size.checked_add(len)) + .ok_or(Error::InternalError)?; + } + total_size + .checked_add(frame_lengths[count - 1]) + .ok_or(Error::InternalError) + } else { + frame_lengths[0] + .checked_mul(count) + .and_then(|size| size.checked_add(2)) + .ok_or(Error::InternalError) + } +} + +fn copy_frame_slices(frames: &[&[u8]], cursor: &mut usize, out: &mut [u8]) -> Result<()> { + for frame in frames { + let dst_end = cursor + .checked_add(frame.len()) + .ok_or(Error::InternalError)?; + if dst_end > out.len() { + return Err(Error::BufferTooSmall); + } + out[*cursor..dst_end].copy_from_slice(frame); + *cursor = dst_end; + } + Ok(()) +} + +fn finish_packet_write(plan: &PacketWritePlan, cursor: usize, out: &mut [u8]) -> Result<()> { + if !plan.extension_bytes.is_empty() { + let ext_end = plan + .extensions_begin + .checked_add(plan.extension_bytes.len()) + .ok_or(Error::InternalError)?; + if ext_end > out.len() { + return Err(Error::BufferTooSmall); + } + out[plan.extensions_begin..ext_end].copy_from_slice(&plan.extension_bytes); + } + if plan.extension_padding_begin < plan.extension_padding_end { + out[plan.extension_padding_begin..plan.extension_padding_end].fill(EXTENSION_PADDING_BYTE); + } + if plan.extension_bytes.is_empty() && cursor < out.len() { + out[cursor..].fill(0); + } + Ok(()) +} + +fn append_repacketizer_extension_in_range<'a>( + mut extension: PacketExtension<'a>, + owner_frame: usize, + begin: usize, + end: usize, + extensions: &mut Vec>, +) -> Result<()> { + let frame = usize::from(extension.frame) + .checked_add(owner_frame) + .ok_or(Error::InternalError)?; + if (begin..end).contains(&frame) { + extension.frame = u8::try_from(frame - begin).map_err(|_| Error::InvalidPacket)?; + extensions.push(extension); + } + Ok(()) +} + +fn collect_repacketizer_extensions_in_range<'a>( + paddings: &[PacketPadding<'a>], + begin: usize, + end: usize, +) -> Result>> { + let mut extensions = Vec::new(); + for (owner_frame, padding) in paddings.iter().enumerate().take(end) { + if let Some(cached_extensions) = padding.cached_extensions { + match cached_extensions { + Ok(spans) => { + for span in spans { + append_repacketizer_extension_in_range( + span.extension(padding.data)?, + owner_frame, + begin, + end, + &mut extensions, + )?; + } + } + Err(err) if (begin..end).contains(&owner_frame) => return Err(err.clone()), + Err(_) => {} + } + } else { + let frame_extensions = + match parse_existing_extensions(padding.data, padding.frame_count) { + Ok(extensions) => extensions, + Err(err) if (begin..end).contains(&owner_frame) => return Err(err), + Err(_) => continue, + }; + for extension in frame_extensions { + append_repacketizer_extension_in_range( + extension, + owner_frame, + begin, + end, + &mut extensions, + )?; + } + } + } + Ok(extensions) +} + +pub(crate) fn packet_repacketizer_layout(packet: &[u8]) -> Result { + let mut parsed = parse_packet_layout(packet, false)?; + if parsed.packet_offset != packet.len() { + return Err(Error::InvalidPacket); + } + let padding = &packet[parsed.padding_start..parsed.packet_offset]; + parsed.extension_spans = Some( + parse_existing_extensions(padding, parsed.frame_count).and_then(|extensions| { + extensions + .iter() + .map(|extension| PacketExtensionSpan::from_extension(extension, padding)) + .collect() + }), + ); + Ok(parsed) +} + +pub(crate) fn repacketize_frames( + toc: u8, + frames: &[&[u8]], + paddings: &[PacketPadding<'_>], + out: &mut [u8], +) -> Result { + repacketize_frames_range(toc, frames, paddings, 0, frames.len(), out) +} + +pub(crate) fn repacketize_frames_range( + toc: u8, + frames: &[&[u8]], + paddings: &[PacketPadding<'_>], + begin: usize, + end: usize, + out: &mut [u8], +) -> Result { + if frames.len() != paddings.len() { + return Err(Error::InternalError); + } + if begin > end || end > frames.len() { + return Err(Error::BadArg); + } + + let frames = &frames[begin..end]; + if frames.len() > MAX_FRAMES_PER_PACKET { + return Err(Error::InvalidPacket); + } + let mut frame_lengths = [0usize; MAX_FRAMES_PER_PACKET]; + for (length, frame) in frame_lengths.iter_mut().zip(frames) { + *length = frame.len(); + } + let frame_lengths = &frame_lengths[..frames.len()]; + let extensions = collect_repacketizer_extensions_in_range(paddings, begin, end)?; + + let compact_len = if extensions.is_empty() { + compact_packet_len(frame_lengths)? + } else { + None + }; + let (total_len, extension_bytes) = if let Some(total_len) = compact_len { + (total_len, Vec::new()) + } else { + let base_len = general_packet_base_len(frame_lengths)?; + let extension_bytes = generate_extensions(&extensions, usize::MAX, frames.len())?; + let ext_len = extension_bytes.len(); + let ext_padding_len = if ext_len == 0 { + 0 + } else { + ext_len + .checked_add(ext_len / PADDING_CONTINUATION_AMOUNT) + .and_then(|len| len.checked_add(1)) + .ok_or(Error::InternalError)? + }; + let total_len = base_len + .checked_add(ext_padding_len) + .ok_or(Error::InternalError)?; + (total_len, extension_bytes) + }; + + ensure_output_capacity(total_len, out)?; + let out = &mut out[..total_len]; + let mut plan = if compact_len.is_some() { + write_compact_packet_header(toc, frame_lengths, out)? + } else { + write_general_packet_header(toc, frame_lengths, extension_bytes, out)? + }; + + copy_frame_slices(frames, &mut plan.cursor, out)?; + finish_packet_write(&plan, plan.cursor, out)?; + debug_assert_eq!(plan.total_size, total_len); + Ok(total_len) +} + +#[cfg(opus_codec_rust_packet_ops)] +pub(super) fn pad_single_packet(packet: &mut [u8], len: usize, new_len: usize) -> Result<()> { + let parsed = parse_packet_layout(&packet[..len], false)?; + if parsed.packet_offset != len { + return Err(Error::InvalidPacket); + } + let frame_count = parsed.frames().len(); + let mut frame_lengths = [0usize; MAX_FRAMES_PER_PACKET]; + for (length, frame) in frame_lengths.iter_mut().zip(parsed.frames()) { + *length = frame.len; + } + let frame_lengths = &frame_lengths[..frame_count]; + let base_len = general_packet_base_len(frame_lengths)?; + if base_len > new_len { + return Err(Error::BufferTooSmall); + } + let extension_bytes = { + let padding = &packet[parsed.padding_start..parsed.packet_offset]; + let extensions = parse_existing_extensions(padding, frame_count)?; + generate_extensions(&extensions, new_len - base_len, frame_count)? + }; + let frame_offset = general_packet_frame_offset(frame_lengths, extension_bytes.len(), new_len)?; + + let frame_bytes = frame_lengths.iter().try_fold(0usize, |total, &length| { + total.checked_add(length).ok_or(Error::InternalError) + })?; + let frames_end = frame_offset + .checked_add(frame_bytes) + .ok_or(Error::InternalError)?; + + // Growing a packet must never move a frame before its source position. + // Validate every move before mutating the packet so a future layout change + // cannot turn this overlap assumption into partial or corrupt output. + let mut frame_destinations = [0usize; MAX_FRAMES_PER_PACKET]; + let mut source_ends = [0usize; MAX_FRAMES_PER_PACKET]; + let mut destination_end = frames_end; + for index in (0..frame_count).rev() { + let frame = parsed.frames()[index]; + let destination_start = destination_end + .checked_sub(frame.len) + .ok_or(Error::InternalError)?; + let source_end = frame + .start + .checked_add(frame.len) + .ok_or(Error::InternalError)?; + if source_end > len || destination_start < frame.start { + return Err(Error::InternalError); + } + frame_destinations[index] = destination_start; + source_ends[index] = source_end; + destination_end = destination_start; + } + if destination_end != frame_offset { + return Err(Error::InternalError); + } + + for index in (0..frame_count).rev() { + let frame = parsed.frames()[index]; + packet.copy_within(frame.start..source_ends[index], frame_destinations[index]); + } + + let out = &mut packet[..new_len]; + let plan = write_general_packet_header(parsed.toc, frame_lengths, extension_bytes, out)?; + debug_assert_eq!(plan.cursor, frame_offset); + finish_packet_write(&plan, frames_end, out)?; + debug_assert_eq!(plan.total_size, out.len()); + Ok(()) +} + +#[cfg(opus_codec_rust_packet_ops)] +pub(super) fn multistream_last_stream_offset(packet: &[u8], nb_streams: usize) -> Result { + let mut offset = 0usize; + for _ in 0..nb_streams.saturating_sub(1) { + let parsed = parse_packet_layout(&packet[offset..], true)?; + offset = offset + .checked_add(parsed.packet_offset) + .ok_or(Error::InternalError)?; + if offset > packet.len() { + return Err(Error::InvalidPacket); + } + } + Ok(offset) +} diff --git a/src/packet/layout/extensions.rs b/src/packet/layout/extensions.rs new file mode 100644 index 0000000..169e045 --- /dev/null +++ b/src/packet/layout/extensions.rs @@ -0,0 +1,704 @@ +#![cfg_attr(not(opus_codec_rust_packet_ops), allow(dead_code))] + +#[cfg(not(opus_codec_frame_bounded_extensions))] +use super::EXTENSION_MIN_DATA_ID; +#[cfg(opus_codec_frame_bounded_extensions)] +use super::{EXTENSION_FRAME_BOUNDED_MIN_DATA_ID, EXTENSION_REPEAT_ID}; +use super::{ + EXTENSION_FRAME_SEPARATOR_ID, EXTENSION_LENGTH_CHUNK, EXTENSION_LENGTH_CHUNK_BYTE, + EXTENSION_MAX_ID, EXTENSION_MULTI_FRAME_SEPARATOR, EXTENSION_ONE_FRAME_SEPARATOR, + EXTENSION_PADDING_ID, EXTENSION_SHORT_ID_MAX, MAX_FRAMES_PER_PACKET, PacketExtension, +}; +use crate::error::{Error, Result}; + +#[cfg(not(opus_codec_frame_bounded_extensions))] +fn skip_extension(data: &[u8], start: usize) -> Result<(usize, usize)> { + let remaining = data.len().saturating_sub(start); + if remaining == 0 { + return Ok((start, 0)); + } + + let first = data[start]; + let id = first >> 1; + let len_flag = first & 1; + if id == EXTENSION_PADDING_ID && len_flag == 1 { + return Ok((start + 1, 1)); + } + if id > EXTENSION_PADDING_ID && id < EXTENSION_SHORT_ID_MAX { + let consumed = 1 + usize::from(len_flag); + if remaining < consumed { + return Err(Error::InvalidPacket); + } + return Ok((start + consumed, 1)); + } + if len_flag == 0 { + return Ok((data.len(), 1)); + } + + let mut cursor = start + 1; + let mut header_size = 1usize; + let mut payload_len = 0usize; + loop { + if cursor >= data.len() { + return Err(Error::InvalidPacket); + } + let byte = usize::from(data[cursor]); + payload_len = payload_len.checked_add(byte).ok_or(Error::InternalError)?; + header_size += 1; + cursor += 1; + if byte != EXTENSION_LENGTH_CHUNK { + break; + } + } + + if payload_len > data.len().saturating_sub(cursor) { + return Err(Error::InvalidPacket); + } + Ok((cursor + payload_len, header_size)) +} + +#[cfg(opus_codec_frame_bounded_extensions)] +fn skip_extension_payload_frame_bounded( + data: &[u8], + start: usize, + id_byte: u8, + trailing_short_len: usize, +) -> Result<(usize, usize)> { + let id = id_byte >> 1; + let len_flag = id_byte & 1; + let mut cursor = start; + let mut header_size = 0usize; + + if (id == EXTENSION_PADDING_ID && len_flag == 1) || id == EXTENSION_REPEAT_ID { + return Ok((cursor, header_size)); + } + if id > EXTENSION_PADDING_ID && id < EXTENSION_SHORT_ID_MAX { + let consumed = usize::from(len_flag); + if data.len().saturating_sub(cursor) < consumed { + return Err(Error::InvalidPacket); + } + return Ok((cursor + consumed, header_size)); + } + if len_flag == 0 { + if data.len().saturating_sub(cursor) < trailing_short_len { + return Err(Error::InvalidPacket); + } + return Ok((data.len() - trailing_short_len, header_size)); + } + + let mut payload_len = 0usize; + loop { + if cursor >= data.len() { + return Err(Error::InvalidPacket); + } + let byte = usize::from(data[cursor]); + payload_len = payload_len.checked_add(byte).ok_or(Error::InternalError)?; + header_size += 1; + cursor += 1; + if byte != EXTENSION_LENGTH_CHUNK { + break; + } + } + + if payload_len > data.len().saturating_sub(cursor) { + return Err(Error::InvalidPacket); + } + Ok((cursor + payload_len, header_size)) +} + +#[cfg(opus_codec_frame_bounded_extensions)] +fn skip_extension_frame_bounded(data: &[u8], start: usize) -> Result<(usize, usize)> { + let id_byte = *data.get(start).ok_or(Error::InvalidPacket)?; + let (next, payload_header_size) = + skip_extension_payload_frame_bounded(data, start + 1, id_byte, 0)?; + Ok((next, payload_header_size + 1)) +} + +#[cfg(not(opus_codec_frame_bounded_extensions))] +fn parse_extensions(data: &[u8], _frame_count: usize) -> Result>> { + let mut cursor = 0usize; + let mut current_frame = 0usize; + let mut extensions = Vec::new(); + + while cursor < data.len() { + let entry = data[cursor]; + let id = entry >> 1; + let len_flag = entry & 1; + let (next, header_size) = skip_extension(data, cursor)?; + if id >= EXTENSION_MIN_DATA_ID { + if current_frame >= MAX_FRAMES_PER_PACKET { + return Err(Error::InvalidPacket); + } + let data_start = cursor + .checked_add(header_size) + .ok_or(Error::InternalError)?; + extensions.push(PacketExtension { + id, + frame: u8::try_from(current_frame).map_err(|_| Error::InvalidPacket)?, + data: &data[data_start..next], + }); + } else if id == EXTENSION_FRAME_SEPARATOR_ID { + if len_flag == 0 { + current_frame += 1; + } else if cursor + 1 < data.len() { + current_frame += usize::from(data[cursor + 1]); + } + if current_frame >= MAX_FRAMES_PER_PACKET { + return Err(Error::InvalidPacket); + } + } + cursor = next; + } + + Ok(extensions) +} + +#[cfg(opus_codec_frame_bounded_extensions)] +#[derive(Debug)] +struct ExtensionIteratorFrameBounded<'a> { + data: &'a [u8], + nb_frames: usize, + curr_pos: usize, + repeat_data: usize, + repeat_pos: usize, + repeat_end: usize, + repeat_frame: usize, + curr_frame: usize, + repeat_len_flag: u8, + trailing_short_len: usize, + last_long_end: Option, +} + +#[cfg(opus_codec_frame_bounded_extensions)] +impl<'a> ExtensionIteratorFrameBounded<'a> { + fn new(data: &'a [u8], nb_frames: usize) -> Self { + Self { + data, + nb_frames, + curr_pos: 0, + repeat_data: 0, + repeat_pos: 0, + repeat_end: 0, + repeat_frame: 0, + curr_frame: 0, + repeat_len_flag: 0, + trailing_short_len: 0, + last_long_end: None, + } + } + + fn next_extension(&mut self) -> Result>> { + if self.nb_frames > MAX_FRAMES_PER_PACKET { + return Err(Error::InvalidPacket); + } + loop { + if let Some(extension) = self.next_repeated_extension()? { + return Ok(Some(extension)); + } + if self.curr_frame >= self.nb_frames { + return Ok(None); + } + while self.curr_pos < self.data.len() { + let start = self.curr_pos; + let entry = self.data[start]; + let id = entry >> 1; + let len_flag = entry & 1; + let (next, header_size) = skip_extension_frame_bounded(self.data, start)?; + self.curr_pos = next; + + if id == EXTENSION_FRAME_SEPARATOR_ID { + if len_flag == 0 { + self.curr_frame += 1; + } else if self.data[start + 1] != 0 { + self.curr_frame += usize::from(self.data[start + 1]); + } else { + continue; + } + if self.curr_frame >= self.nb_frames { + return Err(Error::InvalidPacket); + } + self.repeat_data = self.curr_pos; + self.last_long_end = None; + self.trailing_short_len = 0; + } else if id == EXTENSION_REPEAT_ID { + self.repeat_len_flag = len_flag; + self.repeat_frame = self.curr_frame + 1; + self.repeat_end = start; + self.repeat_pos = self.repeat_data; + break; + } else if id >= EXTENSION_FRAME_BOUNDED_MIN_DATA_ID { + if id >= EXTENSION_SHORT_ID_MAX { + self.last_long_end = Some(self.curr_pos); + self.trailing_short_len = 0; + } else { + self.trailing_short_len = self + .trailing_short_len + .checked_add(usize::from(len_flag)) + .ok_or(Error::InternalError)?; + } + let data_start = start.checked_add(header_size).ok_or(Error::InternalError)?; + return Ok(Some(PacketExtension { + id, + frame: u8::try_from(self.curr_frame).map_err(|_| Error::InvalidPacket)?, + data: &self.data[data_start..next], + })); + } + } + // A trailing repeat marker is valid when all repeated short + // extensions have empty payloads; process it before treating EOF + // as the end of the extension stream. + if self.repeat_frame > 0 { + continue; + } + if self.curr_pos >= self.data.len() { + return Ok(None); + } + } + } + + fn next_repeated_extension(&mut self) -> Result>> { + while self.repeat_frame > 0 { + while self.repeat_frame < self.nb_frames { + while self.repeat_pos < self.repeat_end { + let original_id_byte = self.data[self.repeat_pos]; + let (source_next, _) = + skip_extension_frame_bounded(self.data, self.repeat_pos)?; + self.repeat_pos = source_next; + + if original_id_byte <= 3 { + continue; + } + + let mut repeated_id_byte = original_id_byte; + if self.repeat_len_flag == 0 + && self.repeat_frame + 1 >= self.nb_frames + && Some(self.repeat_pos) == self.last_long_end + { + repeated_id_byte &= !1; + } + + let payload_start = self.curr_pos; + let (payload_next, header_size) = skip_extension_payload_frame_bounded( + self.data, + self.curr_pos, + repeated_id_byte, + self.trailing_short_len, + )?; + self.curr_pos = payload_next; + let data_start = payload_start + .checked_add(header_size) + .ok_or(Error::InternalError)?; + return Ok(Some(PacketExtension { + id: repeated_id_byte >> 1, + frame: u8::try_from(self.repeat_frame).map_err(|_| Error::InvalidPacket)?, + data: &self.data[data_start..payload_next], + })); + } + self.repeat_pos = self.repeat_data; + self.repeat_frame += 1; + } + + self.repeat_data = self.curr_pos; + self.last_long_end = None; + if self.repeat_len_flag == 0 { + self.curr_frame += 1; + if self.curr_frame >= self.nb_frames { + self.curr_pos = self.data.len(); + } + } + self.repeat_frame = 0; + } + Ok(None) + } +} + +#[cfg(opus_codec_frame_bounded_extensions)] +fn parse_extensions(data: &[u8], frame_count: usize) -> Result>> { + let mut iterator = ExtensionIteratorFrameBounded::new(data, frame_count); + let mut extensions = Vec::new(); + while let Some(extension) = iterator.next_extension()? { + extensions.push(extension); + } + Ok(extensions) +} + +fn ensure_extension_space(out: &[u8], len_limit: usize, additional: usize) -> Result<()> { + let required = out + .len() + .checked_add(additional) + .ok_or(Error::InternalError)?; + if required > len_limit { + return Err(Error::BufferTooSmall); + } + Ok(()) +} + +fn extension_output_capacity_hint(extensions: &[PacketExtension<'_>], len_limit: usize) -> usize { + extensions + .iter() + .fold(0usize, |capacity, extension| { + capacity.saturating_add( + extension + .data + .len() + .saturating_add(extension.data.len() / EXTENSION_LENGTH_CHUNK) + .saturating_add(4), + ) + }) + .min(len_limit) +} + +fn write_extension_frame_separator( + out: &mut Vec, + len_limit: usize, + current_frame: &mut usize, + frame: usize, +) -> Result<()> { + if frame == *current_frame { + return Ok(()); + } + + let diff = frame + .checked_sub(*current_frame) + .ok_or(Error::InvalidPacket)?; + if diff == 1 { + ensure_extension_space(out, len_limit, 1)?; + out.push(EXTENSION_ONE_FRAME_SEPARATOR); + } else { + ensure_extension_space(out, len_limit, 2)?; + out.push(EXTENSION_MULTI_FRAME_SEPARATOR); + out.push(u8::try_from(diff).map_err(|_| Error::InvalidPacket)?); + } + *current_frame = frame; + Ok(()) +} + +fn write_extension_payload( + out: &mut Vec, + len_limit: usize, + extension: &PacketExtension<'_>, + last: bool, +) -> Result<()> { + if extension.id < EXTENSION_SHORT_ID_MAX { + if extension.data.len() > 1 { + return Err(Error::InvalidPacket); + } + ensure_extension_space(out, len_limit, extension.data.len())?; + out.extend_from_slice(extension.data); + } else { + let length_bytes = if last { + 0 + } else { + 1 + extension.data.len() / EXTENSION_LENGTH_CHUNK + }; + ensure_extension_space(out, len_limit, length_bytes + extension.data.len())?; + if !last { + out.extend(std::iter::repeat_n( + EXTENSION_LENGTH_CHUNK_BYTE, + extension.data.len() / EXTENSION_LENGTH_CHUNK, + )); + out.push( + u8::try_from(extension.data.len() % EXTENSION_LENGTH_CHUNK) + .map_err(|_| Error::InternalError)?, + ); + } + out.extend_from_slice(extension.data); + } + Ok(()) +} + +fn write_extension_entry( + out: &mut Vec, + len_limit: usize, + extension: &PacketExtension<'_>, + last: bool, +) -> Result<()> { + ensure_extension_space(out, len_limit, 1)?; + let len_flag = if extension.id < EXTENSION_SHORT_ID_MAX { + u8::try_from(extension.data.len()).map_err(|_| Error::InternalError)? + } else { + u8::from(!last) + }; + out.push(extension.id << 1 | len_flag); + write_extension_payload(out, len_limit, extension, last) +} + +#[cfg(not(opus_codec_frame_bounded_extensions))] +pub(super) fn generate_extensions( + extensions: &[PacketExtension<'_>], + len_limit: usize, + _frame_count: usize, +) -> Result> { + if extensions.is_empty() { + return Ok(Vec::new()); + } + + let mut max_frame = 0usize; + for ext in extensions { + let frame = usize::from(ext.frame); + if ext.id < EXTENSION_MIN_DATA_ID + || ext.id > EXTENSION_MAX_ID + || frame >= MAX_FRAMES_PER_PACKET + { + return Err(Error::InvalidPacket); + } + max_frame = max_frame.max(frame); + } + + let mut out = Vec::with_capacity(extension_output_capacity_hint(extensions, len_limit)); + let mut current_frame = 0usize; + let mut written = 0usize; + + for frame in 0..=max_frame { + for ext in extensions + .iter() + .filter(|ext| usize::from(ext.frame) == frame) + { + write_extension_frame_separator(&mut out, len_limit, &mut current_frame, frame)?; + write_extension_entry(&mut out, len_limit, ext, written + 1 == extensions.len())?; + written += 1; + } + } + + Ok(out) +} + +#[cfg(opus_codec_frame_bounded_extensions)] +#[derive(Clone, Copy, Debug, Default)] +struct FrameBoundedRepeatPlan { + trigger_index: Option, + repeat_count: usize, + last_long_idx: Option, +} + +#[cfg(opus_codec_frame_bounded_extensions)] +struct FrameBoundedExtensionWriter<'extensions, 'data> { + extensions: &'extensions [PacketExtension<'data>], + len_limit: usize, + extension_count: usize, + frame_count: usize, + frame_min_idx: [usize; MAX_FRAMES_PER_PACKET], + frame_max_idx: [usize; MAX_FRAMES_PER_PACKET], + frame_repeat_idx: [usize; MAX_FRAMES_PER_PACKET], + out: Vec, + current_frame: usize, + written: usize, +} + +#[cfg(opus_codec_frame_bounded_extensions)] +impl<'extensions, 'data> FrameBoundedExtensionWriter<'extensions, 'data> { + fn new( + extensions: &'extensions [PacketExtension<'data>], + len_limit: usize, + frame_count: usize, + ) -> Result { + if frame_count > MAX_FRAMES_PER_PACKET { + return Err(Error::InvalidPacket); + } + + let extension_count = extensions.len(); + let mut frame_min_idx = [extension_count; MAX_FRAMES_PER_PACKET]; + let mut frame_max_idx = [0usize; MAX_FRAMES_PER_PACKET]; + for (index, extension) in extensions.iter().enumerate() { + let frame = usize::from(extension.frame); + if frame >= frame_count + || extension.id < EXTENSION_FRAME_BOUNDED_MIN_DATA_ID + || extension.id > EXTENSION_MAX_ID + { + return Err(Error::InvalidPacket); + } + frame_min_idx[frame] = frame_min_idx[frame].min(index); + frame_max_idx[frame] = frame_max_idx[frame].max(index + 1); + } + + let frame_repeat_idx = frame_min_idx; + Ok(Self { + extensions, + len_limit, + extension_count, + frame_count, + frame_min_idx, + frame_max_idx, + frame_repeat_idx, + out: Vec::with_capacity(extension_output_capacity_hint(extensions, len_limit)), + current_frame: 0, + written: 0, + }) + } + + fn into_bytes(mut self) -> Result> { + for frame in 0..self.frame_count { + self.write_frame(frame)?; + } + + debug_assert_eq!(self.written, self.extension_count); + Ok(self.out) + } + + fn write_frame(&mut self, frame: usize) -> Result<()> { + let repeat_plan = self.repeat_plan(frame); + if self.frame_min_idx[frame] >= self.frame_max_idx[frame] { + return Ok(()); + } + + for index in self.frame_min_idx[frame]..self.frame_max_idx[frame] { + if usize::from(self.extensions[index].frame) != frame { + continue; + } + + self.write_extension(index)?; + if repeat_plan.trigger_index == Some(index) { + self.write_repeated_payloads(frame, repeat_plan)?; + } + } + Ok(()) + } + + fn write_extension(&mut self, index: usize) -> Result<()> { + write_extension_frame_separator( + &mut self.out, + self.len_limit, + &mut self.current_frame, + usize::from(self.extensions[index].frame), + )?; + write_extension_entry( + &mut self.out, + self.len_limit, + &self.extensions[index], + self.written + 1 == self.extension_count, + )?; + self.written += 1; + Ok(()) + } + + fn repeat_plan(&mut self, frame: usize) -> FrameBoundedRepeatPlan { + let mut plan = FrameBoundedRepeatPlan::default(); + if frame + 1 >= self.frame_count || self.frame_min_idx[frame] >= self.frame_max_idx[frame] { + return plan; + } + + for index in self.frame_min_idx[frame]..self.frame_max_idx[frame] { + if usize::from(self.extensions[index].frame) != frame { + continue; + } + if !self.extension_repeats_to_final_frame(frame, index) { + break; + } + + if self.extensions[index].id >= EXTENSION_SHORT_ID_MAX { + plan.last_long_idx = Some(self.frame_repeat_idx[self.frame_count - 1]); + } + + self.advance_future_repeat_indexes(frame); + plan.repeat_count += 1; + plan.trigger_index = Some(index); + } + plan + } + + fn extension_repeats_to_final_frame(&self, frame: usize, index: usize) -> bool { + let mut future_frame = frame + 1; + while future_frame < self.frame_count { + if self.frame_repeat_idx[future_frame] >= self.frame_max_idx[future_frame] { + break; + } + let repeated = &self.extensions[self.frame_repeat_idx[future_frame]]; + if repeated.id != self.extensions[index].id { + break; + } + if repeated.id < EXTENSION_SHORT_ID_MAX + && repeated.data.len() != self.extensions[index].data.len() + { + break; + } + future_frame += 1; + } + future_frame == self.frame_count + } + + fn advance_future_repeat_indexes(&mut self, frame: usize) { + for future_frame in (frame + 1)..self.frame_count { + let mut next = self.frame_repeat_idx[future_frame] + 1; + while next < self.frame_max_idx[future_frame] + && usize::from(self.extensions[next].frame) != future_frame + { + next += 1; + } + self.frame_repeat_idx[future_frame] = next; + } + } + + fn write_repeated_payloads( + &mut self, + frame: usize, + plan: FrameBoundedRepeatPlan, + ) -> Result<()> { + let Some(trigger_index) = plan.trigger_index else { + return Ok(()); + }; + let repeated_count = plan + .repeat_count + .checked_mul(self.frame_count - (frame + 1)) + .ok_or(Error::InternalError)?; + let last = self + .written + .checked_add(repeated_count) + .ok_or(Error::InternalError)? + == self.extension_count + || (plan.last_long_idx.is_none() && trigger_index + 1 >= self.frame_max_idx[frame]); + ensure_extension_space(&self.out, self.len_limit, 1)?; + self.out.push(EXTENSION_REPEAT_ID << 1 | u8::from(!last)); + + for future_frame in (frame + 1)..self.frame_count { + self.write_repeated_frame_payloads(future_frame, plan.last_long_idx, last)?; + } + if last { + self.current_frame += 1; + } + Ok(()) + } + + fn write_repeated_frame_payloads( + &mut self, + future_frame: usize, + last_long_idx: Option, + last: bool, + ) -> Result<()> { + let mut next = self.frame_min_idx[future_frame]; + while next < self.frame_repeat_idx[future_frame] { + if usize::from(self.extensions[next].frame) == future_frame { + write_extension_payload( + &mut self.out, + self.len_limit, + &self.extensions[next], + last && Some(next) == last_long_idx, + )?; + self.written += 1; + } + next += 1; + } + self.frame_min_idx[future_frame] = next; + Ok(()) + } +} + +#[cfg(opus_codec_frame_bounded_extensions)] +pub(super) fn generate_extensions( + extensions: &[PacketExtension<'_>], + len_limit: usize, + frame_count: usize, +) -> Result> { + if extensions.is_empty() { + return Ok(Vec::new()); + } + FrameBoundedExtensionWriter::new(extensions, len_limit, frame_count)?.into_bytes() +} + +pub(super) fn parse_existing_extensions( + data: &[u8], + frame_count: usize, +) -> Result>> { + // Upstream repacketizer code treats extension-parse failures inside an + // otherwise successfully parsed packet as OPUS_INTERNAL_ERROR. + parse_extensions(data, frame_count).map_err(|_| Error::InternalError) +} diff --git a/src/projection.rs b/src/projection.rs index 0318366..4bcf5ec 100644 --- a/src/projection.rs +++ b/src/projection.rs @@ -10,15 +10,39 @@ use crate::bindings::{ opus_projection_decoder_get_size, opus_projection_decoder_init, opus_projection_encode, opus_projection_encode_float, opus_projection_encoder_ctl, opus_projection_encoder_destroy, }; -use crate::constants::max_frame_samples_for; +use crate::constants::{is_frame_size_2_5ms_aligned, max_frame_samples_for}; use crate::error::{Error, Result}; use crate::types::{Application, Bitrate, SampleRate}; use crate::{AlignedBuffer, Ownership, RawHandle}; use std::marker::PhantomData; -use std::num::NonZeroUsize; +use std::num::{NonZeroU8, NonZeroUsize}; use std::ops::{Deref, DerefMut}; use std::ptr::NonNull; +fn validate_channels(channels: u8) -> Result<()> { + NonZeroU8::new(channels).ok_or(Error::BadArg)?; + Ok(()) +} + +fn validate_projection_mapping_family(mapping_family: i32) -> Result<()> { + if mapping_family == 3 { + Ok(()) + } else { + Err(Error::BadArg) + } +} + +fn validate_stream_counts(streams: u8, coupled_streams: u8) -> Result<()> { + let streams = NonZeroU8::new(streams).ok_or(Error::BadArg)?; + if coupled_streams > streams.get() { + return Err(Error::BadArg); + } + if usize::from(streams.get()) + usize::from(coupled_streams) > u8::MAX as usize { + return Err(Error::BadArg); + } + Ok(()) +} + /// Safe wrapper around `OpusProjectionEncoder`. pub struct ProjectionEncoder { raw: RawHandle, @@ -29,7 +53,6 @@ pub struct ProjectionEncoder { } unsafe impl Send for ProjectionEncoder {} -unsafe impl Sync for ProjectionEncoder {} /// Borrowed wrapper around a projection encoder state. pub struct ProjectionEncoderRef<'a> { @@ -38,7 +61,6 @@ pub struct ProjectionEncoderRef<'a> { } unsafe impl Send for ProjectionEncoderRef<'_> {} -unsafe impl Sync for ProjectionEncoderRef<'_> {} impl ProjectionEncoder { fn from_raw( @@ -63,6 +85,8 @@ impl ProjectionEncoder { /// # Errors /// Returns [`Error::BadArg`] if the channel/mapping configuration is invalid. pub fn size(channels: u8, mapping_family: i32) -> Result { + validate_channels(channels)?; + validate_projection_mapping_family(mapping_family)?; let raw = unsafe { opus_projection_ambisonics_encoder_get_size(i32::from(channels), mapping_family) }; @@ -93,6 +117,7 @@ impl ProjectionEncoder { if !crate::opus_ptr_is_aligned(ptr.cast()) { return Err(Error::BadArg); } + Self::size(channels, mapping_family)?; let mut streams = 0i32; let mut coupled = 0i32; let r = unsafe { @@ -129,6 +154,7 @@ impl ProjectionEncoder { mapping_family: i32, application: Application, ) -> Result { + Self::size(channels, mapping_family)?; let mut err = 0i32; let mut streams = 0i32; let mut coupled = 0i32; @@ -168,7 +194,10 @@ impl ProjectionEncoder { } fn ensure_pcm_layout(&self, len: usize, frame_size_per_ch: usize) -> Result<()> { - if len != frame_size_per_ch * self.channels as usize { + let expected = frame_size_per_ch + .checked_mul(self.channels as usize) + .ok_or(Error::BadArg)?; + if len != expected { return Err(Error::BadArg); } Ok(()) @@ -285,6 +314,10 @@ impl ProjectionEncoder { /// when libopus reports an invalid matrix size. pub fn write_demixing_matrix(&mut self, out: &mut [u8]) -> Result { let size = self.demixing_matrix_size()?; + self.write_demixing_matrix_with_size(out, size) + } + + fn write_demixing_matrix_with_size(&mut self, out: &mut [u8], size: i32) -> Result { if size <= 0 { return Err(Error::InternalError); } @@ -315,7 +348,7 @@ impl ProjectionEncoder { let size = self.demixing_matrix_size()?; let len = usize::try_from(size).map_err(|_| Error::InternalError)?; let mut buf = vec![0u8; len]; - self.write_demixing_matrix(&mut buf)?; + self.write_demixing_matrix_with_size(&mut buf, size)?; Ok(buf) } @@ -367,9 +400,18 @@ impl<'a> ProjectionEncoderRef<'a> { /// # Safety /// - `ptr` must point to valid, initialized memory of at least [`ProjectionEncoder::size()`] bytes /// - `ptr` must be aligned to at least `align_of::()` (malloc-style alignment) + /// - `sample_rate`, `channels`, `streams`, and `coupled_streams` must exactly match + /// the encoder state already stored at `ptr` /// - The memory must remain valid for the lifetime `'a` /// - Caller is responsible for freeing the memory after this wrapper is dropped /// + /// Passing mismatched metadata is undefined behavior: later safe methods may validate buffer + /// sizes with the wrong layout and then call libopus with out-of-bounds buffers. + /// + /// # Panics + /// Panics if `ptr` is null or not pointer-aligned, or if the channel/stream + /// counts are invalid. + /// /// Use [`ProjectionEncoder::init_in_place`] to initialize the memory before calling this. #[must_use] pub unsafe fn from_raw( @@ -379,10 +421,16 @@ impl<'a> ProjectionEncoderRef<'a> { streams: u8, coupled_streams: u8, ) -> Self { - debug_assert!(!ptr.is_null(), "from_raw called with null ptr"); - debug_assert!(crate::opus_ptr_is_aligned(ptr.cast())); + assert!( + validate_channels(channels).is_ok(), + "ProjectionEncoderRef::from_raw called with zero channels" + ); + assert!( + validate_stream_counts(streams, coupled_streams).is_ok(), + "ProjectionEncoderRef::from_raw called with invalid stream counts" + ); let encoder = ProjectionEncoder::from_raw( - unsafe { NonNull::new_unchecked(ptr) }, + crate::checked_non_null(ptr, "ProjectionEncoderRef::from_raw"), sample_rate, channels, streams, @@ -448,7 +496,6 @@ pub struct ProjectionDecoder { } unsafe impl Send for ProjectionDecoder {} -unsafe impl Sync for ProjectionDecoder {} /// Borrowed wrapper around a projection decoder state. pub struct ProjectionDecoderRef<'a> { @@ -457,7 +504,6 @@ pub struct ProjectionDecoderRef<'a> { } unsafe impl Send for ProjectionDecoderRef<'_> {} -unsafe impl Sync for ProjectionDecoderRef<'_> {} impl ProjectionDecoder { fn from_raw( @@ -482,6 +528,8 @@ impl ProjectionDecoder { /// # Errors /// Returns [`Error::BadArg`] if the channel/stream configuration is invalid. pub fn size(channels: u8, streams: u8, coupled_streams: u8) -> Result { + validate_channels(channels)?; + validate_stream_counts(streams, coupled_streams)?; let raw = unsafe { opus_projection_decoder_get_size( i32::from(channels), @@ -517,7 +565,13 @@ impl ProjectionDecoder { if !crate::opus_ptr_is_aligned(ptr.cast()) { return Err(Error::BadArg); } + Self::size(channels, streams, coupled_streams)?; let matrix_len = i32::try_from(demixing_matrix.len()).map_err(|_| Error::BadArg)?; + let mut demixing_matrix = demixing_matrix.to_vec(); + // SAFETY: libopus' C ABI takes a non-const pointer despite documenting this + // parameter as input-only. Pass a mutable scratch copy so C never receives a + // mutable pointer into the caller's immutable slice. libopus copies the + // bytes before returning and does not retain this pointer. let r = unsafe { opus_projection_decoder_init( ptr, @@ -525,7 +579,7 @@ impl ProjectionDecoder { i32::from(channels), i32::from(streams), i32::from(coupled_streams), - demixing_matrix.as_ptr().cast_mut(), + demixing_matrix.as_mut_ptr(), matrix_len, ) }; @@ -550,15 +604,19 @@ impl ProjectionDecoder { if demixing_matrix.is_empty() { return Err(Error::BadArg); } + Self::size(channels, streams, coupled_streams)?; let matrix_len = i32::try_from(demixing_matrix.len()).map_err(|_| Error::BadArg)?; + let mut demixing_matrix = demixing_matrix.to_vec(); let mut err = 0i32; + // SAFETY: see comment in init_in_place; libopus copies the scratch input + // before returning and does not retain this pointer. let dec = unsafe { opus_projection_decoder_create( sample_rate as i32, i32::from(channels), i32::from(streams), i32::from(coupled_streams), - demixing_matrix.as_ptr().cast_mut(), + demixing_matrix.as_mut_ptr(), matrix_len, &raw mut err, ) @@ -586,7 +644,10 @@ impl ProjectionDecoder { } fn ensure_output_layout(&self, len: usize, frame_size_per_ch: usize) -> Result<()> { - if len != frame_size_per_ch * self.channels as usize { + let expected = frame_size_per_ch + .checked_mul(self.channels as usize) + .ok_or(Error::BadArg)?; + if len != expected { return Err(Error::BadArg); } Ok(()) @@ -607,6 +668,12 @@ impl ProjectionDecoder { ) -> Result { self.ensure_output_layout(out.len(), frame_size_per_ch)?; let frame_size = self.validate_frame_size(frame_size_per_ch)?; + // libopus requires PLC/FEC frame sizes to be multiples of 2.5 ms. + if (packet.is_empty() || fec) + && !is_frame_size_2_5ms_aligned(frame_size_per_ch, self.sample_rate) + { + return Err(Error::BadArg); + } let packet_len = if packet.is_empty() { 0 } else { @@ -647,6 +714,12 @@ impl ProjectionDecoder { ) -> Result { self.ensure_output_layout(out.len(), frame_size_per_ch)?; let frame_size = self.validate_frame_size(frame_size_per_ch)?; + // libopus requires PLC/FEC frame sizes to be multiples of 2.5 ms. + if (packet.is_empty() || fec) + && !is_frame_size_2_5ms_aligned(frame_size_per_ch, self.sample_rate) + { + return Err(Error::BadArg); + } let packet_len = if packet.is_empty() { 0 } else { @@ -703,9 +776,18 @@ impl<'a> ProjectionDecoderRef<'a> { /// # Safety /// - `ptr` must point to valid, initialized memory of at least [`ProjectionDecoder::size()`] bytes /// - `ptr` must be aligned to at least `align_of::()` (malloc-style alignment) + /// - `sample_rate`, `channels`, `streams`, and `coupled_streams` must exactly match + /// the decoder state already stored at `ptr` /// - The memory must remain valid for the lifetime `'a` /// - Caller is responsible for freeing the memory after this wrapper is dropped /// + /// Passing mismatched metadata is undefined behavior: later safe methods may validate buffer + /// sizes with the wrong layout and then call libopus with out-of-bounds buffers. + /// + /// # Panics + /// Panics if `ptr` is null or not pointer-aligned, or if the channel/stream + /// counts are invalid. + /// /// Use [`ProjectionDecoder::init_in_place`] to initialize the memory before calling this. #[must_use] pub unsafe fn from_raw( @@ -715,10 +797,16 @@ impl<'a> ProjectionDecoderRef<'a> { streams: u8, coupled_streams: u8, ) -> Self { - debug_assert!(!ptr.is_null(), "from_raw called with null ptr"); - debug_assert!(crate::opus_ptr_is_aligned(ptr.cast())); + assert!( + validate_channels(channels).is_ok(), + "ProjectionDecoderRef::from_raw called with zero channels" + ); + assert!( + validate_stream_counts(streams, coupled_streams).is_ok(), + "ProjectionDecoderRef::from_raw called with invalid stream counts" + ); let decoder = ProjectionDecoder::from_raw( - unsafe { NonNull::new_unchecked(ptr) }, + crate::checked_non_null(ptr, "ProjectionDecoderRef::from_raw"), sample_rate, channels, streams, diff --git a/src/raw.rs b/src/raw.rs index 975a1ff..c70a048 100644 --- a/src/raw.rs +++ b/src/raw.rs @@ -23,6 +23,15 @@ impl RawHandle { } } +pub(crate) fn checked_non_null(ptr: *mut T, context: &str) -> NonNull { + assert!(!ptr.is_null(), "{context} called with null ptr"); + assert!( + crate::opus_ptr_is_aligned(ptr.cast()), + "{context} called with misaligned ptr" + ); + NonNull::new(ptr).expect("pointer was checked for null") +} + impl Drop for RawHandle { fn drop(&mut self) { if self.ownership == Ownership::Owned { diff --git a/src/repacketizer.rs b/src/repacketizer.rs index 07adf18..1887111 100644 --- a/src/repacketizer.rs +++ b/src/repacketizer.rs @@ -6,6 +6,8 @@ use crate::bindings::{ opus_repacketizer_out, opus_repacketizer_out_range, }; use crate::error::{Error, Result}; +#[cfg(opus_codec_rust_packet_ops)] +use crate::packet; use crate::{AlignedBuffer, Ownership, RawHandle}; use std::marker::PhantomData; use std::ops::{Deref, DerefMut}; @@ -14,11 +16,16 @@ use std::ptr::NonNull; /// Repackages Opus frames into packets. pub struct Repacketizer { rp: RawHandle, - packets: Vec>, + packets: Vec, +} + +struct RetainedPacket { + data: Vec, + #[cfg(opus_codec_rust_packet_ops)] + layout: Result, } unsafe impl Send for Repacketizer {} -unsafe impl Sync for Repacketizer {} /// Borrowed wrapper around a repacketizer state. pub struct RepacketizerRef<'a> { @@ -27,7 +34,6 @@ pub struct RepacketizerRef<'a> { } unsafe impl Send for RepacketizerRef<'_> {} -unsafe impl Sync for RepacketizerRef<'_> {} impl Repacketizer { fn from_raw(ptr: NonNull, ownership: Ownership) -> Self { @@ -60,19 +66,35 @@ impl Repacketizer { /// # Errors /// Returns an error if the packet is invalid for the current state. pub fn push(&mut self, packet: &[u8]) -> Result<()> { + if packet.is_empty() || i32::try_from(packet.len()).is_err() { + return Err(Error::BadArg); + } + self.push_owned(packet.to_vec()) + } + + /// Add an owned packet to the current state without copying its payload. + /// + /// The packet is retained until the next call to [`Self::reset`]. + /// + /// # Errors + /// Returns an error if the packet is invalid for the current state. + pub fn push_owned(&mut self, packet: Vec) -> Result<()> { if packet.is_empty() { return Err(Error::BadArg); } let len_i32 = i32::try_from(packet.len()).map_err(|_| Error::BadArg)?; - self.packets.push(packet.to_vec()); - let idx = self.packets.len() - 1; - let r = - unsafe { opus_repacketizer_cat(self.rp.as_ptr(), self.packets[idx].as_ptr(), len_i32) }; + #[cfg(opus_codec_rust_packet_ops)] + let layout = packet::packet_repacketizer_layout(&packet); + let r = unsafe { opus_repacketizer_cat(self.rp.as_ptr(), packet.as_ptr(), len_i32) }; if r != 0 { - self.packets.pop(); return Err(Error::from_code(r)); } // libopus stores pointers into packet data; keep owned buffers alive. + self.packets.push(RetainedPacket { + data: packet, + #[cfg(opus_codec_rust_packet_ops)] + layout, + }); Ok(()) } @@ -102,7 +124,8 @@ impl Repacketizer { /// Emit a packet containing frames in range [begin, end). /// /// # Errors - /// Returns an error if range is invalid or output buffer is too small. + /// Returns [`Error::BadArg`] if the range is invalid or `out` is empty, or + /// [`Error::BufferTooSmall`] if `out` cannot hold the packet. pub fn emit_range(&mut self, begin: i32, end: i32, out: &mut [u8]) -> Result { if out.is_empty() { return Err(Error::BadArg); @@ -110,30 +133,66 @@ impl Repacketizer { if begin < 0 || end <= begin { return Err(Error::BadArg); } - let out_len_i32 = i32::try_from(out.len()).map_err(|_| Error::BadArg)?; - let n = unsafe { - opus_repacketizer_out_range(self.rp.as_ptr(), begin, end, out.as_mut_ptr(), out_len_i32) - }; - if n < 0 { - return Err(Error::from_code(n)); + #[cfg(opus_codec_rust_packet_ops)] + let begin_index = usize::try_from(begin).map_err(|_| Error::BadArg)?; + #[cfg(opus_codec_rust_packet_ops)] + let end_index = usize::try_from(end).map_err(|_| Error::BadArg)?; + #[cfg(not(opus_codec_rust_packet_ops))] + { + self.emit_range_via_c(begin, end, out) + } + #[cfg(opus_codec_rust_packet_ops)] + { + let mut frames = [&[][..]; packet::MAX_FRAMES_PER_PACKET]; + let mut paddings = [packet::PacketPadding::EMPTY; packet::MAX_FRAMES_PER_PACKET]; + if let Some((toc, frame_count)) = + self.repacketizer_inputs(&mut frames, &mut paddings)? + { + if end_index > frame_count { + return Err(Error::BadArg); + } + return packet::repacketize_frames_range( + toc, + &frames[..frame_count], + &paddings[..frame_count], + begin_index, + end_index, + out, + ); + } + self.emit_range_via_c(begin, end, out) } - usize::try_from(n).map_err(|_| Error::InternalError) } /// Emit a packet with all queued frames. /// /// # Errors - /// Returns an error if the output buffer is too small. + /// Returns [`Error::BadArg`] if `out` is empty, or [`Error::BufferTooSmall`] + /// if `out` cannot hold the packet. pub fn emit(&mut self, out: &mut [u8]) -> Result { if out.is_empty() { return Err(Error::BadArg); } - let out_len_i32 = i32::try_from(out.len()).map_err(|_| Error::BadArg)?; - let n = unsafe { opus_repacketizer_out(self.rp.as_ptr(), out.as_mut_ptr(), out_len_i32) }; - if n < 0 { - return Err(Error::from_code(n)); + #[cfg(not(opus_codec_rust_packet_ops))] + { + self.emit_via_c(out) + } + #[cfg(opus_codec_rust_packet_ops)] + { + let mut frames = [&[][..]; packet::MAX_FRAMES_PER_PACKET]; + let mut paddings = [packet::PacketPadding::EMPTY; packet::MAX_FRAMES_PER_PACKET]; + if let Some((toc, frame_count)) = + self.repacketizer_inputs(&mut frames, &mut paddings)? + { + return packet::repacketize_frames( + toc, + &frames[..frame_count], + &paddings[..frame_count], + out, + ); + } + self.emit_via_c(out) } - usize::try_from(n).map_err(|_| Error::InternalError) } /// Size of a repacketizer state in bytes for external allocation. @@ -166,6 +225,82 @@ impl Repacketizer { unsafe { opus_repacketizer_init(ptr) }; Ok(()) } + + #[cfg(opus_codec_rust_packet_ops)] + fn repacketizer_inputs<'a>( + &'a self, + frames: &mut [&'a [u8]; packet::MAX_FRAMES_PER_PACKET], + paddings: &mut [packet::PacketPadding<'a>; packet::MAX_FRAMES_PER_PACKET], + ) -> Result> { + let c_frame_count = self.len(); + if self.packets.is_empty() { + return if c_frame_count == 0 { + Err(Error::BadArg) + } else { + Ok(None) + }; + } + + let mut toc = None; + let mut frame_count = 0usize; + for packet in &self.packets { + let layout = match &packet.layout { + Ok(layout) => layout, + // The packet was already accepted by opus_repacketizer_cat(). + // Treat a Rust/C structural-parser disagreement as an + // unavailable optimization and let the caller use C output. + Err(Error::InvalidPacket) => return Ok(None), + Err(err) => return Err(err.clone()), + }; + toc.get_or_insert(layout.toc); + let packet_frame_count = layout.frames().len(); + if frame_count + packet_frame_count > packet::MAX_FRAMES_PER_PACKET { + return Ok(None); + } + for (packet_frame, frame) in layout.frames().iter().enumerate() { + frames[frame_count] = frame.slice(&packet.data)?; + paddings[frame_count] = if packet_frame == 0 { + layout.packet_padding(&packet.data) + } else { + packet::PacketPadding::EMPTY + }; + frame_count += 1; + } + } + + if frame_count != c_frame_count { + return Ok(None); + } + + let toc = toc.ok_or(Error::BadArg)?; + Ok(Some((toc, frame_count))) + } + + fn emit_range_via_c(&self, begin: i32, end: i32, out: &mut [u8]) -> Result { + if out.is_empty() { + return Err(Error::BadArg); + } + let out_len_i32 = i32::try_from(out.len()).map_err(|_| Error::BadArg)?; + let n = unsafe { + opus_repacketizer_out_range(self.rp.as_ptr(), begin, end, out.as_mut_ptr(), out_len_i32) + }; + if n < 0 { + return Err(Error::from_code(n)); + } + usize::try_from(n).map_err(|_| Error::InternalError) + } + + fn emit_via_c(&self, out: &mut [u8]) -> Result { + if out.is_empty() { + return Err(Error::BadArg); + } + let out_len_i32 = i32::try_from(out.len()).map_err(|_| Error::BadArg)?; + let n = unsafe { opus_repacketizer_out(self.rp.as_ptr(), out.as_mut_ptr(), out_len_i32) }; + if n < 0 { + return Err(Error::from_code(n)); + } + usize::try_from(n).map_err(|_| Error::InternalError) + } } impl<'a> RepacketizerRef<'a> { @@ -176,14 +311,21 @@ impl<'a> RepacketizerRef<'a> { /// - `ptr` must be aligned to at least `align_of::()` (malloc-style alignment) /// - The memory must remain valid for the lifetime `'a` /// - Caller is responsible for freeing the memory after this wrapper is dropped + /// - If `ptr` already contains packet pointers, their backing storage must + /// remain valid while this wrapper is used. /// /// Use [`Repacketizer::init_in_place`] to initialize the memory before calling this. + /// If packets are pushed through this wrapper, dropping it resets the raw + /// state to avoid leaving dangling pointers to the wrapper's owned buffers. + /// + /// # Panics + /// Panics if `ptr` is null or not pointer-aligned. #[must_use] pub unsafe fn from_raw(ptr: *mut OpusRepacketizer) -> Self { - debug_assert!(!ptr.is_null(), "from_raw called with null ptr"); - debug_assert!(crate::opus_ptr_is_aligned(ptr.cast())); - let repacketizer = - Repacketizer::from_raw(unsafe { NonNull::new_unchecked(ptr) }, Ownership::Borrowed); + let repacketizer = Repacketizer::from_raw( + crate::checked_non_null(ptr, "RepacketizerRef::from_raw"), + Ownership::Borrowed, + ); Self { inner: repacketizer, _marker: PhantomData, @@ -205,6 +347,18 @@ impl<'a> RepacketizerRef<'a> { } } +impl Drop for RepacketizerRef<'_> { + fn drop(&mut self) { + if !self.inner.packets.is_empty() { + // Reinitialize the external C state to clear packet pointers that + // reference our `self.inner.packets` buffers, which are about to be + // freed. Without this the caller could reuse the external + // OpusRepacketizer and dereference dangling pointers. + unsafe { opus_repacketizer_init(self.inner.rp.as_ptr()) }; + } + } +} + impl Deref for RepacketizerRef<'_> { type Target = Repacketizer; @@ -218,3 +372,38 @@ impl DerefMut for RepacketizerRef<'_> { &mut self.inner } } + +#[cfg(all(test, opus_codec_rust_packet_ops))] +mod tests { + use super::*; + + #[test] + fn invalid_cached_layout_falls_back_to_c_emit() { + let packet = vec![0x78, b'a']; + let mut rp = Repacketizer::new().unwrap(); + rp.push_owned(packet.clone()).unwrap(); + rp.packets[0].layout = Err(Error::InvalidPacket); + + let mut out = [0u8; 8]; + let out_len = rp.emit(&mut out).unwrap(); + assert_eq!(&out[..out_len], packet); + + let out_len = rp.emit_range(0, 1, &mut out).unwrap(); + assert_eq!(&out[..out_len], packet); + } + + #[test] + fn unexpected_cached_layout_error_is_not_hidden() { + let packet = vec![0x78, b'a']; + let mut rp = Repacketizer::new().unwrap(); + rp.push_owned(packet).unwrap(); + rp.packets[0].layout = Err(Error::InternalError); + + let mut out = [0u8; 8]; + assert_eq!(rp.emit(&mut out).unwrap_err(), Error::InternalError); + assert_eq!( + rp.emit_range(0, 1, &mut out).unwrap_err(), + Error::InternalError + ); + } +} diff --git a/src/types.rs b/src/types.rs index 3f37f99..911f378 100644 --- a/src/types.rs +++ b/src/types.rs @@ -159,6 +159,16 @@ pub enum ExpertFrameDuration { pub struct Complexity(u32); impl Complexity { + /// Create a complexity value when `complexity` is in the range 0..=10. + #[must_use] + pub const fn try_new(complexity: u32) -> Option { + if complexity <= 10 { + Some(Self(complexity)) + } else { + None + } + } + /// Create a new complexity value in range 0..=10. /// /// # Panics @@ -215,4 +225,11 @@ mod tests { assert_eq!(FrameSize::Ms5.samples(SampleRate::Hz16000), 80); assert_eq!(FrameSize::Ms2_5.samples(SampleRate::Hz8000), 20); } + + #[test] + fn complexity_checked_constructor_rejects_out_of_range_values() { + assert_eq!(Complexity::try_new(0), Some(Complexity::new(0))); + assert_eq!(Complexity::try_new(10), Some(Complexity::new(10))); + assert_eq!(Complexity::try_new(11), None); + } } diff --git a/tests/ctl_compatibility.rs b/tests/ctl_compatibility.rs new file mode 100644 index 0000000..2e343c0 --- /dev/null +++ b/tests/ctl_compatibility.rs @@ -0,0 +1,320 @@ +#![cfg(not(opus_codec_system_lib))] + +use opus_codec::multistream::{Mapping, MultistreamDecoder, MultistreamEncoder}; +use opus_codec::projection::ProjectionEncoder; +use opus_codec::{ + Application, Bandwidth, Bitrate, Channels, Complexity, Decoder, Encoder, Error, + ExpertFrameDuration, Result, SampleRate, Signal, +}; + +type CtlCase = (&'static str, fn(&mut T) -> Result<()>); + +fn run_ctl_cases(target: &mut T, cases: &[CtlCase]) { + for (name, case) in cases { + case(target).unwrap_or_else(|err| panic!("bundled Opus CTL {name} failed: {err:?}")); + } +} + +#[test] +fn every_single_stream_ctl_is_supported_by_bundled_opus() { + let mut encoder = + Encoder::new(SampleRate::Hz48000, Channels::Stereo, Application::Audio).unwrap(); + let encoder_cases: &[CtlCase] = &[ + ("bitrate", |encoder| { + encoder.set_bitrate(Bitrate::Custom(96_000))?; + let _ = encoder.bitrate()?; + Ok(()) + }), + ("complexity", |encoder| { + encoder.set_complexity(Complexity::new(5))?; + let _ = encoder.complexity()?; + Ok(()) + }), + ("vbr", |encoder| { + encoder.set_vbr(true)?; + let _ = encoder.vbr()?; + Ok(()) + }), + ("vbr_constraint", |encoder| { + encoder.set_vbr_constraint(true)?; + let _ = encoder.vbr_constraint()?; + Ok(()) + }), + ("inband_fec", |encoder| { + encoder.set_inband_fec(true)?; + let _ = encoder.inband_fec()?; + Ok(()) + }), + ("packet_loss", |encoder| { + encoder.set_packet_loss_perc(10)?; + let _ = encoder.packet_loss_perc()?; + Ok(()) + }), + ("dtx", |encoder| { + encoder.set_dtx(true)?; + let _ = encoder.dtx()?; + let _ = encoder.in_dtx()?; + Ok(()) + }), + ("max_bandwidth", |encoder| { + encoder.set_max_bandwidth(Bandwidth::Wideband)?; + let _ = encoder.max_bandwidth()?; + Ok(()) + }), + ("bandwidth", |encoder| { + encoder.set_bandwidth(Bandwidth::Wideband)?; + let _ = encoder.bandwidth()?; + Ok(()) + }), + ("force_channels", |encoder| { + encoder.set_force_channels(Some(Channels::Mono))?; + let _ = encoder.force_channels()?; + Ok(()) + }), + ("signal", |encoder| { + encoder.set_signal(Signal::Music)?; + let _ = encoder.signal()?; + Ok(()) + }), + ("lookahead", |encoder| { + let _ = encoder.lookahead()?; + Ok(()) + }), + ("final_range", |encoder| { + let _ = encoder.final_range()?; + Ok(()) + }), + ("lsb_depth", |encoder| { + encoder.set_lsb_depth(16)?; + let _ = encoder.lsb_depth()?; + Ok(()) + }), + ("expert_frame_duration", |encoder| { + encoder.set_expert_frame_duration(ExpertFrameDuration::Ms20)?; + let _ = encoder.expert_frame_duration()?; + Ok(()) + }), + ("prediction_disabled", |encoder| { + encoder.set_prediction_disabled(true)?; + let _ = encoder.prediction_disabled()?; + Ok(()) + }), + ("phase_inversion", |encoder| { + encoder.set_phase_inversion_disabled(true)?; + let _ = encoder.phase_inversion_disabled()?; + Ok(()) + }), + ("reset", Encoder::reset), + ]; + run_ctl_cases(&mut encoder, encoder_cases); + + let mut decoder = Decoder::new(SampleRate::Hz48000, Channels::Stereo).unwrap(); + let decoder_cases: &[CtlCase] = &[ + ("sample_rate", |decoder| { + let _ = decoder.get_sample_rate()?; + Ok(()) + }), + ("pitch", |decoder| { + let _ = decoder.get_pitch()?; + Ok(()) + }), + ("last_packet_duration", |decoder| { + let _ = decoder.get_last_packet_duration()?; + Ok(()) + }), + ("final_range", |decoder| { + let _ = decoder.final_range()?; + Ok(()) + }), + ("gain", |decoder| { + decoder.set_gain(128)?; + let _ = decoder.gain()?; + Ok(()) + }), + ("phase_inversion", |decoder| { + decoder.set_phase_inversion_disabled(true)?; + let _ = decoder.phase_inversion_disabled()?; + Ok(()) + }), + ("reset", Decoder::reset), + ]; + run_ctl_cases(&mut decoder, decoder_cases); +} + +#[test] +fn every_multistream_ctl_is_supported_by_bundled_opus() { + let mapping_table = [0u8, 1u8]; + let mapping = Mapping { + channels: 2, + streams: 1, + coupled_streams: 1, + mapping: &mapping_table, + }; + let mut encoder = + MultistreamEncoder::new(SampleRate::Hz48000, Application::Audio, mapping).unwrap(); + let encoder_cases: &[CtlCase] = &[ + ("final_range", |encoder| { + let _ = encoder.final_range()?; + Ok(()) + }), + ("bitrate", |encoder| { + encoder.set_bitrate(Bitrate::Custom(96_000))?; + let _ = encoder.bitrate()?; + Ok(()) + }), + ("complexity", |encoder| { + encoder.set_complexity(Complexity::new(5))?; + let _ = encoder.complexity()?; + Ok(()) + }), + ("dtx", |encoder| { + encoder.set_dtx(true)?; + let _ = encoder.dtx()?; + let _ = encoder.in_dtx()?; + Ok(()) + }), + ("inband_fec", |encoder| { + encoder.set_inband_fec(true)?; + let _ = encoder.inband_fec()?; + Ok(()) + }), + ("packet_loss", |encoder| { + encoder.set_packet_loss_perc(10)?; + let _ = encoder.packet_loss_perc()?; + Ok(()) + }), + ("vbr", |encoder| { + encoder.set_vbr(true)?; + let _ = encoder.vbr()?; + Ok(()) + }), + ("vbr_constraint", |encoder| { + encoder.set_vbr_constraint(true)?; + let _ = encoder.vbr_constraint()?; + Ok(()) + }), + ("max_bandwidth", |encoder| { + encoder.set_max_bandwidth(Bandwidth::Wideband)?; + let _ = encoder.max_bandwidth()?; + Ok(()) + }), + ("bandwidth", |encoder| { + encoder.set_bandwidth(Bandwidth::Wideband)?; + let _ = encoder.bandwidth()?; + Ok(()) + }), + ("force_channels", |encoder| { + encoder.set_force_channels(Some(Channels::Mono))?; + let _ = encoder.force_channels()?; + Ok(()) + }), + ("signal", |encoder| { + encoder.set_signal(Signal::Music)?; + let _ = encoder.signal()?; + Ok(()) + }), + ("lookahead", |encoder| { + let _ = encoder.lookahead()?; + Ok(()) + }), + ("encoder_state", |encoder| { + let ptr = unsafe { encoder.encoder_state_ptr(0)? }; + if ptr.is_null() { + Err(Error::InternalError) + } else { + Ok(()) + } + }), + ("reset", MultistreamEncoder::reset), + ]; + run_ctl_cases(&mut encoder, encoder_cases); + + let mut decoder = MultistreamDecoder::new(SampleRate::Hz48000, mapping).unwrap(); + let decoder_cases: &[CtlCase] = &[ + ("final_range", |decoder| { + let _ = decoder.final_range()?; + Ok(()) + }), + ("gain", |decoder| { + decoder.set_gain(128)?; + let _ = decoder.gain()?; + Ok(()) + }), + ("phase_inversion", |decoder| { + decoder.set_phase_inversion_disabled(true)?; + let _ = decoder.phase_inversion_disabled()?; + Ok(()) + }), + ("sample_rate", |decoder| { + let _ = decoder.get_sample_rate()?; + Ok(()) + }), + ("pitch", |decoder| { + let _ = decoder.get_pitch()?; + Ok(()) + }), + ("last_packet_duration", |decoder| { + let _ = decoder.get_last_packet_duration()?; + Ok(()) + }), + ("decoder_state", |decoder| { + let ptr = unsafe { decoder.decoder_state_ptr(0)? }; + if ptr.is_null() { + Err(Error::InternalError) + } else { + Ok(()) + } + }), + ("reset", MultistreamDecoder::reset), + ]; + run_ctl_cases(&mut decoder, decoder_cases); +} + +#[test] +fn every_projection_encoder_ctl_is_supported_by_bundled_opus() { + let mut encoder = + ProjectionEncoder::new(SampleRate::Hz48000, 4, 3, Application::Audio).unwrap(); + let cases: &[CtlCase] = &[ + ("bitrate", |encoder| { + encoder.set_bitrate(Bitrate::Custom(128_000))?; + let _ = encoder.bitrate()?; + Ok(()) + }), + ("demixing_matrix_size", |encoder| { + let _ = encoder.demixing_matrix_size()?; + Ok(()) + }), + ("demixing_matrix_gain", |encoder| { + let _ = encoder.demixing_matrix_gain()?; + Ok(()) + }), + ("demixing_matrix", |encoder| { + let _ = encoder.demixing_matrix_bytes()?; + Ok(()) + }), + ]; + run_ctl_cases(&mut encoder, cases); +} + +#[cfg(feature = "dred")] +#[test] +fn every_dred_ctl_uses_the_bundled_opus_signature() { + use opus_codec::DredDecoder; + + let mut encoder = + Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Audio).unwrap(); + encoder.set_dred_duration(2).unwrap(); + assert_eq!(encoder.dred_duration().unwrap(), 2); + + let mut decoder = Decoder::new(SampleRate::Hz48000, Channels::Mono).unwrap(); + assert_eq!( + unsafe { decoder.set_dnn_blob(std::ptr::null(), 0) }, + Err(Error::BadArg) + ); + + let mut dred_decoder = DredDecoder::new().unwrap(); + assert_eq!( + unsafe { dred_decoder.set_dnn_blob(&[]) }, + Err(Error::BadArg) + ); +} diff --git a/tests/encoder_ctls.rs b/tests/encoder_ctls.rs index 339cf89..780430d 100644 --- a/tests/encoder_ctls.rs +++ b/tests/encoder_ctls.rs @@ -59,3 +59,31 @@ fn encoder_control_roundtrip() { .expect("clear force channels"); assert_eq!(encoder.force_channels().expect("get forced channels"), None); } + +#[test] +fn lookahead_uses_the_configured_sample_rate() { + let mut encoder_8k = Encoder::new(SampleRate::Hz8000, Channels::Mono, Application::Audio) + .expect("create 8 kHz encoder"); + let mut encoder_48k = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Audio) + .expect("create 48 kHz encoder"); + + let lookahead_8k = encoder_8k.lookahead().expect("8 kHz lookahead"); + let lookahead_48k = encoder_48k.lookahead().expect("48 kHz lookahead"); + assert_eq!(lookahead_48k, lookahead_8k * 6); +} + +#[cfg(all(feature = "dred", not(opus_codec_system_lib)))] +#[test] +fn dred_duration_is_an_encoder_ctl_in_ten_ms_frames() { + let mut encoder = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Audio) + .expect("create DRED encoder"); + + encoder + .set_dred_duration(3) + .expect("set three 10-ms DRED frames"); + assert_eq!(encoder.dred_duration().expect("get DRED duration"), 3); + assert_eq!( + encoder.set_dred_duration(-1), + Err(opus_codec::Error::BadArg) + ); +} diff --git a/tests/encoder_decoder.rs b/tests/encoder_decoder.rs index 6c9939b..160a5d5 100644 --- a/tests/encoder_decoder.rs +++ b/tests/encoder_decoder.rs @@ -52,6 +52,13 @@ fn test_buffer_empty() { assert_eq!(result, Err(Error::BadArg)); } +#[test] +fn test_packet_samples_rejects_empty_packet() { + let decoder = Decoder::new(SampleRate::Hz48000, Channels::Mono).unwrap(); + let result = decoder.packet_samples(&[]); + assert_eq!(result, Err(Error::BadArg)); +} + #[test] fn test_init_in_place_alignment_checks() { let sr = SampleRate::Hz48000; diff --git a/tests/multhithread.rs b/tests/multhithread.rs index 32cc3c3..dfa4646 100644 --- a/tests/multhithread.rs +++ b/tests/multhithread.rs @@ -2,7 +2,7 @@ use opus_codec::{Application, Channels, Decoder, Encoder, SampleRate}; use std::sync::Arc; use std::thread; -fn assert_send_sync() {} +fn assert_send() {} fn pcm_frame() -> Vec { const FRAME: usize = 960; // 20 ms @ 48 kHz per channel @@ -13,13 +13,13 @@ fn pcm_frame() -> Vec { } #[test] -fn encoder_is_send_sync() { - assert_send_sync::(); +fn encoder_is_send() { + assert_send::(); } #[test] -fn decoder_is_send_sync() { - assert_send_sync::(); +fn decoder_is_send() { + assert_send::(); } #[test] diff --git a/tests/multistream.rs b/tests/multistream.rs index 2b234a7..d7b6717 100644 --- a/tests/multistream.rs +++ b/tests/multistream.rs @@ -4,7 +4,8 @@ use opus_codec::max_frame_samples_for; use opus_codec::multistream::{ Mapping, MultistreamDecoder, MultistreamDecoderRef, MultistreamEncoder, MultistreamEncoderRef, }; -use opus_codec::types::{Application, SampleRate}; +use opus_codec::packet::{multistream_packet_pad, multistream_packet_unpad}; +use opus_codec::types::{Application, Bandwidth, SampleRate}; #[test] fn test_multistream_surround() { @@ -46,6 +47,122 @@ fn test_multistream_surround() { assert_eq!(decoded_len, frame_size); } +fn assert_surround_mapping_matches_libopus(channels: u8, family: i32) { + let (mapping, streams, coupled) = + MultistreamEncoder::surround_mapping(channels, family).unwrap(); + let (encoder, libopus_mapping) = + MultistreamEncoder::new_surround(SampleRate::Hz48000, channels, family, Application::Audio) + .unwrap(); + assert_eq!( + mapping, libopus_mapping, + "mapping mismatch for family {family}, {channels} channels" + ); + assert_eq!( + streams, + encoder.streams(), + "stream-count mismatch for family {family}, {channels} channels" + ); + assert_eq!( + coupled, + encoder.coupled_streams(), + "coupled-stream mismatch for family {family}, {channels} channels" + ); +} + +#[test] +fn surround_mapping_matches_libopus_families() { + let cases = [ + (1, 0, vec![0], 1, 0), + (2, 0, vec![0, 1], 1, 1), + (3, 1, vec![0, 2, 1], 2, 1), + (6, 1, vec![0, 4, 1, 2, 3, 5], 4, 2), + (4, 2, vec![0, 1, 2, 3], 4, 0), + (6, 2, vec![2, 3, 4, 5, 0, 1], 5, 1), + (4, 255, vec![0, 1, 2, 3], 4, 0), + ]; + + for (channels, family, expected_mapping, expected_streams, expected_coupled) in cases { + let (mapping, streams, coupled) = + MultistreamEncoder::surround_mapping(channels, family).unwrap(); + assert_eq!(mapping, expected_mapping); + assert_eq!(streams, expected_streams); + assert_eq!(coupled, expected_coupled); + + let (encoder, libopus_mapping) = MultistreamEncoder::new_surround( + SampleRate::Hz48000, + channels, + family, + Application::Audio, + ) + .unwrap(); + assert_eq!(libopus_mapping, expected_mapping); + assert_eq!(encoder.streams(), expected_streams); + assert_eq!(encoder.coupled_streams(), expected_coupled); + + let (_decoder, decoder_mapping, decoder_streams, decoder_coupled) = + MultistreamDecoder::new_surround(SampleRate::Hz48000, channels, family).unwrap(); + assert_eq!(decoder_mapping, expected_mapping); + assert_eq!(decoder_streams, expected_streams); + assert_eq!(decoder_coupled, expected_coupled); + } + + assert_eq!( + MultistreamEncoder::surround_mapping(3, 0), + Err(Error::BadArg) + ); + assert_eq!( + MultistreamEncoder::surround_mapping(5, 2), + Err(Error::BadArg) + ); +} + +#[test] +fn surround_mapping_exhaustively_matches_libopus() { + for channels in 1..=2 { + assert_surround_mapping_matches_libopus(channels, 0); + } + assert_eq!( + MultistreamEncoder::surround_mapping(3, 0), + Err(Error::BadArg) + ); + + for channels in 1..=8 { + assert_surround_mapping_matches_libopus(channels, 1); + } + assert_eq!( + MultistreamEncoder::surround_mapping(9, 1), + Err(Error::BadArg) + ); + + let mut valid_ambisonic_counts = [false; 228]; + for order_plus_one in 1usize..=15 { + let ambisonic_channels = order_plus_one * order_plus_one; + valid_ambisonic_counts[ambisonic_channels] = true; + if ambisonic_channels + 2 <= 227 { + valid_ambisonic_counts[ambisonic_channels + 2] = true; + } + } + for channels in 1u8..=227 { + if valid_ambisonic_counts[usize::from(channels)] { + assert_surround_mapping_matches_libopus(channels, 2); + } else { + assert_eq!( + MultistreamEncoder::surround_mapping(channels, 2), + Err(Error::BadArg), + "family 2 unexpectedly accepted {channels} channels" + ); + } + } + assert_eq!( + MultistreamEncoder::surround_mapping(228, 2), + Err(Error::BadArg) + ); + + for channels in [1, 2, 8, 227, u8::MAX] { + assert_surround_mapping_matches_libopus(channels, 255); + } +} + #[test] fn test_multistream_frame_size_validation() { let mapping_table = [0u8]; @@ -75,6 +192,30 @@ fn test_multistream_frame_size_validation() { ); } +#[test] +fn multistream_scalar_ctls_use_underlying_stream_states() { + let mapping_table = [0u8, 1u8]; + let mapping = Mapping { + channels: 2, + streams: 1, + coupled_streams: 1, + mapping: &mapping_table, + }; + let mut encoder = + MultistreamEncoder::new(SampleRate::Hz48000, Application::Audio, mapping).unwrap(); + let mut decoder = MultistreamDecoder::new(SampleRate::Hz48000, mapping).unwrap(); + + encoder + .set_max_bandwidth(Bandwidth::Wideband) + .expect("set all streams' maximum bandwidth"); + assert_eq!( + encoder.max_bandwidth().expect("first stream bandwidth"), + Bandwidth::Wideband + ); + assert!(!encoder.in_dtx().expect("aggregate in-DTX state")); + assert_eq!(decoder.get_pitch().expect("first stream pitch"), 0); +} + #[test] fn test_init_in_place_unowned_multistream() { let sr = SampleRate::Hz48000; @@ -143,3 +284,26 @@ fn test_init_in_place_invalid_mapping() { let err = unsafe { MultistreamDecoder::init_in_place(dec_ptr, sr, mapping) }.unwrap_err(); assert_eq!(err, Error::BadArg); } + +#[test] +fn test_multistream_packet_pad_handles_repadding_large_packet() { + let channels = 6; + let frame_size = 960; + let (mut encoder, _) = + MultistreamEncoder::new_surround(SampleRate::Hz48000, channels, 1, Application::Audio) + .unwrap(); + + let pcm = vec![0i16; frame_size * channels as usize]; + let mut packet = vec![0u8; 70_001]; + let len = encoder.encode(&pcm, frame_size, &mut packet).unwrap(); + let original = packet[..len].to_vec(); + let nb_streams = i32::from(encoder.streams()); + + multistream_packet_pad(&mut packet, len, 70_000, nb_streams).unwrap(); + multistream_packet_pad(&mut packet, 70_000, 70_001, nb_streams).unwrap(); + assert_eq!( + multistream_packet_unpad(&mut packet, 70_001, nb_streams).unwrap(), + len + ); + assert_eq!(&packet[..len], original.as_slice()); +} diff --git a/tests/packet_padding.rs b/tests/packet_padding.rs new file mode 100644 index 0000000..d2feb31 --- /dev/null +++ b/tests/packet_padding.rs @@ -0,0 +1,325 @@ +use opus_codec::encoder::Encoder; +use opus_codec::error::{Error, Result}; +use opus_codec::multistream::MultistreamEncoder; +use opus_codec::packet::{ + multistream_packet_pad, multistream_packet_unpad, packet_pad, packet_unpad, +}; +use opus_codec::types::{Application, Channels, SampleRate}; +use opus_codec::{OPUS_INTERNAL_ERROR, opus_multistream_packet_pad, opus_packet_pad}; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; + +const SINGLE_RANDOM_PACKET_CASES: usize = 24; +const SINGLE_RANDOM_PACKET_STEPS: usize = 8; +const EXTENSION_PACKET_CASES: usize = 24; +const EXTENSION_PACKET_STEPS: usize = 6; +const MULTISTREAM_PACKET_CASES: usize = 22; +const MULTISTREAM_PACKET_STEPS: usize = 8; +const RANDOMIZED_PAD_PARITY_TOTAL_CASES: usize = SINGLE_RANDOM_PACKET_CASES + * SINGLE_RANDOM_PACKET_STEPS + + EXTENSION_PACKET_CASES * EXTENSION_PACKET_STEPS + + MULTISTREAM_PACKET_CASES * MULTISTREAM_PACKET_STEPS; +const _: [(); 512] = [(); RANDOMIZED_PAD_PARITY_TOTAL_CASES]; + +fn usize_below(rng: &mut impl Rng, upper_exclusive: usize) -> usize { + if upper_exclusive <= 1 { + 0 + } else { + (rng.next_u32() as usize) % upper_exclusive + } +} + +fn i16_sample(rng: &mut impl Rng) -> i16 { + rng.next_u32() as i16 +} + +fn c_pad_result(code: i32) -> Result<()> { + if code == 0 { + Ok(()) + } else { + Err(Error::from_code(code)) + } +} + +fn assert_packet_pad_matches_opus(packet: &mut [u8], len: usize, new_len: usize) { + let mut c_packet = packet.to_vec(); + let c_result = c_pad_result(unsafe { + opus_packet_pad( + c_packet.as_mut_ptr(), + i32::try_from(len).unwrap(), + i32::try_from(new_len).unwrap(), + ) + }); + let rust_result = packet_pad(packet, len, new_len); + assert_eq!(rust_result, c_result); + if rust_result.is_ok() { + assert_eq!(&packet[..new_len], &c_packet[..new_len]); + } +} + +fn assert_multistream_packet_pad_matches_opus( + packet: &mut [u8], + len: usize, + new_len: usize, + nb_streams: i32, +) { + let mut c_packet = packet.to_vec(); + let c_result = c_pad_result(unsafe { + opus_multistream_packet_pad( + c_packet.as_mut_ptr(), + i32::try_from(len).unwrap(), + i32::try_from(new_len).unwrap(), + nb_streams, + ) + }); + let rust_result = multistream_packet_pad(packet, len, new_len, nb_streams); + assert_eq!(rust_result, c_result); + if rust_result.is_ok() { + assert_eq!(&packet[..new_len], &c_packet[..new_len]); + } +} + +fn packet_with_valid_extensions() -> Vec { + let payload = [ + 0x7B, 0x41, 16, 0x00, 67, 7, b'a', b'b', b'c', b'd', b'e', b'f', b'g', 200, b'u', b'v', + b'w', b'x', b'y', b'z', + ]; + let mut packet = vec![0u8; 40]; + packet[..payload.len()].copy_from_slice(&payload); + packet +} + +fn packet_with_malformed_extension_len() -> Vec { + let payload = [ + 0x7B, 0x41, 16, 0x00, 67, 255, b'a', b'b', b'c', b'd', b'e', b'f', b'g', 200, b'u', b'v', + b'w', b'x', b'y', b'z', + ]; + let mut packet = vec![0u8; 40]; + packet[..payload.len()].copy_from_slice(&payload); + packet +} + +fn packet_with_short_id_two_extension_in_padding() -> Vec { + let payload = [0x7B, 0x41, 2, 0x00, 0x05, b'a']; + let mut packet = vec![0u8; 10]; + packet[..payload.len()].copy_from_slice(&payload); + packet +} + +#[test] +fn packet_pad_handles_repadding_large_packet() { + let mut encoder = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip).unwrap(); + let pcm = vec![0i16; 960]; + let mut encoded_packet = [0u8; 512]; + let encoded_len = encoder.encode(&pcm, &mut encoded_packet).unwrap(); + + let mut packet = vec![0u8; 70_001]; + packet[..encoded_len].copy_from_slice(&encoded_packet[..encoded_len]); + let original = packet[..encoded_len].to_vec(); + + packet_pad(&mut packet, encoded_len, 70_000).unwrap(); + packet_pad(&mut packet, 70_000, 70_001).unwrap(); + assert_eq!(packet_unpad(&mut packet, 70_001).unwrap(), encoded_len); + assert_eq!(&packet[..encoded_len], original.as_slice()); +} + +#[test] +fn packet_pad_rejects_zero_len_even_when_noop() { + let mut packet = [0u8; 1]; + let err = packet_pad(&mut packet, 0, 0).unwrap_err(); + assert_eq!(err, Error::BadArg); +} + +#[test] +fn packet_unpad_rejects_zero_len() { + let mut packet = [0u8; 1]; + let err = packet_unpad(&mut packet, 0).unwrap_err(); + assert_eq!(err, Error::BadArg); +} + +#[test] +fn packet_pad_matches_opus_for_existing_extensions() { + let mut packet = packet_with_valid_extensions(); + assert_packet_pad_matches_opus(&mut packet, 20, 40); +} + +#[test] +fn packet_pad_matches_opus_internal_error_for_malformed_extensions() { + let mut packet = packet_with_malformed_extension_len(); + let mut c_packet = packet.clone(); + + let c_result = unsafe { opus_packet_pad(c_packet.as_mut_ptr(), 20, 40) }; + assert_eq!(c_result, OPUS_INTERNAL_ERROR); + assert_eq!(packet_pad(&mut packet, 20, 40), Err(Error::InternalError)); +} + +#[test] +fn packet_pad_matches_opus_for_short_id_two_extension_in_padding() { + let mut packet = packet_with_short_id_two_extension_in_padding(); + assert_packet_pad_matches_opus(&mut packet, 6, 10); +} + +#[cfg(opus_codec_frame_bounded_extensions)] +#[test] +fn packet_pad_matches_opus_for_trailing_repeat_with_empty_payloads() { + let payload = [0x7B, 0x45, 2, b'a', b'b', b'c', b'd', b'e', 0x12, 0x05]; + let mut packet = vec![0u8; 32]; + packet[..payload.len()].copy_from_slice(&payload); + let new_len = packet.len(); + assert_packet_pad_matches_opus(&mut packet, payload.len(), new_len); +} + +#[test] +fn packet_pad_matches_opus_randomized_repadding() { + let mut rng = StdRng::seed_from_u64(0x5EED_CAFE); + let frame_sizes = [120usize, 240, 480, 960, 1920, 2880]; + + for _ in 0..SINGLE_RANDOM_PACKET_CASES { + let frame_size = frame_sizes[usize_below(&mut rng, frame_sizes.len())]; + let mut encoder = + Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip).unwrap(); + let pcm: Vec = (0..frame_size).map(|_| i16_sample(&mut rng)).collect(); + let mut encoded_packet = [0u8; 1500]; + let encoded_len = encoder.encode(&pcm, &mut encoded_packet).unwrap(); + + let mut packet = vec![0u8; encoded_len + 1024]; + packet[..encoded_len].copy_from_slice(&encoded_packet[..encoded_len]); + let mut len = encoded_len; + + for _ in 0..SINGLE_RANDOM_PACKET_STEPS { + let remaining = packet.len() - len; + let growth = usize_below(&mut rng, remaining + 1); + let new_len = len + growth; + assert_packet_pad_matches_opus(&mut packet, len, new_len); + len = new_len; + } + } +} + +#[test] +fn packet_pad_matches_opus_randomized_existing_extensions() { + let mut rng = StdRng::seed_from_u64(0xA11C_E55E); + + for _ in 0..EXTENSION_PACKET_CASES { + let mut packet = vec![0u8; 160]; + let base = packet_with_valid_extensions(); + packet[..20].copy_from_slice(&base[..20]); + let mut len = 20usize; + + for _ in 0..EXTENSION_PACKET_STEPS { + let remaining = packet.len() - len; + let growth = usize_below(&mut rng, remaining + 1); + let new_len = len + growth; + assert_packet_pad_matches_opus(&mut packet, len, new_len); + len = new_len; + } + } +} + +#[test] +fn multistream_pad_rejects_invalid_stream_count() { + let mut packet = vec![0u8; 8]; + assert_eq!( + multistream_packet_pad(&mut packet, 1, 2, 0).unwrap_err(), + Error::BadArg + ); + assert_eq!( + multistream_packet_pad(&mut packet, 1, 2, i32::MIN).unwrap_err(), + Error::BadArg + ); +} + +#[test] +fn multistream_pad_rejects_zero_len_even_when_noop() { + let mut packet = [0u8; 1]; + let err = multistream_packet_pad(&mut packet, 0, 0, 1).unwrap_err(); + assert_eq!(err, Error::BadArg); +} + +#[test] +fn multistream_pad_matches_opus_for_existing_extensions() { + let first_stream = [0x00, 0x01, 0x00]; + let second_stream = &packet_with_valid_extensions()[..20]; + let mut packet = vec![0u8; 50]; + packet[..first_stream.len()].copy_from_slice(&first_stream); + packet[first_stream.len()..first_stream.len() + second_stream.len()] + .copy_from_slice(second_stream); + assert_multistream_packet_pad_matches_opus(&mut packet, 23, 50, 2); +} + +#[test] +fn multistream_pad_matches_opus_when_streams_exhaust_packet() { + let mut packet = [0x00, 0x00, 0x00]; + let mut c_packet = packet; + let c_result = + c_pad_result(unsafe { opus_multistream_packet_pad(c_packet.as_mut_ptr(), 2, 3, 2) }); + + assert_eq!(c_result, Err(Error::BadArg)); + assert_eq!(multistream_packet_pad(&mut packet, 2, 3, 2), c_result); +} + +#[test] +fn multistream_pad_matches_opus_internal_error_for_malformed_extensions() { + let mut packet = packet_with_malformed_extension_len(); + let mut c_packet = packet.clone(); + + let c_result = unsafe { opus_multistream_packet_pad(c_packet.as_mut_ptr(), 20, 40, 1) }; + assert_eq!(c_result, OPUS_INTERNAL_ERROR); + assert_eq!( + multistream_packet_pad(&mut packet, 20, 40, 1), + Err(Error::InternalError) + ); +} + +#[test] +fn multistream_unpad_rejects_invalid_stream_count() { + let mut packet = vec![0u8; 8]; + assert_eq!( + multistream_packet_unpad(&mut packet, 1, 0).unwrap_err(), + Error::BadArg + ); + assert_eq!( + multistream_packet_unpad(&mut packet, 1, i32::MIN).unwrap_err(), + Error::BadArg + ); +} + +#[test] +fn multistream_unpad_rejects_zero_len() { + let mut packet = [0u8; 1]; + let err = multistream_packet_unpad(&mut packet, 0, 1).unwrap_err(); + assert_eq!(err, Error::BadArg); +} + +#[test] +fn multistream_packet_pad_matches_opus_randomized_repadding() { + let mut rng = StdRng::seed_from_u64(0xC0DE_BAAD); + let frame_sizes = [120usize, 240, 480, 960]; + + for _ in 0..MULTISTREAM_PACKET_CASES { + let frame_size = frame_sizes[usize_below(&mut rng, frame_sizes.len())]; + let channels = 6usize; + let (mut encoder, _) = MultistreamEncoder::new_surround( + SampleRate::Hz48000, + channels as u8, + 1, + Application::Audio, + ) + .unwrap(); + let nb_streams = i32::from(encoder.streams()); + let pcm: Vec = (0..frame_size * channels) + .map(|_| i16_sample(&mut rng)) + .collect(); + let mut packet = vec![0u8; 4096]; + let len = encoder.encode(&pcm, frame_size, &mut packet).unwrap(); + let mut len = len; + + for _ in 0..MULTISTREAM_PACKET_STEPS { + let remaining = packet.len() - len; + let growth = usize_below(&mut rng, remaining + 1); + let new_len = len + growth; + assert_multistream_packet_pad_matches_opus(&mut packet, len, new_len, nb_streams); + len = new_len; + } + } +} diff --git a/tests/packet_utils.rs b/tests/packet_utils.rs index b95d28c..a48027e 100644 --- a/tests/packet_utils.rs +++ b/tests/packet_utils.rs @@ -1,8 +1,8 @@ use opus_codec::encoder::Encoder; use opus_codec::error::Error; use opus_codec::packet::{ - packet_bandwidth, packet_channels, packet_frame_count, packet_parse, packet_sample_count, - soft_clip, + packet_bandwidth, packet_channels, packet_frame_count, packet_parse, packet_parse_into, + packet_sample_count, soft_clip, }; use opus_codec::types::{Application, Bandwidth, Channels, SampleRate}; @@ -40,6 +40,24 @@ fn test_packet_parse_keeps_zero_length_frames() { assert!(frames[1].is_empty()); } +#[test] +fn test_packet_parse_into_uses_caller_storage() { + let packet = [0x02u8, 0x01, 0x00]; + let mut frames = [&[][..]; 2]; + let (toc, offset, count) = packet_parse_into(&packet, &mut frames).unwrap(); + assert_eq!(toc, packet[0]); + assert_eq!(offset, 2); + assert_eq!(count, 2); + assert_eq!(frames[0], &[0]); + assert!(frames[1].is_empty()); + + let mut too_small = [&[][..]; 1]; + assert_eq!( + packet_parse_into(&packet, &mut too_small), + Err(Error::BufferTooSmall) + ); +} + #[test] fn test_soft_clip_validations() { let mut pcm = vec![1.5f32; 4]; diff --git a/tests/projection.rs b/tests/projection.rs index 0eeb656..ba640f6 100644 --- a/tests/projection.rs +++ b/tests/projection.rs @@ -44,6 +44,17 @@ fn test_projection_ambisonics() { assert_eq!(decoded_len, frame_size); } +#[test] +fn test_projection_size_helpers_reject_invalid_args() { + assert_eq!(ProjectionEncoder::size(0, 3), Err(Error::BadArg)); + assert_eq!(ProjectionEncoder::size(4, 1), Err(Error::BadArg)); + + assert_eq!(ProjectionDecoder::size(0, 1, 0), Err(Error::BadArg)); + assert_eq!(ProjectionDecoder::size(4, 0, 0), Err(Error::BadArg)); + assert_eq!(ProjectionDecoder::size(4, 1, 2), Err(Error::BadArg)); + assert_eq!(ProjectionDecoder::size(255, 255, 1), Err(Error::BadArg)); +} + #[test] fn test_init_in_place_unowned_projection() { let sr = SampleRate::Hz48000; diff --git a/tests/raw_alloc.rs b/tests/raw_alloc.rs index 6d32ddd..aa84f31 100644 --- a/tests/raw_alloc.rs +++ b/tests/raw_alloc.rs @@ -1,5 +1,6 @@ use opus_codec::decoder::Decoder; use opus_codec::encoder::Encoder; +use opus_codec::error::Error; use opus_codec::multistream::{MultistreamDecoder, MultistreamEncoder}; use opus_codec::repacketizer::Repacketizer; use opus_codec::types::Channels; @@ -12,3 +13,14 @@ fn test_raw_size_helpers() { assert!(MultistreamDecoder::size(1, 0).unwrap() > 0); assert!(Repacketizer::size().unwrap() > 0); } + +#[test] +fn test_multistream_size_helpers_reject_invalid_stream_counts() { + assert_eq!(MultistreamEncoder::size(0, 0), Err(Error::BadArg)); + assert_eq!(MultistreamEncoder::size(1, 2), Err(Error::BadArg)); + assert_eq!(MultistreamEncoder::size(200, 100), Err(Error::BadArg)); + + assert_eq!(MultistreamDecoder::size(0, 0), Err(Error::BadArg)); + assert_eq!(MultistreamDecoder::size(1, 2), Err(Error::BadArg)); + assert_eq!(MultistreamDecoder::size(200, 100), Err(Error::BadArg)); +} diff --git a/tests/repacketizer.rs b/tests/repacketizer.rs index 4ab16e2..ef59105 100644 --- a/tests/repacketizer.rs +++ b/tests/repacketizer.rs @@ -1,9 +1,150 @@ use opus_codec::AlignedBuffer; use opus_codec::encoder::Encoder; use opus_codec::error::Error; -use opus_codec::packet::packet_frame_count; +#[cfg(not(opus_codec_rust_packet_ops))] +use opus_codec::opus_repacketizer_out_range; +use opus_codec::packet::{packet_frame_count, packet_unpad}; use opus_codec::repacketizer::{Repacketizer, RepacketizerRef}; use opus_codec::types::{Application, Channels, SampleRate}; +use opus_codec::{ + OpusRepacketizer, opus_repacketizer_cat, opus_repacketizer_create, opus_repacketizer_destroy, + opus_repacketizer_get_nb_frames, opus_repacketizer_out, +}; + +const TOC_CONFIG: u8 = 0x78; +const PACKET_CODE_1: u8 = 0x01; +const PACKET_CODE_3: u8 = 0x03; +const CODE_3_PADDING_FLAG: u8 = 0x40; +#[cfg(opus_codec_rust_packet_ops)] +const FRAME_SEPARATOR_TO_NEXT: u8 = 0x02; +const FRAME_SEPARATOR_WITH_DELTA: u8 = 0x03; +const EXTENSION_ID_5_WITH_ONE_BYTE: u8 = 0x0B; +const EXTENSION_ID_33_WITH_MISSING_LENGTH_END: u8 = 0x43; + +fn single_frame_packet(payload: u8) -> [u8; 2] { + [TOC_CONFIG, payload] +} + +fn three_frame_packet_with_extension_on_last_frame() -> [u8; 10] { + const FRAME_COUNT: u8 = 3; + const PADDING_LEN: u8 = 4; + const LAST_FRAME_INDEX: u8 = 2; + const EXTENSION_PAYLOAD: u8 = b'x'; + + [ + TOC_CONFIG | PACKET_CODE_3, + CODE_3_PADDING_FLAG | FRAME_COUNT, + PADDING_LEN, + b'a', + b'b', + b'c', + FRAME_SEPARATOR_WITH_DELTA, + LAST_FRAME_INDEX, + EXTENSION_ID_5_WITH_ONE_BYTE, + EXTENSION_PAYLOAD, + ] +} + +#[cfg(opus_codec_frame_bounded_extensions)] +fn three_frame_packet_with_repeated_extension_payloads() -> [u8; 11] { + [ + TOC_CONFIG | PACKET_CODE_3, + CODE_3_PADDING_FLAG | 3, + 5, + b'a', + b'b', + b'c', + EXTENSION_ID_5_WITH_ONE_BYTE, + b'x', + 0x04, + b'y', + b'z', + ] +} + +#[cfg(opus_codec_frame_bounded_extensions)] +fn five_frame_packet_with_repeated_empty_extension() -> [u8; 10] { + [ + TOC_CONFIG | PACKET_CODE_3, + CODE_3_PADDING_FLAG | 5, + 2, + b'a', + b'b', + b'c', + b'd', + b'e', + 0x12, + 0x05, + ] +} + +fn two_frame_packet_with_malformed_extension_on_first_frame() -> [u8; 7] { + const FRAME_COUNT: u8 = 2; + const PADDING_LEN: u8 = 2; + + [ + TOC_CONFIG | PACKET_CODE_3, + CODE_3_PADDING_FLAG | FRAME_COUNT, + PADDING_LEN, + b'a', + b'b', + EXTENSION_ID_33_WITH_MISSING_LENGTH_END, + 255, + ] +} + +fn raw_repacketizer_emit(packets: &[&[u8]]) -> Result, Error> { + let rp = unsafe { opus_repacketizer_create() }; + assert!(!rp.is_null()); + + for packet in packets { + let result = unsafe { opus_repacketizer_cat(rp, packet.as_ptr(), packet.len() as i32) }; + assert_eq!(result, 0); + } + + let mut out = [0u8; 128]; + let len = unsafe { opus_repacketizer_out(rp, out.as_mut_ptr(), out.len() as i32) }; + unsafe { opus_repacketizer_destroy(rp) }; + if len < 0 { + return Err(Error::from_code(len)); + } + Ok(out[..usize::try_from(len).unwrap()].to_vec()) +} + +#[cfg(not(opus_codec_rust_packet_ops))] +fn raw_repacketizer_emit_range(packets: &[&[u8]], begin: i32, end: i32) -> Result, Error> { + let rp = unsafe { opus_repacketizer_create() }; + assert!(!rp.is_null()); + + for packet in packets { + let result = unsafe { opus_repacketizer_cat(rp, packet.as_ptr(), packet.len() as i32) }; + assert_eq!(result, 0); + } + + let mut out = [0u8; 128]; + let len = + unsafe { opus_repacketizer_out_range(rp, begin, end, out.as_mut_ptr(), out.len() as i32) }; + unsafe { opus_repacketizer_destroy(rp) }; + if len < 0 { + return Err(Error::from_code(len)); + } + Ok(out[..usize::try_from(len).unwrap()].to_vec()) +} + +#[cfg(not(opus_codec_rust_packet_ops))] +fn assert_repacketizer_result_matches_raw( + actual: Result, + out: &[u8], + expected: Result, Error>, +) { + match expected { + Ok(expected) => { + let actual_len = actual.unwrap(); + assert_eq!(&out[..actual_len], expected.as_slice()); + } + Err(expected) => assert_eq!(actual.unwrap_err(), expected), + } +} #[test] fn test_repacketizer() { @@ -81,3 +222,401 @@ fn test_repacketizer_owns_packet_data() { let out_len = rp.emit(&mut out).unwrap(); assert_eq!(&out[..out_len], original.as_slice()); } + +#[test] +fn test_repacketizer_accepts_owned_packet_without_changing_output() { + let packet = single_frame_packet(b'x').to_vec(); + let expected = packet.clone(); + let mut rp = Repacketizer::new().unwrap(); + rp.push_owned(packet).unwrap(); + + let mut out = [0u8; 16]; + let out_len = rp.emit(&mut out).unwrap(); + assert_eq!(&out[..out_len], expected.as_slice()); +} + +#[test] +fn test_repacketizer_emit_handles_padding_extensions() { + let packet = [ + 0x7B, 0x41, 16, 0x00, 67, 7, b'a', b'b', b'c', b'd', b'e', b'f', b'g', 200, b'u', b'v', + b'w', b'x', b'y', b'z', + ]; + let mut rp = Repacketizer::new().unwrap(); + rp.push(&packet).unwrap(); + + let mut out = [0u8; 64]; + let out_len = rp.emit(&mut out).unwrap(); + assert_eq!(packet_frame_count(&out[..out_len]).unwrap(), 1); + assert_eq!(&out[..out_len], packet); + + let unpadded_len = packet_unpad(&mut out, out_len).unwrap(); + assert!(unpadded_len > 0); +} + +#[test] +fn test_repacketizer_empty_state_rejects_emit() { + let mut rp = Repacketizer::new().unwrap(); + assert!(rp.is_empty()); + + let mut out = [0u8; 8]; + assert_eq!(rp.emit(&mut out).unwrap_err(), Error::BadArg); + assert_eq!(rp.emit_range(0, 1, &mut out).unwrap_err(), Error::BadArg); + + let mut empty = []; + assert_eq!(rp.emit(&mut empty).unwrap_err(), Error::BadArg); + assert_eq!(rp.emit_range(0, 1, &mut empty).unwrap_err(), Error::BadArg); +} + +#[test] +fn test_repacketizer_emit_range_validates_bounds() { + let mut rp = Repacketizer::new().unwrap(); + let packet_a = single_frame_packet(b'a'); + let packet_b = single_frame_packet(b'b'); + rp.push(&packet_a).unwrap(); + rp.push(&packet_b).unwrap(); + + let mut out = [0u8; 8]; + assert_eq!(rp.emit_range(-1, 1, &mut out).unwrap_err(), Error::BadArg); + assert_eq!(rp.emit_range(1, 1, &mut out).unwrap_err(), Error::BadArg); + assert_eq!(rp.emit_range(2, 1, &mut out).unwrap_err(), Error::BadArg); + assert_eq!(rp.emit_range(0, 3, &mut out).unwrap_err(), Error::BadArg); +} + +#[test] +fn test_repacketizer_emit_reports_buffer_too_small() { + let mut rp = Repacketizer::new().unwrap(); + let packet = single_frame_packet(b'a'); + rp.push(&packet).unwrap(); + + let mut tiny = [0u8; 1]; + assert_eq!(rp.emit(&mut tiny).unwrap_err(), Error::BufferTooSmall); + assert_eq!( + rp.emit_range(0, 1, &mut tiny).unwrap_err(), + Error::BufferTooSmall + ); + + let mut empty = []; + assert_eq!(rp.emit(&mut empty).unwrap_err(), Error::BadArg); + assert_eq!(rp.emit_range(0, 1, &mut empty).unwrap_err(), Error::BadArg); +} + +#[test] +fn test_repacketizer_emit_range_outputs_exact_frame_subset() { + let mut rp = Repacketizer::new().unwrap(); + let packet_a = single_frame_packet(b'a'); + let packet_b = single_frame_packet(b'b'); + let packet_c = single_frame_packet(b'c'); + rp.push(&packet_a).unwrap(); + rp.push(&packet_b).unwrap(); + rp.push(&packet_c).unwrap(); + + let mut out = [0u8; 16]; + let out_len = rp.emit(&mut out).unwrap(); + assert_eq!( + &out[..out_len], + &[TOC_CONFIG | PACKET_CODE_3, 3, b'a', b'b', b'c'] + ); + + let out_len = rp.emit_range(1, 2, &mut out).unwrap(); + assert_eq!(&out[..out_len], &[TOC_CONFIG, b'b']); + + let out_len = rp.emit_range(1, 3, &mut out).unwrap(); + assert_eq!(&out[..out_len], &[TOC_CONFIG | PACKET_CODE_1, b'b', b'c']); +} + +#[cfg(opus_codec_frame_bounded_extensions)] +#[test] +fn test_repacketizer_emit_handles_repeated_extensions() { + let packet = three_frame_packet_with_repeated_extension_payloads(); + let mut rp = Repacketizer::new().unwrap(); + rp.push(&packet).unwrap(); + + let mut out = [0u8; 32]; + let out_len = rp.emit(&mut out).unwrap(); + assert_eq!(&out[..out_len], packet); + + let out_len = rp.emit_range(1, 3, &mut out).unwrap(); + assert_eq!( + &out[..out_len], + &[ + TOC_CONFIG | PACKET_CODE_3, + CODE_3_PADDING_FLAG | 2, + 4, + b'b', + b'c', + EXTENSION_ID_5_WITH_ONE_BYTE, + b'y', + 0x04, + b'z', + ] + ); +} + +#[cfg(opus_codec_frame_bounded_extensions)] +#[test] +fn test_repacketizer_emit_handles_trailing_repeat_with_empty_payloads() { + let packet = five_frame_packet_with_repeated_empty_extension(); + let mut rp = Repacketizer::new().unwrap(); + rp.push(&packet).unwrap(); + + let mut out = [0u8; 32]; + let out_len = rp.emit(&mut out).unwrap(); + assert_eq!( + &out[..out_len], + &[ + TOC_CONFIG | PACKET_CODE_3, + CODE_3_PADDING_FLAG | 5, + 2, + b'a', + b'b', + b'c', + b'd', + b'e', + 0x12, + 0x04, + ] + ); + + let out_len = rp.emit_range(1, 5, &mut out).unwrap(); + assert_eq!( + &out[..out_len], + &[ + TOC_CONFIG | PACKET_CODE_3, + CODE_3_PADDING_FLAG | 4, + 2, + b'b', + b'c', + b'd', + b'e', + 0x12, + 0x04, + ] + ); +} + +#[test] +fn test_repacketizer_emit_range_filters_and_reindexes_extensions() { + let mut rp = Repacketizer::new().unwrap(); + let packet = three_frame_packet_with_extension_on_last_frame(); + rp.push(&packet).unwrap(); + + let mut out = [0u8; 32]; + #[cfg(opus_codec_rust_packet_ops)] + { + let out_len = rp.emit(&mut out).unwrap(); + assert_eq!(&out[..out_len], packet); + + let out_len = rp.emit_range(0, 1, &mut out).unwrap(); + assert_eq!(&out[..out_len], &[TOC_CONFIG, b'a']); + + let out_len = rp.emit_range(1, 3, &mut out).unwrap(); + assert_eq!( + &out[..out_len], + &[ + TOC_CONFIG | PACKET_CODE_3, + CODE_3_PADDING_FLAG | 2, + 3, + b'b', + b'c', + FRAME_SEPARATOR_TO_NEXT, + EXTENSION_ID_5_WITH_ONE_BYTE, + b'x', + ] + ); + + let out_len = rp.emit_range(2, 3, &mut out).unwrap(); + assert_eq!( + &out[..out_len], + &[ + TOC_CONFIG | PACKET_CODE_3, + CODE_3_PADDING_FLAG | 1, + 2, + b'c', + EXTENSION_ID_5_WITH_ONE_BYTE, + b'x', + ] + ); + } + #[cfg(not(opus_codec_rust_packet_ops))] + { + assert_repacketizer_result_matches_raw( + rp.emit(&mut out), + &out, + raw_repacketizer_emit(&[&packet]), + ); + + assert_repacketizer_result_matches_raw( + rp.emit_range(0, 1, &mut out), + &out, + raw_repacketizer_emit_range(&[&packet], 0, 1), + ); + + assert_repacketizer_result_matches_raw( + rp.emit_range(1, 3, &mut out), + &out, + raw_repacketizer_emit_range(&[&packet], 1, 3), + ); + + assert_repacketizer_result_matches_raw( + rp.emit_range(2, 3, &mut out), + &out, + raw_repacketizer_emit_range(&[&packet], 2, 3), + ); + } +} + +#[test] +fn test_repacketizer_emit_range_ignores_malformed_extensions_outside_range() { + let mut rp = Repacketizer::new().unwrap(); + let packet = two_frame_packet_with_malformed_extension_on_first_frame(); + rp.push(&packet).unwrap(); + + let mut out = [0u8; 8]; + assert_eq!( + rp.emit_range(0, 1, &mut out).unwrap_err(), + Error::InternalError + ); + + let out_len = rp.emit_range(1, 2, &mut out).unwrap(); + assert_eq!(&out[..out_len], &[TOC_CONFIG, b'b']); +} + +#[test] +fn test_repacketizer_emit_sorts_extensions_by_output_frame() { + let future_frame_extension = [ + TOC_CONFIG | PACKET_CODE_3, + CODE_3_PADDING_FLAG | 1, + 4, + b'a', + FRAME_SEPARATOR_WITH_DELTA, + 2, + EXTENSION_ID_5_WITH_ONE_BYTE, + b'x', + ]; + let current_frame_extension = [ + TOC_CONFIG | PACKET_CODE_3, + CODE_3_PADDING_FLAG | 1, + 2, + b'b', + EXTENSION_ID_5_WITH_ONE_BYTE, + b'y', + ]; + let plain_packet = single_frame_packet(b'c'); + let expected = raw_repacketizer_emit(&[ + &future_frame_extension, + ¤t_frame_extension, + &plain_packet, + ]); + + let mut rp = Repacketizer::new().unwrap(); + rp.push(&future_frame_extension).unwrap(); + rp.push(¤t_frame_extension).unwrap(); + rp.push(&plain_packet).unwrap(); + + let mut out = [0u8; 128]; + match expected { + Ok(expected) => { + let out_len = rp.emit(&mut out).unwrap(); + assert_eq!(&out[..out_len], expected.as_slice()); + } + Err(expected) => { + assert_eq!(rp.emit(&mut out).unwrap_err(), expected); + } + } +} + +#[test] +fn test_repacketizer_reset_clears_state_and_allows_reuse() { + let mut rp = Repacketizer::new().unwrap(); + let packet_a = single_frame_packet(b'a'); + let packet_b = single_frame_packet(b'b'); + rp.push(&packet_a).unwrap(); + rp.push(&packet_b).unwrap(); + assert_eq!(rp.len(), 2); + + rp.reset(); + assert!(rp.is_empty()); + let mut out = [0u8; 8]; + assert_eq!(rp.emit(&mut out).unwrap_err(), Error::BadArg); + + rp.push(&packet_b).unwrap(); + let out_len = rp.emit(&mut out).unwrap(); + assert_eq!(&out[..out_len], packet_b); +} + +#[test] +fn test_repacketizer_failed_push_does_not_change_state() { + let mut rp = Repacketizer::new().unwrap(); + let packet = single_frame_packet(b'a'); + let incompatible = [0x00, b'z']; + rp.push(&packet).unwrap(); + + assert_eq!(rp.push(&incompatible).unwrap_err(), Error::InvalidPacket); + assert_eq!(rp.len(), 1); + + let mut out = [0u8; 8]; + let out_len = rp.emit(&mut out).unwrap(); + assert_eq!(&out[..out_len], packet); +} + +#[test] +fn test_repacketizer_ref_init_in_rejects_small_buffer() { + let mut buf = AlignedBuffer::with_capacity_bytes(0); + assert!(matches!( + RepacketizerRef::init_in(&mut buf), + Err(Error::BadArg) + )); +} + +#[test] +fn test_init_in_place_rejects_misaligned_pointer() { + let required = Repacketizer::size().unwrap(); + let mut storage = vec![0u8; required + std::mem::align_of::()]; + let ptr = unsafe { storage.as_mut_ptr().add(1).cast::() }; + + let err = unsafe { Repacketizer::init_in_place(ptr) }.unwrap_err(); + assert_eq!(err, Error::BadArg); +} + +#[test] +fn test_repacketizer_ref_drop_resets_external_state() { + let rp_size = Repacketizer::size().unwrap(); + let mut rp_buf = AlignedBuffer::with_capacity_bytes(rp_size); + let rp_ptr = rp_buf.as_mut_ptr(); + unsafe { + Repacketizer::init_in_place(rp_ptr).unwrap(); + } + + { + let packet = single_frame_packet(b'a'); + let mut rp = unsafe { RepacketizerRef::from_raw(rp_ptr) }; + rp.push(&packet).unwrap(); + assert_eq!(unsafe { opus_repacketizer_get_nb_frames(rp_ptr) }, 1); + } + + assert_eq!(unsafe { opus_repacketizer_get_nb_frames(rp_ptr) }, 0); +} + +#[test] +fn test_repacketizer_ref_can_emit_prepopulated_raw_state() { + let rp_ptr = unsafe { opus_repacketizer_create() }; + assert!(!rp_ptr.is_null()); + let packet = single_frame_packet(b'a'); + let result = unsafe { opus_repacketizer_cat(rp_ptr, packet.as_ptr(), packet.len() as i32) }; + assert_eq!(result, 0); + + { + let mut rp = unsafe { RepacketizerRef::from_raw(rp_ptr) }; + assert_eq!(rp.len(), 1); + + let mut out = [0u8; 8]; + let out_len = rp.emit(&mut out).unwrap(); + assert_eq!(&out[..out_len], packet); + + let out_len = rp.emit_range(0, 1, &mut out).unwrap(); + assert_eq!(&out[..out_len], packet); + } + + assert_eq!(unsafe { opus_repacketizer_get_nb_frames(rp_ptr) }, 1); + unsafe { opus_repacketizer_destroy(rp_ptr) }; +}