diff --git a/.github/workflows/bench-advisory.yml b/.github/workflows/bench-advisory.yml new file mode 100644 index 0000000..265e698 --- /dev/null +++ b/.github/workflows/bench-advisory.yml @@ -0,0 +1,99 @@ +name: bench-advisory + +# SDD-120 §6 / SRS-218 — advisory-only realtime-audio bench workflow. +# Runs the criterion bench harness on PR + push events, compares the +# current results against the SAD-089 baseline JSON resolved at the +# merge-base, and renders a markdown report posted (or updated) as a +# single sticky PR comment. The job status is ALWAYS success — this +# workflow never fails a check on regression (SRS-218 clause 4). + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [product/scaffold-v0] + +permissions: + pull-requests: write + contents: read + +jobs: + bench-advisory: + name: bench-advisory + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: System deps (cpal / Opus) + run: | + sudo apt-get update + sudo apt-get install -y \ + libasound2-dev libpulse-dev pkg-config \ + libopus-dev + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run benchmarks + run: | + cargo bench -p chanora_audio \ + --bench realtime_capture \ + --bench opus_codec \ + --bench resampler + - name: Emit current baseline JSON + run: cargo run --example emit_baseline -p chanora_audio + - name: Resolve merge-base baseline + run: | + DEFAULT_BRANCH="${{ github.event.repository.default_branch }}" + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + MERGE_BASE=$(git merge-base "$BASE_SHA" HEAD || true) + else + MERGE_BASE=$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD || true) + fi + if [ -n "$MERGE_BASE" ] && git show "${MERGE_BASE}:crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json" > baseline.json 2>/dev/null; then + echo "BASELINE_FOUND=1" >> "$GITHUB_ENV" + else + echo "BASELINE_FOUND=0" >> "$GITHUB_ENV" + echo "{}" > baseline.json + fi + - name: Compare against baseline + run: | + cargo run --example compare_baseline -p chanora_audio -- \ + --current current.json \ + --baseline baseline.json \ + --output report.md + - name: Post PR comment + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('report.md', 'utf8'); + const marker = ''; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body && c.body.startsWith(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + - name: Upload report artifact (push events) + if: github.event_name == 'push' + uses: actions/upload-artifact@v4 + with: + name: bench-report + path: report.md diff --git a/.github/workflows/bench-baseline-update.yml b/.github/workflows/bench-baseline-update.yml new file mode 100644 index 0000000..cdc04b2 --- /dev/null +++ b/.github/workflows/bench-baseline-update.yml @@ -0,0 +1,53 @@ +name: bench-baseline-update + +# SDD-120 §7 / SAD-089 — manual-dispatch workflow that opens a PR +# updating the committed baseline JSON. This is the SOLE writer of +# `crates/chanora_audio/benches/baselines/.json`. Triggered +# only on operator demand via workflow_dispatch. + +on: + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + bench-baseline-update: + name: bench-baseline-update + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: System deps (cpal / Opus) + run: | + sudo apt-get update + sudo apt-get install -y \ + libasound2-dev libpulse-dev pkg-config \ + libopus-dev + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run benchmarks + run: | + cargo bench -p chanora_audio \ + --bench realtime_capture \ + --bench opus_codec \ + --bench resampler + - name: Emit current baseline JSON + run: cargo run --example emit_baseline -p chanora_audio + - name: Copy current to baseline path + run: cp current.json crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json + - name: Create pull request + uses: peter-evans/create-pull-request@v6 + with: + branch: bench/baseline-update-${{ github.run_id }} + base: product/scaffold-v0 + title: "chore(bench): update realtime audio baselines" + body: | + Automated baseline update produced by manual dispatch of + `bench-baseline-update.yml`. + + Run: ${{ github.run_id }} + Triggered by: ${{ github.actor }} + commit-message: "chore(bench): update realtime audio baselines" + add-paths: | + crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json diff --git a/Cargo.lock b/Cargo.lock index 63160e6..a9481b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,18 @@ dependencies = [ "log", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.102" @@ -317,6 +329,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.62" @@ -390,6 +408,8 @@ dependencies = [ "chanora_protocol", "coreaudio-rs", "cpal", + "criterion", + "dhat", "futures-util", "jni 0.21.1", "ndk-context", @@ -397,6 +417,7 @@ dependencies = [ "rand 0.8.6", "reqwest", "sdl2", + "serde_json", "thiserror 2.0.18", "tokio", "tracing", @@ -490,6 +511,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -501,6 +549,31 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmac" version = "0.7.2" @@ -644,6 +717,42 @@ dependencies = [ "libc", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -659,6 +768,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -674,6 +793,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -836,6 +961,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "dhat" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cd11d84628e233de0ce467de10b8633f4ddaecafadefc86e13b84b8739b827" +dependencies = [ + "backtrace", + "lazy_static", + "mintex", + "parking_lot", + "rustc-hash 1.1.0", + "serde", + "serde_json", + "thousands", +] + [[package]] name = "digest" version = "0.9.0" @@ -1350,6 +1491,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1755,6 +1907,26 @@ dependencies = [ "serde", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2027,6 +2199,12 @@ dependencies = [ "adler2", ] +[[package]] +name = "mintex" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c505b3e17ed6b70a7ed2e67fbb2c560ee327353556120d6e72f5232b6880d536" + [[package]] name = "mio" version = "1.2.0" @@ -2334,6 +2512,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -2488,6 +2672,34 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -2599,7 +2811,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "socket2", "thiserror 2.0.18", @@ -2620,7 +2832,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", @@ -2741,6 +2953,26 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2890,6 +3122,12 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3435,6 +3673,12 @@ dependencies = [ "syn", ] +[[package]] +name = "thousands" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" + [[package]] name = "thread_local" version = "1.1.9" @@ -3494,6 +3738,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -3762,7 +4016,7 @@ source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917a dependencies = [ "base64", "heck", - "itertools", + "itertools 0.14.0", "num-derive", "num-traits", "serde", @@ -3786,7 +4040,7 @@ dependencies = [ "git-testament", "hickory-net", "hickory-resolver", - "itertools", + "itertools 0.14.0", "num-traits", "pin-utils", "rand 0.10.1", diff --git a/crates/chanora_audio/Cargo.toml b/crates/chanora_audio/Cargo.toml index fa0c5d9..b396ab5 100644 --- a/crates/chanora_audio/Cargo.toml +++ b/crates/chanora_audio/Cargo.toml @@ -90,6 +90,34 @@ tokio = { version = "1", features = ["sync", "rt", "macros", "time", "test-util" # Cross-platform recording Layer for the SDD-090 / DEC-027 privacy # invariant integration test (`tests/ptt_privacy.rs`). tracing-subscriber = { version = "0.3", features = ["registry"] } +# SDD-120 §3 — criterion bench harness (realtime_capture / opus_codec / +# resampler). `harness = false` per bench entry below disables the +# default libtest harness so criterion can install its own. +criterion = "0.5" +# SDD-120 §3 item 1 — dhat is used as the global allocator inside +# `benches/realtime_capture.rs` to count post-warmup heap allocations +# on the realtime capture path. Dev-dep only — does NOT affect +# production builds. +dhat = "0.3" +# SDD-120 §5 / §8 — JSON serialization for `emit_baseline` / +# `compare_baseline` binaries that consume criterion's per-bench +# `estimates.json` outputs and emit the SRS-217 baseline schema. +serde_json = "1" + +[[bench]] +name = "realtime_capture" +harness = false +path = "benches/realtime_capture.rs" + +[[bench]] +name = "opus_codec" +harness = false +path = "benches/opus_codec.rs" + +[[bench]] +name = "resampler" +harness = false +path = "benches/resampler.rs" [target.'cfg(target_os = "linux")'.dependencies] # GNOME-on-Wayland Global Push-to-Talk uses the freedesktop diff --git a/crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json b/crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json @@ -0,0 +1 @@ +{} diff --git a/crates/chanora_audio/benches/common.rs b/crates/chanora_audio/benches/common.rs new file mode 100644 index 0000000..e95f2f0 --- /dev/null +++ b/crates/chanora_audio/benches/common.rs @@ -0,0 +1,57 @@ +// SDD-120 §4 — deterministic synthetic input generation. +// +// Shared by the three bench files (`realtime_capture.rs`, +// `opus_codec.rs`, `resampler.rs`). Included via `mod common;` in +// each bench file because criterion bench files compile as +// independent binaries — there is no shared crate boundary between +// them and `common.rs` is NOT registered as a `[[bench]]` entry. +// +// Determinism is load-bearing for §5 baseline stability: a 440 Hz +// sine at amplitude 0.5 over the 48 kHz mixer rate, no RNG, no +// fade-in, no DC offset. Same input bytes across runs and hosts. +#![allow(dead_code)] + +use std::f32::consts::PI; + +/// Frame count of one 20 ms Opus frame at 48 kHz mono — matches +/// `chanora_audio::engine::FRAME_SAMPLES` (private constant; the +/// value `960` is fixed by the Opus codec protocol and SAD-088). +pub const FRAME_SAMPLES: usize = 960; + +/// 48 kHz mixer rate — matches `chanora_audio::engine::SAMPLE_RATE`. +pub const SAMPLE_RATE: u32 = 48_000; + +/// Interleaved synthetic capture buffer. For multi-channel layouts +/// the same scalar value is replicated across each frame's channels +/// (matches cpal's interleaved `Stream` data layout). +pub fn synthetic_capture_buffer(frames: usize, channels: usize) -> Vec { + let mut out = Vec::with_capacity(frames * channels); + for n in 0..frames { + let s = 0.5 * (2.0 * PI * 440.0 * (n as f32) / 48_000.0).sin(); + for _ in 0..channels { + out.push(s); + } + } + out +} + +/// One Opus-frame-sized synthetic PCM buffer (960 samples mono). +pub fn synthetic_opus_frame() -> Vec { + synthetic_capture_buffer(FRAME_SAMPLES, 1) +} + +/// Synthetic encoded Opus bytes for decode benches. Built by +/// encoding `synthetic_opus_frame()` with a one-shot encoder. +pub fn synthetic_opus_bytes() -> Vec { + use audiopus::coder::Encoder; + use audiopus::{Application, Channels, SampleRate}; + let enc = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip) + .expect("opus encoder init"); + let pcm = synthetic_opus_frame(); + let mut out = vec![0u8; 1275]; // MAX_OPUS_FRAME + let len = enc + .encode_float(&pcm, &mut out) + .expect("opus encode synthetic frame"); + out.truncate(len); + out +} diff --git a/crates/chanora_audio/benches/opus_codec.rs b/crates/chanora_audio/benches/opus_codec.rs new file mode 100644 index 0000000..c792b03 --- /dev/null +++ b/crates/chanora_audio/benches/opus_codec.rs @@ -0,0 +1,53 @@ +// SDD-120 §3 items 3-4 — Opus encode/decode latency benches. +// +// Both benches measure the codec-level call directly (audiopus +// Encoder::encode_float / Decoder::decode_float) and not the +// composite tsclientlib AudioHandler::fill_buffer call. Rationale +// per SDD-120 §3 item 4: AudioHandler::fill_buffer conflates +// jitter-buffer dequeue + Opus decode + PCM mix in a single call +// and the wall-clock measurement would conflate three distinct +// concerns. The canonical `opus_decode_latency` metric here is +// codec-only. + +use audiopus::coder::{Decoder, Encoder}; +use audiopus::packet::Packet; +use audiopus::{Application, Channels, MutSignals, SampleRate}; +use std::convert::TryFrom; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +mod common; +use common::{synthetic_opus_bytes, synthetic_opus_frame}; + +fn bench_opus_encode_latency(c: &mut Criterion) { + let mut enc = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip) + .expect("opus encoder init"); + let pcm = synthetic_opus_frame(); + let mut out = vec![0u8; 1275]; + + c.bench_function("opus_encode_latency", |b| { + b.iter(|| { + let len = enc + .encode_float(black_box(&pcm[..]), &mut out[..]) + .expect("encode"); + black_box(len); + }); + }); +} + +fn bench_opus_decode_latency(c: &mut Criterion) { + let mut dec = Decoder::new(SampleRate::Hz48000, Channels::Mono).expect("opus decoder init"); + let bytes = synthetic_opus_bytes(); + let mut pcm_out = vec![0.0f32; 960]; + + c.bench_function("opus_decode_latency", |b| { + b.iter(|| { + let input = Packet::try_from(black_box(&bytes[..])).expect("packet"); + let output = MutSignals::try_from(&mut pcm_out[..]).expect("signals"); + let n = dec.decode_float(Some(input), output, false).expect("decode"); + black_box(n); + }); + }); +} + +criterion_group!(opus_codec, bench_opus_encode_latency, bench_opus_decode_latency); +criterion_main!(opus_codec); diff --git a/crates/chanora_audio/benches/realtime_capture.rs b/crates/chanora_audio/benches/realtime_capture.rs new file mode 100644 index 0000000..b030c53 --- /dev/null +++ b/crates/chanora_audio/benches/realtime_capture.rs @@ -0,0 +1,118 @@ +// SDD-120 §3 items 1-2 — realtime capture bench harness. +// +// - `bench_capture_alloc_count`: dhat-backed heap-allocation count +// across 1000 post-warmup `CaptureState::ingest` calls. Realizes +// the SRS-219 clause-a zero-allocation invariant. Local-developer +// surface additionally asserts that the post-warmup count is 0 +// so a regression hard-fails locally; the CI advisory comparator +// in `compare_baseline.rs` carries the same `tolerance = 0` rule +// independently. +// - `bench_capture_callback_wall_clock`: criterion default-warmup +// wall-clock bench of `ingest` on the same synthetic buffer. +// +// dhat is invasive — it replaces the global allocator for the +// whole bench binary, but only this bench binary; production +// builds and other benches are unaffected per Cargo's per-bench +// compilation model. + +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +use chanora_audio::bench_seam::CaptureBenchHandle; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +mod common; +use common::{synthetic_capture_buffer, FRAME_SAMPLES}; + +fn bench_capture_alloc_count(c: &mut Criterion) { + // Build the dhat profiler in test mode so it is process-local + // and does not write a JSON heap-dump file. Held for the + // duration of this bench function. + let _profiler = dhat::Profiler::builder().testing().build(); + + let mut handle = CaptureBenchHandle::new(48_000, 1); + let buf = synthetic_capture_buffer(FRAME_SAMPLES, 1); + + // 100-call warm-up to prime first-call allocations (ring + // growth, opus encoder lazy state, mono_scratch / pcm_accum + // capacity). SDD-120 §3 item 1. + for _ in 0..100 { + handle.ingest_f32(&buf); + } + + let stats_warm = dhat::HeapStats::get(); + let blocks_warm = stats_warm.total_blocks; + + // 1000 post-warmup calls — measurement window. + for _ in 0..1000 { + handle.ingest_f32(&buf); + } + + let stats_final = dhat::HeapStats::get(); + let blocks_final = stats_final.total_blocks; + let delta = blocks_final - blocks_warm; + + // Local-developer surface: hard-fail on any regression. + // The CI advisory comparator carries the same rule with a + // markdown 🔴 marker on regression instead of a panic. + assert_eq!( + delta, 0, + "post-warmup heap allocation regression: {} blocks (SRS-219 clause a)", + delta + ); + + // Register the metric value with criterion so it appears in + // `target/criterion/.../estimates.json` for the §5 emitter to + // pick up. We bench a no-op closure here because the actual + // measurement is the delta computed above; criterion's + // function-time-mean is uninteresting for an alloc-count + // metric. The §5 emitter reads the `capture_alloc_count` + // metric value out of band via a sidecar file written below. + c.bench_function("capture_alloc_count", |b| { + b.iter(|| { + black_box(delta); + }); + }); + + // Sidecar file for §5 emit_baseline: criterion's own + // estimates.json carries the no-op closure timing, NOT the + // alloc count, so we write the canonical alloc-count value + // here and the emitter reads it directly. + if let Ok(dir) = std::env::var("CARGO_TARGET_DIR") + .map(std::path::PathBuf::from) + .or_else(|_| { + std::env::current_dir().map(|d| d.join("target")) + }) + { + let path = dir.join("criterion").join("capture_alloc_count.sidecar"); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&path, format!("{}", delta)); + } +} + +fn bench_capture_callback_wall_clock(c: &mut Criterion) { + let mut handle = CaptureBenchHandle::new(48_000, 1); + let buf = synthetic_capture_buffer(FRAME_SAMPLES, 1); + + // Pre-warm so first-call allocations do not skew the + // measurement window (criterion's default warmup is 3 s which + // is more than enough; this loop is belt-and-braces). + for _ in 0..100 { + handle.ingest_f32(&buf); + } + + c.bench_function("capture_callback_wall_clock", |b| { + b.iter(|| { + handle.ingest_f32(black_box(&buf)); + }); + }); +} + +criterion_group!( + realtime_capture, + bench_capture_alloc_count, + bench_capture_callback_wall_clock +); +criterion_main!(realtime_capture); diff --git a/crates/chanora_audio/benches/resampler.rs b/crates/chanora_audio/benches/resampler.rs new file mode 100644 index 0000000..2fce3d0 --- /dev/null +++ b/crates/chanora_audio/benches/resampler.rs @@ -0,0 +1,43 @@ +// SDD-120 §3 item 5 — resampler throughput bench. +// +// The chanora_audio engine uses a hand-rolled linear resampler +// inside CaptureState (see engine.rs `resample_into_accum`); there +// is no rubato dependency. The bench drives the same code path +// via the §3 bench seam by instantiating CaptureBenchHandle at +// different `in_sample_rate` values (44_100, 16_000, 48_000) and +// feeding a 1-second buffer per iteration. Throughput is reported +// as samples/sec via criterion's `Throughput::Elements`. + +use chanora_audio::bench_seam::CaptureBenchHandle; +use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; + +mod common; +use common::synthetic_capture_buffer; + +fn bench_resampler_throughput(c: &mut Criterion) { + let mut group = c.benchmark_group("resampler_throughput"); + + for (label, in_rate, frames) in [ + ("44100_to_48000", 44_100u32, 44_100usize), + ("16000_to_48000", 16_000u32, 16_000usize), + ("48000_passthrough", 48_000u32, 48_000usize), + ] { + let buf = synthetic_capture_buffer(frames, 1); + let mut handle = CaptureBenchHandle::new(in_rate, 1); + // Pre-warm so the first allocation does not dominate. + for _ in 0..4 { + handle.ingest_f32(&buf); + } + group.throughput(Throughput::Elements(frames as u64)); + group.bench_function(label, |b| { + b.iter(|| { + handle.ingest_f32(black_box(&buf)); + }); + }); + } + + group.finish(); +} + +criterion_group!(resampler, bench_resampler_throughput); +criterion_main!(resampler); diff --git a/crates/chanora_audio/examples/compare_baseline.rs b/crates/chanora_audio/examples/compare_baseline.rs new file mode 100644 index 0000000..98f2f0f --- /dev/null +++ b/crates/chanora_audio/examples/compare_baseline.rs @@ -0,0 +1,240 @@ +// SDD-120 §8 — PR-vs-baseline comparator binary. +// +// Reads `current.json` (from §5 emit_baseline) and `baseline.json` +// (extracted from the SAD-089 path at the merge-base by the §6 +// workflow) and emits a markdown report applying: +// +// - Zero-tolerance metrics (SRS-219 clause a — capture_alloc_count): +// 🔴 if c != 0 else 🟢. Yellow does not apply. +// - Otherwise (t > 0): +// delta_pct = (c - b) / b (or 0 if b == 0) +// 🔴 if delta_pct > t +// 🟡 if 0.5*t < delta_pct ≤ t +// 🟢 otherwise (including improvements, delta_pct < 0) +// +// Always exits 0 — regression detection is rendered as a 🔴 marker +// inside the markdown comment, NOT as a non-zero exit code +// (SRS-218 clause 4). +// +// CLI: +// cargo run --bin compare_baseline -- \ +// --current ./current.json \ +// --baseline ./baseline.json \ +// --output ./report.md + +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::process::ExitCode; + +use serde_json::Value; + +struct Args { + current: PathBuf, + baseline: PathBuf, + output: PathBuf, +} + +fn parse_args() -> Args { + let mut current = PathBuf::from("current.json"); + let mut baseline = PathBuf::from("baseline.json"); + let mut output = PathBuf::from("report.md"); + let mut it = std::env::args().skip(1); + while let Some(a) = it.next() { + match a.as_str() { + "--current" => current = PathBuf::from(it.next().unwrap_or_default()), + "--baseline" => baseline = PathBuf::from(it.next().unwrap_or_default()), + "--output" => output = PathBuf::from(it.next().unwrap_or_default()), + _ => {} + } + } + Args { + current, + baseline, + output, + } +} + +fn read_metrics(path: &PathBuf) -> (Option, HashMap) { + let mut out = HashMap::new(); + let text = match fs::read_to_string(path) { + Ok(t) => t, + Err(_) => return (None, out), + }; + if text.trim().is_empty() || text.trim() == "{}" { + return (None, out); + } + let v: Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => return (None, out), + }; + let metrics = v.get("metrics").and_then(|m| m.as_array()).cloned(); + if let Some(arr) = metrics { + for m in arr { + let name = m + .get("metric") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + let unit = m + .get("unit") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + let value = m.get("value").and_then(|n| n.as_f64()); + let tolerance = m + .get("tolerance_pct") + .and_then(|n| n.as_f64()) + .unwrap_or(0.0); + if !name.is_empty() { + out.insert( + name.clone(), + MetricRow { + metric: name, + unit, + value, + tolerance_pct: tolerance, + }, + ); + } + } + } + (Some(v), out) +} + +#[derive(Clone)] +struct MetricRow { + metric: String, + unit: String, + value: Option, + tolerance_pct: f64, +} + +fn fmt_value(v: Option) -> String { + match v { + Some(x) if x.is_finite() => { + if x.abs() >= 1_000_000.0 { + format!("{:.3e}", x) + } else if x.abs() >= 1.0 { + format!("{:.3}", x) + } else { + format!("{:.6}", x) + } + } + _ => "n/a".to_string(), + } +} + +fn marker(b: Option, c: Option, t: f64) -> (String, String) { + // Returns (marker, delta_str) + let (b, c) = match (b, c) { + (Some(b), Some(c)) => (b, c), + _ => return ("⚪".to_string(), "n/a".to_string()), + }; + if t == 0.0 { + // Zero-tolerance metric (SRS-219 clause a). + if c != 0.0 { + return ("🔴".to_string(), format!("{:+}", c as i64)); + } else { + return ("🟢".to_string(), "+0".to_string()); + } + } + let delta_pct = if b > 0.0 { (c - b) / b } else { 0.0 }; + let delta_str = format!("{:+.2}%", delta_pct * 100.0); + let m = if delta_pct > t { + "🔴" + } else if delta_pct > 0.5 * t { + "🟡" + } else { + "🟢" + }; + (m.to_string(), delta_str) +} + +fn main() -> ExitCode { + let args = parse_args(); + let (cur_doc, cur) = read_metrics(&args.current); + let (base_doc, base) = read_metrics(&args.baseline); + + let mut md = String::new(); + md.push_str("\n"); + md.push_str("### Chanora realtime-audio benchmark advisory (SDD-120)\n\n"); + + if cur_doc.is_none() { + md.push_str("_No `current.json` found — bench harness did not produce results._\n"); + fs::write(&args.output, &md).expect("write report"); + return ExitCode::SUCCESS; + } + + if base.is_empty() { + md.push_str( + "_Baseline not yet established at merge-base; first dispatch of \ + `bench-baseline-update.yml` on the default branch will establish \ + it. No red markers emitted on this first run._\n\n", + ); + md.push_str("| Metric | Unit | Current |\n"); + md.push_str("|---|---|---|\n"); + let mut keys: Vec<&String> = cur.keys().collect(); + keys.sort(); + for k in keys { + let row = &cur[k]; + md.push_str(&format!( + "| `{}` | {} | {} |\n", + row.metric, + row.unit, + fmt_value(row.value) + )); + } + fs::write(&args.output, &md).expect("write report"); + return ExitCode::SUCCESS; + } + + if let Some(d) = base_doc.as_ref() { + if let Some(sha) = d.get("git_sha").and_then(|s| s.as_str()) { + md.push_str(&format!("Baseline `git_sha`: `{}`\n\n", sha)); + } + } + + md.push_str("| Metric | Unit | Baseline | Current | Δ | Tolerance | Status |\n"); + md.push_str("|---|---|---|---|---|---|---|\n"); + + // Iterate over the union of metric names, sorted. + let mut all: Vec = cur.keys().chain(base.keys()).cloned().collect(); + all.sort(); + all.dedup(); + for name in all { + let c = cur.get(&name); + let b = base.get(&name); + let unit = c + .map(|r| r.unit.clone()) + .or_else(|| b.map(|r| r.unit.clone())) + .unwrap_or_default(); + let tol = c + .map(|r| r.tolerance_pct) + .or_else(|| b.map(|r| r.tolerance_pct)) + .unwrap_or(0.0); + let (m, delta) = marker(b.and_then(|r| r.value), c.and_then(|r| r.value), tol); + md.push_str(&format!( + "| `{}` | {} | {} | {} | {} | {} | {} |\n", + name, + unit, + fmt_value(b.and_then(|r| r.value)), + fmt_value(c.and_then(|r| r.value)), + delta, + if tol == 0.0 { + "0 (zero-tolerance)".to_string() + } else { + format!("{:.1}%", tol * 100.0) + }, + m, + )); + } + + md.push_str( + "\n_Advisory only — this workflow never fails a check on regression \ + (SRS-218 clause 4)._\n", + ); + + fs::write(&args.output, &md).expect("write report"); + ExitCode::SUCCESS +} diff --git a/crates/chanora_audio/examples/emit_baseline.rs b/crates/chanora_audio/examples/emit_baseline.rs new file mode 100644 index 0000000..4aeba74 --- /dev/null +++ b/crates/chanora_audio/examples/emit_baseline.rs @@ -0,0 +1,207 @@ +// SDD-120 §5 — JSON post-processor binary. +// +// Reads criterion's per-bench `target/criterion///estimates.json` +// outputs, plus the `capture_alloc_count.sidecar` value written by +// `realtime_capture.rs`, and projects them into the SRS-217 schema: +// { +// "host": "x86_64-unknown-linux-gnu", +// "toolchain": "", +// "git_sha": "", +// "timestamp": "", +// "metrics": [ +// { "metric": "", "value": , "unit": "", +// "tolerance_pct": , ... }, +// ... +// ] +// } +// +// Writes the document to `./current.json`. +// +// Run as: `cargo run --bin emit_baseline -p chanora_audio`. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{json, Value}; + +/// Per-metric SRS-219 tolerance entries. Zero-tolerance metrics +/// per SRS-219 clause a use 0.0 here; the comparator collapses +/// them to 🔴/🟢 only. +fn metric_table() -> Vec<(&'static str, &'static str, f64)> { + // (metric_name, unit, tolerance_pct) + vec![ + ("capture_alloc_count", "blocks", 0.0), + ("capture_callback_wall_clock", "ns", 0.10), + ("opus_encode_latency", "ns", 0.10), + ("opus_decode_latency", "ns", 0.10), + ("resampler_44100_to_48000", "samples_per_sec", 0.10), + ("resampler_16000_to_48000", "samples_per_sec", 0.10), + ("resampler_48000_passthrough", "samples_per_sec", 0.10), + ] +} + +fn target_dir() -> PathBuf { + env::var("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("target")) +} + +/// Read criterion `estimates.json` for a given bench function +/// name (criterion writes one per non-grouped bench under +/// `target/criterion//new/estimates.json`). +fn read_estimate_ns(name: &str) -> Option { + let path = target_dir() + .join("criterion") + .join(name) + .join("new") + .join("estimates.json"); + let text = fs::read_to_string(&path).ok()?; + let v: Value = serde_json::from_str(&text).ok()?; + // criterion estimates.json has `mean.point_estimate` (in ns). + v.get("mean") + .and_then(|m| m.get("point_estimate")) + .and_then(|p| p.as_f64()) +} + +/// Read criterion `estimates.json` for a sub-bench inside a group +/// (criterion writes group sub-benches under +/// `target/criterion///new/estimates.json`). +fn read_estimate_group_ns(group: &str, sub: &str) -> Option { + let path = target_dir() + .join("criterion") + .join(group) + .join(sub) + .join("new") + .join("estimates.json"); + let text = fs::read_to_string(&path).ok()?; + let v: Value = serde_json::from_str(&text).ok()?; + v.get("mean") + .and_then(|m| m.get("point_estimate")) + .and_then(|p| p.as_f64()) +} + +/// Resampler reports throughput; convert ns-per-iter to samples +/// per second using the per-iter input length (1s @ rate). +fn ns_to_throughput(ns_per_iter: f64, samples_per_iter: f64) -> f64 { + if ns_per_iter <= 0.0 { + return 0.0; + } + samples_per_iter * 1_000_000_000.0 / ns_per_iter +} + +fn read_alloc_sidecar() -> Option { + let path = target_dir() + .join("criterion") + .join("capture_alloc_count.sidecar"); + let s = fs::read_to_string(&path).ok()?; + s.trim().parse::().ok() +} + +fn rustc_version() -> String { + Command::new("rustc") + .arg("-V") + .output() + .ok() + .and_then(|o| { + if o.status.success() { + Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) + } else { + None + } + }) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn git_sha() -> String { + Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) + } else { + None + } + }) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn rfc3339_now() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // Minimal RFC3339-style UTC encoder; avoids pulling chrono. + // Algorithm: civil-from-days (Howard Hinnant). + let z = secs / 86_400; + let day_secs = secs % 86_400; + let hh = day_secs / 3600; + let mm = (day_secs % 3600) / 60; + let ss = day_secs % 60; + let z_shift = z as i64 + 719_468; + let era = z_shift.div_euclid(146_097); + let doe = z_shift.rem_euclid(146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if m <= 2 { y + 1 } else { y }; + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + year, m, d, hh, mm, ss + ) +} + +fn main() { + let mut metrics: Vec = Vec::new(); + for (name, unit, tol) in metric_table() { + let value = match name { + "capture_alloc_count" => read_alloc_sidecar(), + "capture_callback_wall_clock" => read_estimate_ns(name), + "opus_encode_latency" => read_estimate_ns(name), + "opus_decode_latency" => read_estimate_ns(name), + "resampler_44100_to_48000" => read_estimate_group_ns( + "resampler_throughput", + "44100_to_48000", + ) + .map(|ns| ns_to_throughput(ns, 44_100.0)), + "resampler_16000_to_48000" => read_estimate_group_ns( + "resampler_throughput", + "16000_to_48000", + ) + .map(|ns| ns_to_throughput(ns, 16_000.0)), + "resampler_48000_passthrough" => read_estimate_group_ns( + "resampler_throughput", + "48000_passthrough", + ) + .map(|ns| ns_to_throughput(ns, 48_000.0)), + _ => None, + }; + let v = value.unwrap_or(f64::NAN); + metrics.push(json!({ + "metric": name, + "value": if v.is_finite() { Value::from(v) } else { Value::Null }, + "unit": unit, + "tolerance_pct": tol, + })); + } + + let doc = json!({ + "host": "x86_64-unknown-linux-gnu", + "toolchain": rustc_version(), + "git_sha": git_sha(), + "timestamp": rfc3339_now(), + "metrics": metrics, + }); + + let out_path = Path::new("current.json"); + fs::write(out_path, serde_json::to_string_pretty(&doc).unwrap()) + .expect("write current.json"); + eprintln!("emit_baseline: wrote {}", out_path.display()); +} diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index 7b937f6..4aec3a5 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -1797,3 +1797,86 @@ pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> { Ok(()) }) } + +// --------------------------------------------------------------------------- +// SDD-120 §3 bench seam. +// +// Exposes a minimal factory for `CaptureState` plus a thin `ingest_f32` +// shim so the criterion bench harness in `crates/chanora_audio/benches/` +// can drive the same realtime capture code path the production cpal +// callback uses, without re-implementing CaptureState in the bench file. +// Marked `#[doc(hidden)]` so the public API surface is unaffected; this +// is not a supported external API. Only compiled on non-iOS targets +// because `CaptureState` itself is gated on `cfg(not(target_os = "ios"))`. +// --------------------------------------------------------------------------- +#[cfg(not(target_os = "ios"))] +#[doc(hidden)] +pub mod bench_seam { + use super::{ + AtomicBool, AtomicU32, Arc, CaptureState, OpusApp, OpusChannels, OpusEncoder, + OpusSampleRate, OutPacket, + }; + use tokio::sync::mpsc; + + /// Opaque handle wrapping a CaptureState plus the dummy mpsc + /// receiver that prevents the channel sender from erroring out + /// when the bench drives `ingest`. The receiver is held inside + /// the handle so it lives for the bench's lifetime. + pub struct CaptureBenchHandle { + state: CaptureState, + // Keep the receiver alive so sends from CaptureState::encode_and_send + // do not fail; the bench discards what would be transmitted. + _rx: mpsc::Receiver, + transmit_active: Arc, + } + + impl CaptureBenchHandle { + /// Construct a CaptureState wired to a private mpsc + a + /// pre-asserted transmit-active flag so `ingest` exercises + /// the full down-mix → resample → encode → send pipeline. + /// + /// `in_sample_rate` selects the input rate (48000 for the + /// passthrough path, 44100 / 16000 to exercise the linear + /// resampler). `in_channels` selects the channel layout + /// (typically 1 or 2). + pub fn new(in_sample_rate: u32, in_channels: usize) -> Self { + let encoder = OpusEncoder::new( + OpusSampleRate::Hz48000, + OpusChannels::Mono, + OpusApp::Voip, + ) + .expect("opus encoder init"); + let (tx, rx) = mpsc::channel::(64); + let transmit_active = Arc::new(AtomicBool::new(true)); + let frames_sent = Arc::new(AtomicU32::new(0)); + let state = CaptureState::new( + encoder, + in_sample_rate, + in_channels, + 1.0, + tx, + transmit_active.clone(), + frames_sent, + ); + Self { + state, + _rx: rx, + transmit_active, + } + } + + /// Drive one cpal-callback-equivalent buffer through the + /// realtime capture pipeline. + #[inline] + pub fn ingest_f32(&mut self, buf: &[f32]) { + self.state.ingest(buf); + } + + /// Set the PTT gate. Defaults to true (bench measures the + /// transmitting path). + pub fn set_transmit_active(&self, active: bool) { + self.transmit_active + .store(active, std::sync::atomic::Ordering::Relaxed); + } + } +} diff --git a/crates/chanora_audio/src/lib.rs b/crates/chanora_audio/src/lib.rs index c5d2be5..a4fb9e9 100644 --- a/crates/chanora_audio/src/lib.rs +++ b/crates/chanora_audio/src/lib.rs @@ -47,6 +47,14 @@ mod ios_voice_unit; pub mod android_voice_unit; pub use engine::{AudioEngine, AudioEngineConfig}; + +// SDD-120 §3 bench seam — `#[doc(hidden)]` re-export so the criterion +// bench harness under `crates/chanora_audio/benches/` can construct a +// CaptureState and drive `ingest` without re-implementing the engine. +// Not part of the supported public API. +#[cfg(not(target_os = "ios"))] +#[doc(hidden)] +pub use engine::bench_seam; pub use ptt::{AudioTransmitGate, MissedKeyUpWatchdog, PttBackendDescriptor, PttCapabilityLevel}; pub use ptt_backends::{ select as select_ptt_backend, DesktopPttBackend, FocusedPttBackend, PttBackendError,