diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1f4663..1694889 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,22 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + amr-minimum-rust: + name: AMR minimum Rust (1.91) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@1.91.0 + - name: Install native hotkey and capture prerequisites + run: | + sudo apt-get update + sudo apt-get install -y libasound2-dev libx11-dev libxtst-dev libxi-dev + - name: Test AMR-enabled library and module on the declared minimum + run: | + cargo test --manifest-path crates/tinyvoice/Cargo.toml --all-features + cargo check --manifest-path crates/tinyvoice-module/Cargo.toml --all-targets --all-features module-ci: uses: tinyhumansai/.github/.github/workflows/module-ci.yml@4adc1080fb495098e7af1aade82ed7e6e3c69bb7 with: diff --git a/.gitignore b/.gitignore index 4533f07..770f3fd 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ worktrees/* .idea/ .vscode/ *.swp +/live-module-coverage.json diff --git a/Cargo.lock b/Cargo.lock index f41156b..fec576e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,6 +63,18 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -222,6 +234,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "foreign-types" version = "0.3.2" @@ -400,6 +418,20 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mp4" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" +dependencies = [ + "byteorder", + "bytes", + "num-rational", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "ndk" version = "0.9.0" @@ -429,6 +461,34 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -440,6 +500,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -576,6 +657,41 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opus-decoder" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15ae32c275dabb0cd2c863460bf349e9abc3568ba2abf0f9eb7b7d2edeeed07e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "opus-pure" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de7c2ad44427a636b27bf0b03eb29e713775ba1b45ff31a0618c19ee5adb215" + +[[package]] +name = "oxideav-aac" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35453ffa827080f322dba5d2233879f008627d4ffb07c496c0c6fe3190b35048" +dependencies = [ + "oxideav-core", +] + +[[package]] +name = "oxideav-core" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64502aa4210ff04a5d574ca86f2c22e064a6c3bda812914b138c777e8ce6b023" +dependencies = [ + "bytemuck", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -663,6 +779,12 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "rustc_version" version = "0.4.1" @@ -678,6 +800,17 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rvoip-codec-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c28127efb2f580e03243c5999ff8a8c13b0ed4cf8a03aaafdb37c23fbf8cbb" +dependencies = [ + "thiserror 1.0.69", + "tracing", + "tracing-subscriber", +] + [[package]] name = "same-file" version = "1.0.6" @@ -742,6 +875,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -770,6 +912,117 @@ version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +[[package]] +name = "symphonia" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424" +dependencies = [ + "lazy_static", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ea5ffc8716bff677dfb3b01b420c7b758de901a72b8c330bf2040ab74b4add" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5bf8e39552d34a3c4c98333370e62f48c92456d2e814f273b5c3ad7c4a5f45c" +dependencies = [ + "lazy_static", + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04ba75686acbe43542fdd374571195f0530c0b7785ca25cc6840e9c6c4b6eea" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-common" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acc3fcc18ec9b8cdd48614e259c4cf0d27b71d41e5d9b120b42c5adab12d7c4" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-core" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "lazy_static", + "log", + "num-complex", + "smallvec", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e681a70e1870d34e02abf1dbc51e4267c3f1827801474e8870be8c689fc4dc3" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ff70929083a8c1a5f6cd7c904b6071c7914ad04739b510c2f7239dfc9b7dabe" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83713a97705d77bdef7cdbc0768fd6e5a54e4cd7e48d60a806ae85639e2c87c6" +dependencies = [ + "lazy_static", + "log", + "regex-lite", + "smallvec", + "symphonia-core", +] + [[package]] name = "syn" version = "2.0.119" @@ -832,16 +1085,32 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinyvoice" version = "0.3.2" dependencies = [ + "bytes", "cpal", "log", + "mp4", + "opus-decoder", + "opus-pure", + "oxideav-aac", "parking_lot", "rdev", + "rvoip-codec-core", "serde", "serde_json", + "symphonia", "thiserror 2.0.21", "tinyvoice-bus", "tokio", @@ -849,7 +1118,7 @@ dependencies = [ [[package]] name = "tinyvoice-bus" -version = "0.1.4" +version = "0.2.0" dependencies = [ "serde", "serde_json", @@ -907,12 +1176,75 @@ dependencies = [ "winnow", ] +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + [[package]] name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/MODULE.md b/MODULE.md index 89e0090..3adb1fb 100644 --- a/MODULE.md +++ b/MODULE.md @@ -4,12 +4,15 @@ This package contains the native `tinyvoice` module for TinyBus module ABI v1. Install only the archive matching the host operating system and architecture. The module claims `ai.tinyhumans.tinyvoice.Voice`, serves the object at -`/ai/tinyhumans/tinyvoice/Voice`, and provides 53 methods under contract 1.10. +`/ai/tinyhumans/tinyvoice/Voice`, and provides 56 methods under contract 1.12. ## Methods | Method | Arguments | Returns | | --- | --- | --- | +| `CodecProbe` | `CodecInput { audio_base64 }` | `CodecInfo`: detected codec, duration, sample rate, channels | +| `CodecTranscode` | `CodecTranscodeRequest { audio_base64, from, target }` | `CodecOutput { audio_base64 }` | +| `CodecWaveform` | `WaveformRequest { audio_base64, buckets }` | `WaveformResult { peaks }` | | `Route` | `transcript` | JSON `VoiceIntent`, tagged by an `intent` field | | `ExtractCommand` | `transcript`, `wake_word` | The command after the wake word, or `""` | | `WakeWordPresent` | `transcript`, `wake_word` | `bool` | diff --git a/README.md b/README.md index 19e4f3a..0e1ed5d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TinyVoice -Host-agnostic voice primitives, extracted from [OpenHuman](https://github.com/tinyhumansai/openhuman): WAV framing, silence gating, voice-activity segmentation, wake-word gating, fast-path command routing, STT hallucination detection, and relay-turn preparation. +Host-agnostic voice primitives, extracted from [OpenHuman](https://github.com/tinyhumansai/openhuman): WAV framing and voice-note codecs, silence gating, voice-activity segmentation, wake-word gating, fast-path command routing, STT hallucination detection, and relay-turn preparation. Ships two things from one repository: @@ -20,11 +20,13 @@ So the default crate build is synchronous, I/O-free and runtime-free. It does not open a microphone, call an STT or TTS endpoint, or know what a `Config` is. The optional `hotkey` feature adds a host-facing global keyboard listener. The optional `capture` feature adds microphone capture when a host supplies a -permission check. +permission check. The optional `amr-nb` feature adds pure-Rust AMR-NB codec +support and requires Rust 1.91. | Here | With the host | | --- | --- | | WAV framing, RMS, resampling, downmix, silence gate; module-owned capture threads | Microphone permission decisions through the computer module | +| In-memory WAV/PCM16, Ogg/Opus, M4A/AAC-LC, and optional AMR-NB probe, transcode, and waveform APIs | Codec calls through the `tinyvoice-bus` module contract | | VAD segmentation | The capture loop that drives it | | Wake-word gate, intent routing | What to *do* with an intent | | Hallucination detection | The STT transport, credentials, and retry policy | @@ -66,6 +68,11 @@ turn. It performs no inference or tool execution. A module host uses the OpenHuman supplies its local agent, acknowledgment policy and delivery. See the [relay contract](docs/specs/relay-turn-preparation.md). +The [`tinyvoice::codec` API](docs/specs/voice-codecs.md) detects, probes, +transcodes, and extracts waveform peaks from supported voice-note formats. It +uses pure-Rust codecs and in-memory containers; it does not require FFmpeg or +system codec libraries. + ### Optional features - `hotkey` (off by default): `tinyvoice::hotkey`, a system-wide push-to-talk / diff --git a/crates/tinyvoice-bus/Cargo.toml b/crates/tinyvoice-bus/Cargo.toml index 54f9907..e139a8b 100644 --- a/crates/tinyvoice-bus/Cargo.toml +++ b/crates/tinyvoice-bus/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tinyvoice-bus" -version = "0.1.4" +version = "0.2.0" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" diff --git a/crates/tinyvoice-bus/src/codec.rs b/crates/tinyvoice-bus/src/codec.rs new file mode 100644 index 0000000..4511137 --- /dev/null +++ b/crates/tinyvoice-bus/src/codec.rs @@ -0,0 +1,73 @@ +//! Typed, transport-neutral requests for voice-note codec operations. + +use serde::{Deserialize, Serialize}; + +/// Supported encoded voice-note media. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AudioCodec { + /// Signed 16-bit little-endian PCM in WAV. + WavPcm16, + /// Opus audio in Ogg. + OggOpus, + /// AAC-LC audio in M4A. + M4aAac, + /// MPEG Layer III audio (source format). + Mp3, + /// Adaptive Multi-Rate narrowband audio in its storage format. + AmrNb, +} + +/// Input bytes encoded as standard Base64. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodecInput { + /// Base64-encoded complete media file. + pub audio_base64: String, +} + +/// Convert media to the requested voice-note codec. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodecTranscodeRequest { + /// Base64-encoded source media. + pub audio_base64: String, + /// Declared source container and codec, checked against the payload. + pub from: AudioCodec, + /// Destination container and codec. + pub target: AudioCodec, +} + +/// Encoded media returned from a transcode operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodecOutput { + /// Base64-encoded complete media file. + pub audio_base64: String, +} + +/// Audio properties decoded from a media file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodecInfo { + /// Detected codec. + pub codec: AudioCodec, + /// Duration rounded down to whole milliseconds. + pub duration_ms: u64, + /// Original sample rate in Hz. + pub sample_rate: u32, + /// Original audio channel count. + pub channels: u16, +} + +/// Extract a fixed-size normalized waveform. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WaveformRequest { + /// Base64-encoded source media. + pub audio_base64: String, + /// Requested number of buckets, limited to 1 through 4096. + pub buckets: usize, +} + +/// Normalized peak amplitude bytes, one per requested bucket. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WaveformResult { + /// Peak values in the range 0 through 255. + pub peaks: Vec, +} diff --git a/crates/tinyvoice-bus/src/lib.rs b/crates/tinyvoice-bus/src/lib.rs index 8dac566..7c4b53a 100644 --- a/crates/tinyvoice-bus/src/lib.rs +++ b/crates/tinyvoice-bus/src/lib.rs @@ -13,6 +13,7 @@ mod error; pub mod capture; +pub mod codec; pub mod hotkey; pub mod intent; pub mod live; @@ -24,6 +25,10 @@ pub mod transcript; pub mod vad; pub mod version; +pub use codec::{ + AudioCodec, CodecInfo, CodecInput, CodecOutput, CodecTranscodeRequest, WaveformRequest, + WaveformResult, +}; pub use error::{Error, Result}; pub use hotkey::*; pub use intent::VoiceIntent; diff --git a/crates/tinyvoice-bus/src/names/mod.rs b/crates/tinyvoice-bus/src/names/mod.rs index 89c9704..7364645 100644 --- a/crates/tinyvoice-bus/src/names/mod.rs +++ b/crates/tinyvoice-bus/src/names/mod.rs @@ -8,6 +8,12 @@ pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinyvoice/Voice"; /// One constant per member of [`BUS_NAME`]. pub mod methods { + /// Decode media and return its container and audio properties. + pub const CODEC_PROBE: &str = "CodecProbe"; + /// Convert an in-memory voice-note audio payload. + pub const CODEC_TRANSCODE: &str = "CodecTranscode"; + /// Decode media and return normalized waveform peak bytes. + pub const CODEC_WAVEFORM: &str = "CodecWaveform"; /// Fix explicit host concurrency and reservation bounds. pub const RELAY_CONFIGURE: &str = "RelayConfigure"; /// Reserve a relay turn before local-brain startup. @@ -128,6 +134,9 @@ pub const METHODS: &[&str] = &[ methods::CAPTURE_SHUTDOWN, methods::CAPTURE_START, methods::CAPTURE_STOP, + methods::CODEC_PROBE, + methods::CODEC_TRANSCODE, + methods::CODEC_WAVEFORM, methods::ENCODE_WAV, methods::ENCODE_WAV_PCM16, methods::EXTRACT_COMMAND, diff --git a/crates/tinyvoice-bus/src/version/mod.rs b/crates/tinyvoice-bus/src/version/mod.rs index 88c0748..c1f1ac8 100644 --- a/crates/tinyvoice-bus/src/version/mod.rs +++ b/crates/tinyvoice-bus/src/version/mod.rs @@ -1,7 +1,7 @@ //! The `TinyVoice` `TinyBus` contract version and its binding rule. /// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (1, 10); +pub const CONTRACT_VERSION: (u32, u32) = (1, 12); /// Returns whether a host using [`CONTRACT_VERSION`] can bind to `module`. /// diff --git a/crates/tinyvoice-bus/tests/codec_contract.rs b/crates/tinyvoice-bus/tests/codec_contract.rs new file mode 100644 index 0000000..4a94b8a --- /dev/null +++ b/crates/tinyvoice-bus/tests/codec_contract.rs @@ -0,0 +1,17 @@ +//! Stable wire tags for codec requests crossing the module boundary. + +use tinyvoice_bus::CodecTranscodeRequest; + +#[test] +fn amr_nb_codec_tag_round_trips_in_transcode_requests() -> Result<(), Box> { + let wire = serde_json::json!({ + "audio_base64": "AA==", + "from": "wav_pcm16", + "target": "amr_nb" + }); + + let request: CodecTranscodeRequest = serde_json::from_value(wire.clone())?; + + assert_eq!(serde_json::to_value(request)?, wire); + Ok(()) +} diff --git a/crates/tinyvoice-module/Cargo.lock b/crates/tinyvoice-module/Cargo.lock index a3fd49d..827922a 100644 --- a/crates/tinyvoice-module/Cargo.lock +++ b/crates/tinyvoice-module/Cargo.lock @@ -34,7 +34,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "libc", ] @@ -70,15 +70,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "async-trait" version = "0.1.92" @@ -87,7 +78,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] @@ -133,9 +124,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block" @@ -176,6 +167,18 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -194,7 +197,7 @@ dependencies = [ "io-lifetimes 3.0.1", "ipnet", "maybe-owned", - "rustix 1.1.4", + "rustix 1.1.5", "rustix-linux-procfs", "windows-sys 0.61.2", "winx", @@ -209,14 +212,14 @@ dependencies = [ "cap-primitives", "io-extras", "io-lifetimes 3.0.1", - "rustix 1.1.4", + "rustix 1.1.5", ] [[package]] name = "cc" -version = "1.4.4" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -226,9 +229,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -322,9 +325,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -398,7 +401,7 @@ version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -456,19 +459,13 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - [[package]] name = "crypto-common" version = "0.1.7" @@ -501,17 +498,6 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "digest" version = "0.10.7" @@ -561,7 +547,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", ] @@ -573,14 +559,14 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -589,9 +575,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.5.0" @@ -610,18 +602,19 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -661,7 +654,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" dependencies = [ "io-lifetimes 2.0.4", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.52.0", ] @@ -671,7 +664,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5" dependencies = [ - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -731,7 +724,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] @@ -779,7 +772,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.4", + "rustix 1.1.5", "windows-link", ] @@ -953,7 +946,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] @@ -1110,9 +1103,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", @@ -1173,7 +1166,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -1248,9 +1241,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libredox" @@ -1329,9 +1322,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -1348,13 +1341,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mp4" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" +dependencies = [ + "byteorder", + "bytes", + "num-rational", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "ndk" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", "ndk-sys", @@ -1377,6 +1384,46 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -1388,6 +1435,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1430,9 +1498,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -1443,7 +1511,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", "objc2", "objc2-core-audio", @@ -1458,7 +1526,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-foundation", ] @@ -1482,7 +1550,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", ] @@ -1492,7 +1560,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "dispatch2", "libc", @@ -1511,7 +1579,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -1536,6 +1604,32 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "opus-pure" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de7c2ad44427a636b27bf0b03eb29e713775ba1b45ff31a0618c19ee5adb215" + +[[package]] +name = "oxideav-aac" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35453ffa827080f322dba5d2233879f008627d4ffb07c496c0c6fe3190b35048" +dependencies = [ + "oxideav-core", +] + +[[package]] +name = "oxideav-core" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64502aa4210ff04a5d574ca86f2c22e064a6c3bda812914b138c777e8ce6b023" +dependencies = [ + "bytemuck", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1612,7 +1706,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.15+spec-1.1.0", + "toml_edit 0.25.17+spec-1.1.0", ] [[package]] @@ -1638,7 +1732,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -1660,7 +1754,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -1677,7 +1771,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1787,7 +1881,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -1797,7 +1891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ "libredox", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -1823,6 +1917,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.11" @@ -1868,7 +1968,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] @@ -1906,7 +2006,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1915,15 +2015,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1933,14 +2033,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" dependencies = [ "once_cell", - "rustix 1.1.4", + "rustix 1.1.5", ] [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", @@ -1963,9 +2063,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1978,6 +2078,17 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rvoip-codec-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c28127efb2f580e03243c5999ff8a8c13b0ed4cf8a03aaafdb37c23fbf8cbb" +dependencies = [ + "thiserror 1.0.69", + "tracing", + "tracing-subscriber", +] + [[package]] name = "ryu" version = "1.0.23" @@ -2032,14 +2143,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -2059,9 +2170,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -2102,15 +2213,24 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", "digest 0.11.3", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -2183,6 +2303,117 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symphonia" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424" +dependencies = [ + "lazy_static", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ea5ffc8716bff677dfb3b01b420c7b758de901a72b8c330bf2040ab74b4add" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5bf8e39552d34a3c4c98333370e62f48c92456d2e814f273b5c3ad7c4a5f45c" +dependencies = [ + "lazy_static", + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04ba75686acbe43542fdd374571195f0530c0b7785ca25cc6840e9c6c4b6eea" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-common" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acc3fcc18ec9b8cdd48614e259c4cf0d27b71d41e5d9b120b42c5adab12d7c4" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-core" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "lazy_static", + "log", + "num-complex", + "smallvec", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e681a70e1870d34e02abf1dbc51e4267c3f1827801474e8870be8c689fc4dc3" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ff70929083a8c1a5f6cd7c904b6071c7914ad04739b510c2f7239dfc9b7dabe" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83713a97705d77bdef7cdbc0768fd6e5a54e4cd7e48d60a806ae85639e2c87c6" +dependencies = [ + "lazy_static", + "log", + "regex-lite", + "smallvec", + "symphonia-core", +] + [[package]] name = "syn" version = "2.0.119" @@ -2196,9 +2427,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -2222,7 +2453,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] @@ -2245,8 +2476,8 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.4", - "windows-sys 0.61.2", + "rustix 1.1.5", + "windows-sys 0.52.0", ] [[package]] @@ -2260,11 +2491,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -2280,13 +2511,22 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", ] [[package]] @@ -2316,12 +2556,12 @@ dependencies = [ "libc", "regex", "reqwest", - "rustix 1.1.4", + "rustix 1.1.5", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyagents-definition", "tinyinference-core", "tinyinference-embeddings", @@ -2348,7 +2588,7 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.1.5" +version = "0.1.6" dependencies = [ "async-trait", "flate2", @@ -2358,10 +2598,10 @@ dependencies = [ "sha2 0.10.9", "tar", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinybus-macros", "tokio", - "toml 1.1.6+spec-1.1.0", + "toml 1.1.8+spec-1.1.0", "tracing", "ureq", "zip", @@ -2369,7 +2609,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.1.5" +version = "0.1.6" dependencies = [ "proc-macro2", "quote", @@ -2378,7 +2618,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.1.5" +version = "0.1.6" dependencies = [ "async-trait", "serde", @@ -2409,7 +2649,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyinference-core", "tokio", "tracing", @@ -2427,7 +2667,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyinference-core", "tinyinference-providers", "tinytools-agent 0.6.0 (git+https://github.com/tinyhumansai/tinytools?rev=3a01641f8ebd8a55656cd212f9a90638d958a447)", @@ -2444,7 +2684,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "tinystoragedrivers-secrets", "tracing", "url", @@ -2462,7 +2702,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-tungstenite", "tracing", @@ -2479,7 +2719,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "ulid", "zstd", @@ -2496,7 +2736,7 @@ dependencies = [ "clack-private-fs", "fs4", "hkdf", - "rustix 1.1.4", + "rustix 1.1.5", "serde", "serde_json", "sha2 0.10.9", @@ -2570,24 +2810,30 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" name = "tinyvoice" version = "0.3.2" dependencies = [ + "bytes", "cpal", "log", + "mp4", + "opus-pure", + "oxideav-aac", "parking_lot", "rdev", + "rvoip-codec-core", "serde", "serde_json", - "thiserror 2.0.20", + "symphonia", + "thiserror 2.0.21", "tinyvoice-bus", "tokio", ] [[package]] name = "tinyvoice-bus" -version = "0.1.4" +version = "0.2.0" dependencies = [ "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -2599,14 +2845,14 @@ dependencies = [ [[package]] name = "tinyvoice-module" -version = "0.3.4" +version = "0.4.0" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", "futures-util", "getrandom 0.4.3", - "rustix 1.1.4", + "rustix 1.1.5", "serde", "serde_json", "tinyagents-harness", @@ -2624,9 +2870,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.1" +version = "1.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" dependencies = [ "bytes", "libc", @@ -2646,7 +2892,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] @@ -2703,14 +2949,14 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "toml_writer", "winnow 1.0.4", @@ -2727,9 +2973,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -2750,21 +2996,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.15+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow 1.0.4", ] @@ -2777,9 +3023,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -2802,7 +3048,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -2855,6 +3101,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -2878,9 +3150,15 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.20", + "thiserror 2.0.21", ] +[[package]] +name = "typed-path" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" + [[package]] name = "typenum" version = "1.20.1" @@ -2899,9 +3177,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "universal-hash" @@ -2921,9 +3199,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.0" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ "base64 0.23.1", "flate2", @@ -2933,14 +3211,14 @@ dependencies = [ "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] name = "ureq-proto" -version = "0.6.1" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ "base64 0.23.1", "http", @@ -2974,15 +3252,21 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.1" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -3075,7 +3359,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -3127,14 +3411,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -3161,7 +3445,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3402,7 +3686,7 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "windows-sys 0.52.0", ] @@ -3435,7 +3719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 1.1.4", + "rustix 1.1.5", "x11rb-protocol", ] @@ -3452,7 +3736,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.4", + "rustix 1.1.5", ] [[package]] @@ -3474,24 +3758,24 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -3515,15 +3799,15 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zerotrie" @@ -3555,43 +3839,34 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] name = "zip" -version = "2.4.2" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ - "arbitrary", "crc32fast", - "crossbeam-utils", - "displaydoc", "flate2", "indexmap", "memchr", - "thiserror 2.0.20", - "zopfli", + "nix", + "typed-path", ] [[package]] -name = "zmij" -version = "1.0.23" +name = "zlib-rs" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] -name = "zopfli" -version = "0.8.3" +name = "zmij" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" diff --git a/crates/tinyvoice-module/Cargo.toml b/crates/tinyvoice-module/Cargo.toml index 2d4a7e9..6599c15 100644 --- a/crates/tinyvoice-module/Cargo.toml +++ b/crates/tinyvoice-module/Cargo.toml @@ -2,9 +2,9 @@ [package] name = "tinyvoice-module" -version = "0.3.4" +version = "0.4.0" edition = "2024" -rust-version = "1.88" +rust-version = "1.91" license = "GPL-3.0-only" description = "Trusted TinyBus module adapter for TinyVoice." repository = "https://github.com/tinyhumansai/tinyvoice" @@ -35,10 +35,10 @@ anyhow = "1" getrandom = "0.4" # The transport-free TinyBus wire contract: names, payload types, and version. -tinyvoice-bus = { version = "0.1.4", path = "../tinyvoice-bus" } +tinyvoice-bus = { version = "0.2.0", path = "../tinyvoice-bus" } # The pure voice library stays independently usable and bus-agnostic; this # crate exists so the vendored TinyBus dependency never reaches it. -tinyvoice = { version = "0.3.2", path = "../tinyvoice", features = ["capture", "hotkey"] } +tinyvoice = { version = "0.3.2", path = "../tinyvoice", features = ["capture", "hotkey", "amr-nb"] } # TinyBus provides the typed service interface and the dynamic module host ABI. tinybus = { version = ">=0.1.3, <0.2", default-features = false, features = ["macros", "modules"] } # The module-side SDK owns its runtime and exports the stable C entrypoints. diff --git a/crates/tinyvoice-module/examples/verify_module.rs b/crates/tinyvoice-module/examples/verify_module.rs index 0ee5dd6..cb87978 100644 --- a/crates/tinyvoice-module/examples/verify_module.rs +++ b/crates/tinyvoice-module/examples/verify_module.rs @@ -4,10 +4,15 @@ use std::io; use std::path::PathBuf; use std::time::Duration; +use base64::Engine as _; use tinybus::Connection; use tinybus::broker::Broker; use tinybus::module::ModuleHost; use tinybus::transport::memory::MemoryBus; +use tinyvoice_bus::{ + AudioCodec, CodecInfo, CodecInput, CodecOutput, CodecTranscodeRequest, WaveformRequest, + WaveformResult, +}; // Imported, never redeclared. A local copy of these is exactly how this // example once ended up dialling `/ai.tinyhumans.tinyvoice.Voice` — a bus name @@ -60,6 +65,7 @@ async fn main() -> Result<(), Box> { io::Error::other(format!("module returned an unexpected intent: {intent}")).into(), ); } + verify_amr_codec(&proxy).await?; let declared: Vec<_> = info .manifest @@ -133,6 +139,66 @@ async fn main() -> Result<(), Box> { Ok(()) } +async fn verify_amr_codec(proxy: &tinybus::Proxy) -> Result<(), Box> { + let wav = include_bytes!("../../tinyvoice/tests/fixtures/voice-envelope.wav"); + let wav_base64 = base64::engine::general_purpose::STANDARD.encode(wav); + let source: CodecInfo = proxy + .call( + "CodecProbe", + (CodecInput { + audio_base64: wav_base64.clone(), + },), + ) + .await?; + if source.duration_ms != 2_370 { + return Err(io::Error::other("WAV fixture duration changed unexpectedly").into()); + } + let result: CodecOutput = proxy + .call( + "CodecTranscode", + (CodecTranscodeRequest { + audio_base64: wav_base64, + from: AudioCodec::WavPcm16, + target: AudioCodec::AmrNb, + },), + ) + .await?; + let encoded = base64::engine::general_purpose::STANDARD.decode(result.audio_base64)?; + if !encoded.starts_with(b"#!AMR\n") { + return Err(io::Error::other("loaded module returned invalid AMR storage").into()); + } + + let info: CodecInfo = proxy + .call( + "CodecProbe", + (CodecInput { + audio_base64: base64::engine::general_purpose::STANDARD.encode(&encoded), + },), + ) + .await?; + if info.codec != AudioCodec::AmrNb + || info.duration_ms != 2_380 + || info.sample_rate != 8_000 + || info.channels != 1 + { + return Err(io::Error::other("loaded module returned incorrect AMR metadata").into()); + } + + let waveform: WaveformResult = proxy + .call( + "CodecWaveform", + (WaveformRequest { + audio_base64: base64::engine::general_purpose::STANDARD.encode(encoded), + buckets: 64, + },), + ) + .await?; + if waveform.peaks.len() != 64 || !waveform.peaks.iter().any(|peak| *peak > 0) { + return Err(io::Error::other("loaded module returned an invalid AMR waveform").into()); + } + Ok(()) +} + async fn verify_recording_summary( proxy: &tinybus::Proxy, ) -> Result<(), Box> { diff --git a/crates/tinyvoice-module/src/service/capture/mod.rs b/crates/tinyvoice-module/src/service/capture/mod.rs index f83a204..3867fa3 100644 --- a/crates/tinyvoice-module/src/service/capture/mod.rs +++ b/crates/tinyvoice-module/src/service/capture/mod.rs @@ -14,7 +14,7 @@ use tinyvoice_bus::capture::{ }; const MAX_RESERVATIONS: usize = 16; -const RESERVATION_TTL: std::time::Duration = std::time::Duration::from_secs(60); +const RESERVATION_TTL: std::time::Duration = std::time::Duration::from_mins(1); const MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024; const MAX_OUTPUTS: usize = 4; const MAX_READ_BYTES: usize = 256 * 1024; diff --git a/crates/tinyvoice-module/src/service/hotkey/mod.rs b/crates/tinyvoice-module/src/service/hotkey/mod.rs index 8bcf351..0eb2c17 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod.rs @@ -13,7 +13,7 @@ use tinyvoice_bus::{ mod native; const MAX_RESERVATIONS: usize = 16; -const RESERVATION_TTL: std::time::Duration = std::time::Duration::from_secs(60); +const RESERVATION_TTL: std::time::Duration = std::time::Duration::from_mins(1); const MAX_KEY_BYTES: usize = 128; const MAX_HANDLE_BYTES: usize = 32; const MAX_FEED_FACTS: usize = 64; diff --git a/crates/tinyvoice-module/src/service/live/prepare_tests.rs b/crates/tinyvoice-module/src/service/live/prepare_tests.rs index 1e549b1..92eccd6 100644 --- a/crates/tinyvoice-module/src/service/live/prepare_tests.rs +++ b/crates/tinyvoice-module/src/service/live/prepare_tests.rs @@ -45,7 +45,7 @@ async fn native_configuration_preserves_all_explicit_host_fields() -> anyhow::Re assert_eq!(prepared.config.provider_options, json!({"host": true})); assert_eq!( prepared.options.tool_timeout, - std::time::Duration::from_millis(1000) + std::time::Duration::from_secs(1) ); assert!(!prepared.options.include_deferred_tools); Ok(()) diff --git a/crates/tinyvoice-module/src/service/live/table.rs b/crates/tinyvoice-module/src/service/live/table.rs index 4d24854..41add0b 100644 --- a/crates/tinyvoice-module/src/service/live/table.rs +++ b/crates/tinyvoice-module/src/service/live/table.rs @@ -18,7 +18,7 @@ pub(super) const MAX_GROUPS: usize = 32; pub(super) const MAX_BATCH_BYTES: usize = 1024 * 1024; pub(super) const MAX_AUDIO_BYTES: usize = 64 * 1024; pub(super) const MAX_TEXT_BYTES: usize = 64 * 1024; -const RESERVATION_TTL: std::time::Duration = std::time::Duration::from_secs(60); +const RESERVATION_TTL: std::time::Duration = std::time::Duration::from_mins(1); /// Mutable state never held across an await. pub(super) struct Phase { diff --git a/crates/tinyvoice-module/src/service/live/table_tests.rs b/crates/tinyvoice-module/src/service/live/table_tests.rs index 579a545..89552c5 100644 --- a/crates/tinyvoice-module/src/service/live/table_tests.rs +++ b/crates/tinyvoice-module/src/service/live/table_tests.rs @@ -349,7 +349,7 @@ async fn live_admission_rejects_changed_expired_and_oversized_inputs_before_conn bind.port.context_token = "known".into(); bind.port.object_path = "invalid path".into(); assert!(sessions.bind_transport(bind).is_err()); - tokio::time::advance(std::time::Duration::from_secs(60)).await; + tokio::time::advance(std::time::Duration::from_mins(1)).await; assert!(sessions.start(request.clone()).is_err()); sessions.release(request.handle).await?; Ok(()) diff --git a/crates/tinyvoice-module/src/service/live/transport_tests.rs b/crates/tinyvoice-module/src/service/live/transport_tests.rs index 39fe290..c198457 100644 --- a/crates/tinyvoice-module/src/service/live/transport_tests.rs +++ b/crates/tinyvoice-module/src/service/live/transport_tests.rs @@ -339,7 +339,7 @@ async fn forgotten_bound_reservation_expires_and_closes_its_mechanical_socket() -> tinybus::Result<()> { let socket = Socket::default(); let (sessions, _module, _host, handle, lease) = fixture(socket.clone()).await?; - tokio::time::advance(Duration::from_secs(60)).await; + tokio::time::advance(Duration::from_mins(1)).await; tokio::time::timeout(Duration::from_secs(1), socket.closed.notified()) .await .map_err(|_| tinybus::Error::failed("fixture deadline"))?; diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index bbb4429..7a2e8e4 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -31,6 +31,15 @@ use tinyvoice::vad::{VadConfig, VadSegmenter}; /// an allocation failure. pub const MAX_AUDIO_BYTES: usize = 8 * 1024 * 1024; +/// Concurrent in-memory codec operations; excess calls fail fast instead of +/// allowing attacker-controlled encoded payloads to build an unbounded queue. +const MAX_CODEC_WORKERS: usize = 2; + +static CODEC_WORKERS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CODEC_WORKERS)) + }); + /// Most sessions a caller may hold open at once. /// /// A session is opened by `VadOpen` and only released by `VadClose`, and a @@ -152,6 +161,26 @@ fn encode_samples(samples: &[f32]) -> String { BASE64.encode(bytes) } +/// Run CPU-heavy, bounded codec work away from the async module worker. +async fn run_codec_worker( + semaphore: std::sync::Arc, + operation: F, +) -> TinyBusResult +where + T: Send + 'static, + F: FnOnce() -> TinyBusResult + Send + 'static, +{ + let permit = semaphore + .try_acquire_owned() + .map_err(|_| tinybus::Error::failed("voice codec worker pool is at capacity"))?; + tokio::task::spawn_blocking(move || { + let _permit = permit; + operation() + }) + .await + .map_err(|_| tinybus::Error::failed("voice codec worker failed"))? +} + /// The error for a session id that is not open. /// /// Its own function so the wording is identical across the four methods that @@ -198,9 +227,10 @@ fn to_json(value: &T) -> TinyBusResult { clippy::unused_async, reason = "TinyBus interface methods require async signatures" )] -#[expect( +#[allow(unknown_lints)] +#[allow( clippy::unused_async_trait_impl, - reason = "TinyBus interface methods must retain async signatures at this dynamic module boundary" + reason = "TinyBus interface dispatch awaits every declared async method" )] #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.Voice")] impl VoiceService { @@ -752,6 +782,61 @@ impl VoiceService { Ok(BASE64.encode(wav)) } + /// Decode a complete in-memory media file and report its audio properties. + async fn codec_probe( + &self, + request: tinyvoice_bus::codec::CodecInput, + ) -> TinyBusResult { + run_codec_worker(CODEC_WORKERS.clone(), move || { + let bytes = decode_audio(&request.audio_base64)?; + let info = tinyvoice::codec::probe(&bytes).map_err(|error| failed(&error))?; + Ok(tinyvoice_bus::codec::CodecInfo { + codec: bus_codec(info.codec)?, + duration_ms: info.duration_ms, + sample_rate: info.sample_rate, + channels: info.channels, + }) + }) + .await + } + + /// Transcode one complete in-memory voice note to the requested format. + async fn codec_transcode( + &self, + request: tinyvoice_bus::codec::CodecTranscodeRequest, + ) -> TinyBusResult { + run_codec_worker(CODEC_WORKERS.clone(), move || { + let bytes = decode_audio(&request.audio_base64)?; + let from = library_codec(request.from); + let target = library_codec(request.target); + let output = tinyvoice::codec::transcode(&bytes, from, target) + .map_err(|error| failed(&error))?; + if output.len() > MAX_AUDIO_BYTES { + return Err(tinybus::Error::failed(format!( + "transcoded audio exceeds the {MAX_AUDIO_BYTES} byte limit" + ))); + } + Ok(tinyvoice_bus::codec::CodecOutput { + audio_base64: BASE64.encode(output), + }) + }) + .await + } + + /// Decode audio and return normalized peak amplitudes for fixed buckets. + async fn codec_waveform( + &self, + request: tinyvoice_bus::codec::WaveformRequest, + ) -> TinyBusResult { + run_codec_worker(CODEC_WORKERS.clone(), move || { + let bytes = decode_audio(&request.audio_base64)?; + let peaks = tinyvoice::codec::waveform(&bytes, request.buckets) + .map_err(|error| failed(&error))?; + Ok(tinyvoice_bus::codec::WaveformResult { peaks }) + }) + .await + } + /// Downmix, resample, and silence-gate a captured buffer, returning WAV. /// /// This is the whole capture-side pipeline in one call, because a host that @@ -844,6 +929,31 @@ impl VoiceService { } } +fn library_codec(codec: tinyvoice_bus::codec::AudioCodec) -> tinyvoice::codec::AudioCodec { + match codec { + tinyvoice_bus::codec::AudioCodec::WavPcm16 => tinyvoice::codec::AudioCodec::WavPcm16, + tinyvoice_bus::codec::AudioCodec::OggOpus => tinyvoice::codec::AudioCodec::OggOpus, + tinyvoice_bus::codec::AudioCodec::M4aAac => tinyvoice::codec::AudioCodec::M4aAac, + tinyvoice_bus::codec::AudioCodec::Mp3 => tinyvoice::codec::AudioCodec::Mp3, + tinyvoice_bus::codec::AudioCodec::AmrNb => tinyvoice::codec::AudioCodec::AmrNb, + } +} + +fn bus_codec( + codec: tinyvoice::codec::AudioCodec, +) -> TinyBusResult { + match codec { + tinyvoice::codec::AudioCodec::WavPcm16 => Ok(tinyvoice_bus::codec::AudioCodec::WavPcm16), + tinyvoice::codec::AudioCodec::OggOpus => Ok(tinyvoice_bus::codec::AudioCodec::OggOpus), + tinyvoice::codec::AudioCodec::M4aAac => Ok(tinyvoice_bus::codec::AudioCodec::M4aAac), + tinyvoice::codec::AudioCodec::Mp3 => Ok(tinyvoice_bus::codec::AudioCodec::Mp3), + tinyvoice::codec::AudioCodec::AmrNb => Ok(tinyvoice_bus::codec::AudioCodec::AmrNb), + _ => Err(tinybus::Error::failed( + "codec is not available on the voice bus", + )), + } +} + /// Claim the bus name and serve the interface. async fn setup(connection: Connection) -> TinyBusResult<()> { connection @@ -903,6 +1013,9 @@ tinybus_module::module_export_optional_static! { "CaptureShutdown", "EncodeWav", "EncodeWavPcm16", + "CodecProbe", + "CodecTranscode", + "CodecWaveform", "PrepareCapture", "ListInputDevices", "RecordingStart", diff --git a/crates/tinyvoice-module/src/service/mod_tests.rs b/crates/tinyvoice-module/src/service/mod_tests.rs index 893d1e9..bce81b8 100644 --- a/crates/tinyvoice-module/src/service/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/mod_tests.rs @@ -20,11 +20,33 @@ use tinybus::broker::Broker; use tinybus::transport::memory::MemoryBus; use tinybus::{Connection, Interface}; use tinyvoice_bus::{ - ActivationMode, HostKeyFact, HotkeyFeedRequest, HotkeyHandleRequest, HotkeyReadRequest, - HotkeyRequest, HotkeyReserveRequest, HotkeySource, SequencedHostFact, + ActivationMode, AudioCodec, CodecInfo, CodecInput, CodecOutput, CodecTranscodeRequest, + HostKeyFact, HotkeyFeedRequest, HotkeyHandleRequest, HotkeyReadRequest, HotkeyRequest, + HotkeyReserveRequest, HotkeySource, SequencedHostFact, WaveformRequest, WaveformResult, }; use tinyvoice_bus::{BUS_NAME, OBJECT_PATH, names}; +#[tokio::test(flavor = "current_thread")] +async fn codec_work_is_offloaded_and_rejects_when_capacity_is_full() { + let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(1)); + let held = semaphore + .clone() + .try_acquire_owned() + .expect("test takes the only codec worker slot"); + assert!( + super::run_codec_worker(semaphore.clone(), || Ok(())) + .await + .is_err() + ); + drop(held); + + let executor_thread = std::thread::current().id(); + let worker_thread = super::run_codec_worker(semaphore, || Ok(std::thread::current().id())) + .await + .expect("available codec worker runs"); + assert_ne!(worker_thread, executor_thread); +} + /// A live bus with the interface served on it. /// /// The service `Connection` is held here rather than dropped at the end of @@ -97,6 +119,149 @@ async fn route_returns_a_tagged_intent() -> tinybus::Result<()> { Ok(()) } +#[tokio::test] +async fn voice_note_codec_methods_are_available_over_the_module_contract() -> tinybus::Result<()> { + let proxy = connect().await?; + let wav = include_bytes!("../../../tinyvoice/tests/fixtures/voice-envelope.wav"); + let encoded = BASE64.encode(wav); + + let info: CodecInfo = proxy + .call( + "CodecProbe", + (CodecInput { + audio_base64: encoded.clone(), + },), + ) + .await?; + assert_eq!(info.codec, AudioCodec::WavPcm16); + assert_eq!(info.channels, 1); + + let waveform: WaveformResult = proxy + .call( + "CodecWaveform", + (WaveformRequest { + audio_base64: encoded.clone(), + buckets: 256, + },), + ) + .await?; + assert_eq!(waveform.peaks.len(), 256); + assert!(waveform.peaks.iter().any(|peak| *peak > 0)); + + let opus: CodecOutput = proxy + .call( + "CodecTranscode", + (CodecTranscodeRequest { + audio_base64: encoded, + from: AudioCodec::WavPcm16, + target: AudioCodec::OggOpus, + },), + ) + .await?; + let opus = base64::engine::general_purpose::STANDARD + .decode(opus.audio_base64) + .expect("module returns valid base64"); + assert!(opus.starts_with(b"OggS")); + Ok(()) +} + +#[tokio::test] +async fn amr_nb_probe_and_decode_are_available_over_the_module_contract() -> tinybus::Result<()> { + let proxy = connect().await?; + let wav = include_bytes!("../../../tinyvoice/tests/fixtures/voice-envelope.wav"); + let wav_base64 = BASE64.encode(wav); + let wav_info: CodecInfo = proxy + .call( + "CodecProbe", + (CodecInput { + audio_base64: wav_base64.clone(), + },), + ) + .await?; + assert_eq!(wav_info.duration_ms, 2_370); + let amr_output: CodecOutput = proxy + .call( + "CodecTranscode", + (CodecTranscodeRequest { + audio_base64: wav_base64, + from: AudioCodec::WavPcm16, + target: AudioCodec::AmrNb, + },), + ) + .await?; + let encoded_amr = BASE64 + .decode(amr_output.audio_base64) + .expect("module returns valid AMR base64"); + assert!(encoded_amr.starts_with(b"#!AMR\n")); + + let encoded_info: CodecInfo = proxy + .call( + "CodecProbe", + (CodecInput { + audio_base64: BASE64.encode(&encoded_amr), + },), + ) + .await?; + assert_eq!(encoded_info.codec, AudioCodec::AmrNb); + assert_eq!(encoded_info.duration_ms, 2_380); + assert_eq!(encoded_info.sample_rate, 8_000); + assert_eq!(encoded_info.channels, 1); + + let waveform: WaveformResult = proxy + .call( + "CodecWaveform", + (WaveformRequest { + audio_base64: BASE64.encode(&encoded_amr), + buckets: 64, + },), + ) + .await?; + assert_eq!(waveform.peaks.len(), 64); + assert!(waveform.peaks.iter().any(|peak| *peak > 0)); + + let amr = include_bytes!("../../../tinyvoice/tests/fixtures/amr/amrnb_mode0.amr"); + let encoded = BASE64.encode(amr); + + let info: CodecInfo = proxy + .call( + "CodecProbe", + (CodecInput { + audio_base64: encoded.clone(), + },), + ) + .await?; + assert_eq!(info.codec, AudioCodec::AmrNb); + assert_eq!(info.duration_ms, 500); + assert_eq!(info.sample_rate, 8_000); + assert_eq!(info.channels, 1); + + let wav: CodecOutput = proxy + .call( + "CodecTranscode", + (CodecTranscodeRequest { + audio_base64: encoded, + from: AudioCodec::AmrNb, + target: AudioCodec::WavPcm16, + },), + ) + .await?; + let wav = BASE64 + .decode(wav.audio_base64) + .expect("module returns valid base64"); + assert!(wav.starts_with(b"RIFF")); + let decoded: CodecInfo = proxy + .call( + "CodecProbe", + (CodecInput { + audio_base64: BASE64.encode(wav), + },), + ) + .await?; + assert_eq!(decoded.codec, AudioCodec::WavPcm16); + assert_eq!(decoded.duration_ms, 500); + Ok(()) +} + #[tokio::test] async fn route_carries_the_payload_for_a_parameterised_intent() -> tinybus::Result<()> { let proxy = connect().await?; diff --git a/crates/tinyvoice-module/src/service/relay_runtime/table.rs b/crates/tinyvoice-module/src/service/relay_runtime/table.rs index 6883e5c..30aff30 100644 --- a/crates/tinyvoice-module/src/service/relay_runtime/table.rs +++ b/crates/tinyvoice-module/src/service/relay_runtime/table.rs @@ -135,7 +135,7 @@ impl Turns { state.leases.retain(|_, lease| { lease.phase.lock().is_ok_and(|phase| { let keep = phase.state != RelayState::Reserved - || lease.created.elapsed() < Duration::from_secs(60); + || lease.created.elapsed() < Duration::from_mins(1); if !keep { lease.cancel(); } @@ -221,7 +221,7 @@ impl Turns { if self.closed.load(Ordering::SeqCst) || lease.cancelled.load(Ordering::SeqCst) || phase.state != RelayState::Reserved - || lease.created.elapsed() >= Duration::from_secs(60) + || lease.created.elapsed() >= Duration::from_mins(1) { return Err(tinybus::Error::failed( "relay reservation is closed or expired", diff --git a/crates/tinyvoice-module/src/service/relay_runtime/table_tests.rs b/crates/tinyvoice-module/src/service/relay_runtime/table_tests.rs index 0409f0d..b43e488 100644 --- a/crates/tinyvoice-module/src/service/relay_runtime/table_tests.rs +++ b/crates/tinyvoice-module/src/service/relay_runtime/table_tests.rs @@ -51,7 +51,7 @@ async fn reservations_require_valid_fixed_limits_and_expire_without_evicting_run .state = RelayState::Running; turns.reserve()?; assert!(turns.reserve().is_err()); - tokio::time::advance(Duration::from_secs(60)).await; + tokio::time::advance(Duration::from_mins(1)).await; turns.reserve()?; assert_eq!( turns.state.lock().map_err(|_| unavailable())?.leases.len(), @@ -107,7 +107,7 @@ async fn relay_start_rejects_bad_timing_payload_changed_or_expired_inputs_before assert!(turns.release(handle.clone()).await.is_err()); assert_eq!(turns.release(handle).await?.state, RelayState::Closed); let expired = turns.reserve()?.handle; - tokio::time::advance(Duration::from_secs(60)).await; + tokio::time::advance(Duration::from_mins(1)).await; assert!(turns.start(request(expired.clone())).is_err()); turns.release(expired).await?; let no_connection = Turns::default(); diff --git a/crates/tinyvoice/Cargo.toml b/crates/tinyvoice/Cargo.toml index bc90375..ca677a3 100644 --- a/crates/tinyvoice/Cargo.toml +++ b/crates/tinyvoice/Cargo.toml @@ -2,7 +2,7 @@ name = "tinyvoice" version = "0.3.2" edition = "2024" -rust-version = "1.88" +rust-version = "1.91" license = "GPL-3.0-only" description = "Host-agnostic voice primitives: WAV framing, silence gating, voice-activity segmentation, command intent routing, and STT hallucination detection." repository = "https://github.com/tinyhumansai/tinyvoice" @@ -16,9 +16,22 @@ categories = ["multimedia::audio"] serde_json = "1" # Shared wire payloads live in a transport-free crate; this adds no TinyBus # runtime or module SDK dependency to the voice library. -tinyvoice-bus = { version = "0.1.4", path = "../tinyvoice-bus" } +tinyvoice-bus = { version = "0.2.0", path = "../tinyvoice-bus" } # Derive macros for the crate-wide error type in `src/error/mod.rs`. thiserror = "2" +# Pure-Rust Opus encoding, decoding, and Ogg encapsulation for voice clips. +opus-pure = "0.2.2" +# Pure-Rust AAC-LC encoder for M4A output. +oxideav-aac = "0.1.7" +# Pure-Rust audio container/codecs for AAC-in-M4A and PCM WAV input. +symphonia = { version = "0.6.1", default-features = false, features = ["aac", "isomp4", "mp3", "pcm", "wav"] } +# In-memory ISO-MP4 muxing for AAC access units. +mp4 = "0.14" +# MP4 sample payload buffer type used by the in-memory muxer. +bytes = "1" +# Pure-Rust AMR-NB speech coding and RFC 4867 storage framing. Kept optional +# so minimal library consumers do not compile a codec they do not use. +codec-core = { package = "rvoip-codec-core", version = "0.4.0", default-features = false, features = ["amr-nb"], optional = true } # `hotkey` only: the global keyboard listener and its plumbing. rdev = { version = "0.5", optional = true } @@ -32,6 +45,7 @@ tokio = { version = "1", default-features = false, features = ["sync"], optional [dev-dependencies] # The `capture` tests drive async handles with `#[tokio::test]`. tokio = { version = "1", features = ["macros", "rt", "sync"] } +opus-decoder = "0.1.1" [features] default = [] @@ -42,6 +56,8 @@ hotkey = ["dep:rdev", "dep:log", "dep:parking_lot", "dep:serde", "dep:tokio"] # Off by default: opening an audio device links the platform audio stack (ALSA # on Linux). Microphone permission is passed in by the host. capture = ["dep:cpal", "dep:log", "dep:parking_lot", "dep:tokio"] +# DingTalk AMR-NB voice-note codec support. Its dependency requires Rust 1.91. +amr-nb = ["dep:codec-core"] [lints] workspace = true diff --git a/crates/tinyvoice/src/codec/README.md b/crates/tinyvoice/src/codec/README.md new file mode 100644 index 0000000..7c6b0d7 --- /dev/null +++ b/crates/tinyvoice/src/codec/README.md @@ -0,0 +1,22 @@ +# Voice codec module + +`mod.rs` owns the pure, in-memory public API for probing, transcoding, and +waveform extraction. It detects WAV/PCM16, Ogg/Opus, M4A/AAC-LC, and MP3 input; +transcode callers also provide the source format so mismatches are rejected. +MP3 is decode-only. Compressed destinations are encoded as mono 48 kHz; WAV to +WAV preserves the bytes. + +`codec_tests.rs` covers internal format constraints and malformed muxer data. +The public integration coverage is in `tests/codecs.rs`, including fixture +provenance and interoperability checks in `tests/fixtures/README.md`. + +Codec operations use pure-Rust dependencies: `opus-pure` for Opus/Ogg, +Symphonia for AAC/M4A, MP3, and WAV decoding, `oxideav-aac` for AAC-LC encoding, +and `mp4` for in-memory M4A muxing. Input is limited to 32 MiB; decoded audio is +limited to five minutes and 32 million samples, with sample rates between 8 and +192 kHz. No codec path uses filesystem, network, process, or device I/O. + +The host-facing surface is the typed `tinyvoice-bus` contract and its compiled +`tinyvoice-module` adapter. Hosts must not call this implementation crate +directly for module-owned behavior. See the [codec specification](../../../../docs/specs/voice-codecs.md) +for backend and bus details. diff --git a/crates/tinyvoice/src/codec/codec_tests.rs b/crates/tinyvoice/src/codec/codec_tests.rs new file mode 100644 index 0000000..e39fbae --- /dev/null +++ b/crates/tinyvoice/src/codec/codec_tests.rs @@ -0,0 +1,662 @@ +//! Internal codec boundaries, limits, and container-repair behavior. + +use super::*; + +#[test] +fn m4a_repair_rejects_invalid_muxer_output_without_panicking() { + assert!(repair_m4a(Vec::new(), 1).is_err()); + assert!(repair_m4a(b"not an MP4".to_vec(), 1).is_err()); +} + +#[test] +fn m4a_sl_config_descriptor_has_its_required_predefined_byte() -> Result<()> { + let (raw_m4a, duration_samples) = stereo_aac_muxed_fixture()?; + let raw_esds_type = find_last(&raw_m4a, b"esds").ok_or_else(|| codec_error("missing esds"))?; + let raw_esds_end = raw_esds_type - 4 + read_be_u32(&raw_m4a, raw_esds_type - 4)? as usize; + let raw_descriptors = raw_m4a + .get(raw_esds_type + 8..raw_esds_end) + .ok_or_else(|| codec_error("truncated raw esds"))?; + let raw_sl_config = raw_descriptors + .windows(2) + .position(|window| window == [0x06, 0x00]) + .ok_or_else(|| codec_error("missing raw SLConfig descriptor"))?; + assert_eq!( + &raw_descriptors[raw_sl_config..raw_sl_config + 3], + &[0x06, 0x00, 0x00] + ); + + let m4a = repair_m4a(raw_m4a, duration_samples)?; + let esds_type = find_last(&m4a, b"esds").ok_or_else(|| codec_error("missing esds"))?; + let esds_start = esds_type - 4; + let esds_end = esds_start + read_be_u32(&m4a, esds_start)? as usize; + let descriptor_start = esds_type + 8; + let descriptors = m4a + .get(descriptor_start..esds_end) + .ok_or_else(|| codec_error("truncated esds"))?; + let sl_config = descriptors + .windows(2) + .position(|window| window == [0x06, 0x01]) + .ok_or_else(|| codec_error("missing SLConfig descriptor"))?; + assert_eq!(&descriptors[sl_config..sl_config + 3], &[0x06, 0x01, 0x02]); + assert!( + probe(&m4a).is_ok(), + "repaired M4A remains independently parseable" + ); + Ok(()) +} + +#[test] +fn normalized_waveform_peak_uses_the_full_byte_range() { + assert_eq!(normalized_peak_byte(0.0), 0); + assert_eq!(normalized_peak_byte(0.5), 128); + assert_eq!(normalized_peak_byte(1.0), 255); + assert_eq!(normalized_peak_byte(2.0), 255); +} + +#[test] +fn decoded_duration_limit_scales_with_input_sample_rate() { + assert!(within_duration(8_000 * 300, 8_000)); + assert!(!within_duration(8_000 * 300 + 1, 8_000)); +} + +#[test] +fn aac_frame_padding_is_bounded_and_only_applies_to_the_decode_stage() { + let five_minutes = 48_000usize * 300; + assert!(within_duration_with_padding( + five_minutes + AAC_FRAME_SAMPLES, + 48_000, + AAC_FRAME_SAMPLES + )); + assert!(!within_duration_with_padding( + five_minutes + AAC_FRAME_SAMPLES + 1, + 48_000, + AAC_FRAME_SAMPLES + )); + assert!(!within_duration(five_minutes + 1, 48_000)); +} + +#[test] +fn five_minute_m4a_trims_real_aac_frame_padding_and_rejects_longer_duration() -> Result<()> { + let exact = five_minute_silent_aac_fixture(48_000 * 300)?; + assert_eq!(probe(&exact)?.duration_ms, 300_000); + + let over = five_minute_silent_aac_fixture(48_000 * 300 + 1)?; + assert!(probe(&over).is_err()); + Ok(()) +} + +#[test] +fn m4a_duration_uses_the_selected_symphonia_track_id() -> Result<()> { + let bytes = multi_track_aac_fixture()?; + let mut hint = Hint::new(); + hint.with_extension("m4a"); + let source = MediaSourceStream::new( + Box::new(Cursor::new(bytes.clone())), + symphonia::core::io::MediaSourceStreamOptions::default(), + ); + let format = symphonia::default::get_probe() + .probe( + &hint, + source, + FormatOptions::default(), + MetadataOptions::default(), + ) + .map_err(|error| codec_error(format!("test Symphonia probe failed: {error}")))?; + let selected_track_id = format + .default_track(symphonia::core::formats::TrackType::Audio) + .ok_or_else(|| codec_error("test M4A has no default audio track"))? + .id; + let mp4 = mp4::Mp4Reader::read_header(Cursor::new(bytes.clone()), bytes.len() as u64) + .map_err(|error| codec_error(format!("test MP4 parse failed: {error}")))?; + let selected = mp4 + .tracks() + .get(&selected_track_id) + .ok_or_else(|| codec_error("Symphonia ID does not match MP4 track ID"))?; + let other = mp4 + .tracks() + .values() + .find(|track| track.track_id() != selected_track_id) + .ok_or_else(|| codec_error("test M4A is missing its second audio track"))?; + assert_ne!(selected.duration(), other.duration()); + + let (_, samples, rate, _) = decode_symphonia(&bytes, AudioCodec::M4aAac, "m4a") + .map_err(|error| codec_error(format!("test M4A decode failed: {error}")))?; + let expected = m4a_playable_samples(&mp4, selected_track_id, rate, usize::MAX)?; + let other_expected = m4a_playable_samples(&mp4, other.track_id(), rate, usize::MAX)?; + assert_ne!(expected, other_expected); + assert_eq!(samples.len(), expected); + let media_duration = selected.trak.mdia.mdhd.duration; + let media_timescale = selected.trak.mdia.mdhd.timescale; + assert_eq!( + u128::try_from(expected).map_err(codec_error_from)?, + u128::from(media_duration) * u128::from(rate) / u128::from(media_timescale) + ); + Ok(()) +} + +#[test] +fn invalid_wav_sample_rates_and_channel_counts_are_rejected() -> Result<()> { + let mut low_rate = audio::f32_mono_to_wav(&[0.2; 960], VOICE_RATE)?; + low_rate[24..28].copy_from_slice(&1_000u32.to_le_bytes()); + low_rate[28..32].copy_from_slice(&2_000u32.to_le_bytes()); + assert!(probe(&low_rate).is_err()); + + let mut many_channels = audio::f32_mono_to_wav(&[0.2; 9], VOICE_RATE)?; + many_channels[22..24].copy_from_slice(&9u16.to_le_bytes()); + many_channels[28..32].copy_from_slice(&(48_000u32 * 9 * 2).to_le_bytes()); + many_channels[32..34].copy_from_slice(&(9u16 * 2).to_le_bytes()); + assert!(probe(&many_channels).is_err()); + Ok(()) +} + +#[test] +fn symphonia_rejects_the_dedicated_ogg_codec() -> Result<()> { + let wav = audio::f32_mono_to_wav(&[0.2; 960], VOICE_RATE)?; + assert!(decode_symphonia(&wav, AudioCodec::OggOpus, "wav").is_err()); + assert!(decode_symphonia(&wav, AudioCodec::M4aAac, "wav").is_err()); + Ok(()) +} + +#[test] +fn transcode_rejects_a_mismatched_source_declaration() { + assert!(transcode(b"not a wav", AudioCodec::WavPcm16, AudioCodec::OggOpus).is_err()); +} + +#[test] +fn empty_silent_oversized_and_unsupported_codec_inputs_are_rejected() -> Result<()> { + assert!(probe(b"").is_err()); + assert!(waveform(b"", 1).is_err()); + assert!(transcode(b"", AudioCodec::WavPcm16, AudioCodec::OggOpus).is_err()); + let oversized = vec![0; MAX_INPUT_BYTES + 1]; + assert!(probe(&oversized).is_err()); + assert!(transcode(&oversized, AudioCodec::WavPcm16, AudioCodec::OggOpus).is_err()); + + let empty_wav = audio::f32_mono_to_wav(&[], VOICE_RATE)?; + assert!(waveform(&empty_wav, 1).is_err()); + + let silence = audio::f32_mono_to_wav(&vec![0.0; 960], VOICE_RATE)?; + assert_eq!(waveform(&silence, 32)?, vec![0; 32]); + assert!(transcode(&silence, AudioCodec::WavPcm16, AudioCodec::Mp3).is_err()); + assert!(encode_opus(&[]).is_err()); + assert!(encode_aac_m4a(&[]).is_err()); + Ok(()) +} + +#[test] +fn adts_splitter_rejects_bad_and_truncated_frames() { + assert!(split_adts(b"not ADTS").is_err()); + assert!(split_adts(&[0xff, 0xf1, 0, 0, 0, 0]).is_err()); + assert!(split_adts(&[0xff, 0xf1, 0, 0, 0, 0, 0]).is_err()); +} + +#[test] +fn waveform_bucket_indices_use_wide_math_for_32_bit_audio_limits() -> Result<()> { + let clip_samples = 23 * 48_000; + let buckets = 4096; + let (start, end) = waveform_bucket_range(clip_samples, buckets, buckets - 1)?; + assert_eq!(start, (buckets - 1) * clip_samples / buckets); + assert_eq!(end, clip_samples); + + let sample_count = usize::try_from(u32::MAX).map_err(codec_error_from)?; + let index = buckets - 1; + assert!( + u32::try_from(sample_count) + .map_err(codec_error_from)? + .checked_mul(u32::try_from(index).map_err(codec_error_from)?) + .is_none() + ); + + let expected_start = usize::try_from( + (u128::try_from(index).map_err(codec_error_from)? + * u128::try_from(sample_count).map_err(codec_error_from)?) + / u128::try_from(buckets).map_err(codec_error_from)?, + ) + .map_err(codec_error_from)?; + let expected_end = usize::try_from( + (u128::try_from(index + 1).map_err(codec_error_from)? + * u128::try_from(sample_count).map_err(codec_error_from)?) + / u128::try_from(buckets).map_err(codec_error_from)?, + ) + .map_err(codec_error_from)?; + assert_eq!( + waveform_bucket_range(sample_count, buckets, index)?, + (expected_start, expected_end) + ); + Ok(()) +} + +#[test] +fn an_empty_ogg_opus_stream_has_no_playable_audio() -> Result<()> { + let head = OpusHead { + channel_count: 1, + pre_skip: 0, + input_sample_rate: VOICE_RATE, + output_gain_q8: 0, + mapping_family: 0, + stream_count: 1, + coupled_count: 0, + channel_mapping: vec![0], + }; + let bytes = OggOpusWriter::new(Vec::new(), head) + .map_err(codec_error_from)? + .finish() + .map_err(codec_error_from)?; + assert!(decode_opus(&bytes).is_err()); + Ok(()) +} + +#[test] +fn same_format_aac_transcode_normalizes_stereo_to_mono_48khz() -> Result<()> { + let stereo = stereo_aac_fixture()?; + let input = probe(&stereo)?; + assert_eq!(input.codec, AudioCodec::M4aAac); + assert_eq!(input.channels, 2); + + let mono = transcode(&stereo, AudioCodec::M4aAac, AudioCodec::M4aAac)?; + let output = probe(&mono)?; + assert_eq!(output.channels, 1); + assert_eq!(output.sample_rate, 48_000); + Ok(()) +} + +#[test] +#[cfg(feature = "amr-nb")] +fn amr_nb_transcode_produces_probeable_storage_audio_with_measured_duration() -> Result<()> { + let samples: Vec = (0u16..8_000) + .map(|index| { + let phase = std::f32::consts::TAU * 440.0 * f32::from(index) / 8_000.0; + phase.sin() * 0.5 + }) + .collect(); + let wav = audio::f32_mono_to_wav(&samples, 8_000)?; + + let amr = transcode(&wav, AudioCodec::WavPcm16, AudioCodec::AmrNb)?; + assert!(amr.starts_with(b"#!AMR\n"), "AMR storage header is present"); + let info = probe(&amr)?; + assert_eq!(info.codec, AudioCodec::AmrNb); + assert_eq!(info.sample_rate, 8_000); + assert_eq!(info.channels, 1); + assert_eq!(info.duration_ms, 1_000); + + let decoded_wav = transcode(&amr, AudioCodec::AmrNb, AudioCodec::WavPcm16)?; + let decoded_info = probe(&decoded_wav)?; + assert_eq!(decoded_info.codec, AudioCodec::WavPcm16); + assert_eq!(decoded_info.duration_ms, 1_000); + assert!(waveform(&amr, 16)?.iter().any(|peak| *peak > 0)); + Ok(()) +} + +#[test] +#[cfg(feature = "amr-nb")] +fn amr_nb_storage_decode_matches_pinned_3gpp_reference_pcm() -> Result<()> { + let encoded = include_bytes!("../../tests/fixtures/amr/amrnb_mode0.amr"); + let expected = include_bytes!("../../tests/fixtures/amr/amrnb_mode0_reference.pcm"); + let (codec, decoded, rate, channels) = decode_amr_nb(encoded)?; + assert_eq!(codec, AudioCodec::AmrNb); + assert_eq!(rate, AMR_NB_RATE); + assert_eq!(channels, 1); + assert_eq!(decoded.len(), expected.len() / 2); + assert_eq!(probe(encoded)?.duration_ms, 500); + let (reference_samples, reference_remainder) = expected.as_chunks::<2>(); + assert_eq!(reference_remainder, &[] as &[u8]); + for (actual, reference) in decoded.iter().zip(reference_samples) { + let pcm = i16::from_le_bytes([reference[0], reference[1]]); + assert_eq!(actual.to_bits(), (f32::from(pcm) / 32_768.0).to_bits()); + } + Ok(()) +} + +#[test] +#[cfg(feature = "amr-nb")] +fn amr_nb_storage_decode_handles_bad_quality_and_no_data_frames() -> Result<()> { + let reference = include_bytes!("../../tests/fixtures/amr/amrnb_mode0.amr"); + let mut reader = AmrStorageReader::new(reference).map_err(codec_error_from)?; + let mut frames = Vec::new(); + while let Some(frame) = reader.next_frame().map_err(codec_error_from)? { + frames.push(frame); + } + frames[0].quality_ok = false; + frames.push(AmrPayloadFrame::no_data()); + + let encoded = storage::write(AmrVariant::NarrowBand, &frames).map_err(codec_error_from)?; + let (codec, decoded, rate, channels) = decode_amr_nb(&encoded)?; + assert_eq!(codec, AudioCodec::AmrNb); + assert_eq!(rate, AMR_NB_RATE); + assert_eq!(channels, 1); + assert_eq!(decoded.len(), frames.len() * AMR_NB_FRAME_SAMPLES); + assert!(decoded.iter().all(|sample| sample.is_finite())); + assert_eq!(probe(&encoded)?.duration_ms, 520); + Ok(()) +} + +#[test] +#[cfg(feature = "amr-nb")] +fn amr_nb_storage_decode_preserves_reference_dtx_and_comfort_noise() -> Result<()> { + let encoded = include_bytes!("../../tests/fixtures/amr/amrnb_dtx_mode0.amr"); + let expected = include_bytes!("../../tests/fixtures/amr/amrnb_dtx_mode0_reference.pcm"); + let (variant, frames) = storage::read(encoded).map_err(codec_error_from)?; + assert_eq!(variant, AmrVariant::NarrowBand); + let mut frame_kinds = [0usize; 3]; + for frame in &frames { + match frame.frame_type { + AmrFrameType::Speech(_) => frame_kinds[0] += 1, + AmrFrameType::Sid(AmrVariant::NarrowBand) => frame_kinds[1] += 1, + AmrFrameType::NoData => frame_kinds[2] += 1, + other => return Err(codec_error(format!("unexpected AMR-NB frame {other:?}"))), + } + } + assert_eq!(frame_kinds, [75, 12, 63]); + + let (codec, decoded, rate, channels) = decode_amr_nb(encoded)?; + assert_eq!(codec, AudioCodec::AmrNb); + assert_eq!(rate, AMR_NB_RATE); + assert_eq!(channels, 1); + assert_eq!(probe(encoded)?.duration_ms, 3_000); + assert_eq!(expected.len(), decoded.len() * 2); + let (reference_samples, reference_remainder) = expected.as_chunks::<2>(); + assert_eq!(reference_remainder, &[] as &[u8]); + for (actual, reference) in decoded.iter().zip(reference_samples) { + let pcm = i16::from_le_bytes([reference[0], reference[1]]); + assert_eq!(actual.to_bits(), (f32::from(pcm) / 32_768.0).to_bits()); + } + + let peaks = waveform(encoded, 16)?; + assert!(peaks.iter().any(|peak| *peak > 0)); + Ok(()) +} + +#[test] +#[cfg(feature = "amr-nb")] +fn amr_nb_encoding_matches_pinned_3gpp_mode_seven_storage() -> Result<()> { + let source = include_bytes!("../../tests/fixtures/amr/amrnb_encoder_input.pcm"); + let expected = include_bytes!("../../tests/fixtures/amr/amrnb_encoder_mode7_reference.amr"); + let (source_samples, source_remainder) = source.as_chunks::<2>(); + assert_eq!(source_remainder, &[] as &[u8]); + let samples: Vec = source_samples + .iter() + .map(|pair| i16::from_le_bytes(*pair)) + .collect(); + let wav = audio::pcm16_to_wav(&samples, AMR_NB_RATE, 1)?; + let encoded = transcode(&wav, AudioCodec::WavPcm16, AudioCodec::AmrNb)?; + assert_eq!(encoded, expected); + Ok(()) +} + +#[test] +#[cfg(feature = "amr-nb")] +fn amr_nb_transcode_resamples_and_downmixes_to_the_platform_rate() -> Result<()> { + let stereo: Vec = (0usize..48_000) + .flat_map(|index| { + let sample = if (index / 24).is_multiple_of(2) { + 10_000 + } else { + -10_000 + }; + [sample, sample] + }) + .collect(); + let wav = audio::pcm16_to_wav(&stereo, 48_000, 2)?; + let amr = transcode(&wav, AudioCodec::WavPcm16, AudioCodec::AmrNb)?; + let info = probe(&amr)?; + assert_eq!(info.codec, AudioCodec::AmrNb); + assert_eq!(info.sample_rate, AMR_NB_RATE); + assert_eq!(info.channels, 1); + assert_eq!(info.duration_ms, 1_000); + Ok(()) +} + +#[test] +#[cfg(feature = "amr-nb")] +fn amr_nb_storage_rejects_truncated_reserved_and_over_duration_frames() { + let mut truncated = b"#!AMR\n".to_vec(); + truncated.push(0x04); + assert!(probe(&truncated).is_err()); + + let mut reserved = b"#!AMR\n".to_vec(); + reserved.push(9 << 3); + assert!(probe(&reserved).is_err()); + + let mut over_duration = b"#!AMR\n".to_vec(); + over_duration.extend(std::iter::repeat_n(15 << 3 | 0x04, MAX_AMR_NB_FRAMES + 1)); + assert!(probe(&over_duration).is_err()); +} + +fn stereo_aac_fixture() -> Result> { + let (bytes, duration_samples) = stereo_aac_muxed_fixture()?; + repair_m4a(bytes, duration_samples) +} + +fn stereo_aac_muxed_fixture() -> Result<(Vec, usize)> { + use bytes::Bytes; + use mp4::{ + AacConfig, AudioObjectType, ChannelConfig, FourCC, MediaConfig, Mp4Config, Mp4Sample, + Mp4Writer, SampleFreqIndex, TrackConfig, TrackType, + }; + use oxideav_aac::encoder::{EncoderConfig, StreamEncoder}; + + let pcm: Vec = (0..9_600).flat_map(|_| [6_000, 6_000]).collect(); + let mut encoder = StreamEncoder::new(EncoderConfig { + sample_rate: 48_000, + channels: 2, + bitrate: 128_000, + }) + .map_err(codec_error_from)?; + let adts = encoder.encode_all(&pcm).map_err(codec_error_from)?; + let frames = aac_frames(&adts)?; + let track = TrackConfig { + track_type: TrackType::Audio, + timescale: VOICE_RATE, + language: "und".into(), + media_conf: MediaConfig::AacConfig(AacConfig { + bitrate: 128_000, + profile: AudioObjectType::AacLowComplexity, + freq_index: SampleFreqIndex::Freq48000, + chan_conf: ChannelConfig::Stereo, + }), + }; + let config = Mp4Config { + major_brand: FourCC::from(*b"M4A "), + minor_version: 0, + compatible_brands: vec![FourCC::from(*b"M4A "), FourCC::from(*b"isom")], + timescale: VOICE_RATE, + }; + let mut writer = + Mp4Writer::write_start(Cursor::new(Vec::new()), &config).map_err(codec_error_from)?; + writer.add_track(&track).map_err(codec_error_from)?; + let mut start_time = 0u64; + for frame in frames.iter().skip(1) { + writer + .write_sample( + 1, + &Mp4Sample { + start_time, + duration: 1024, + rendering_offset: 0, + is_sync: true, + bytes: Bytes::copy_from_slice(frame), + }, + ) + .map_err(codec_error_from)?; + start_time += 1024; + } + writer.write_end().map_err(codec_error_from)?; + Ok((writer.into_writer().into_inner(), 9_600)) +} + +fn five_minute_silent_aac_fixture(duration_samples: usize) -> Result> { + use bytes::Bytes; + use mp4::{ + AacConfig, AudioObjectType, ChannelConfig, FourCC, MediaConfig, Mp4Config, Mp4Sample, + Mp4Writer, SampleFreqIndex, TrackConfig, TrackType, + }; + use oxideav_aac::encoder::{EncoderConfig, StreamEncoder}; + + let mut encoder = StreamEncoder::new(EncoderConfig { + sample_rate: 48_000, + channels: 1, + bitrate: 64_000, + }) + .map_err(codec_error_from)?; + let adts = encoder + .encode_all(&vec![0; AAC_FRAME_SAMPLES * 2]) + .map_err(codec_error_from)?; + let frame = *aac_frames(&adts)? + .get(1) + .ok_or_else(|| codec_error("test AAC encoder returned too few frames"))?; + let track = TrackConfig { + track_type: TrackType::Audio, + timescale: VOICE_RATE, + language: "und".into(), + media_conf: MediaConfig::AacConfig(AacConfig { + bitrate: 64_000, + profile: AudioObjectType::AacLowComplexity, + freq_index: SampleFreqIndex::Freq48000, + chan_conf: ChannelConfig::Mono, + }), + }; + let config = Mp4Config { + major_brand: FourCC::from(*b"M4A "), + minor_version: 0, + compatible_brands: vec![FourCC::from(*b"M4A "), FourCC::from(*b"isom")], + timescale: VOICE_RATE, + }; + let mut writer = + Mp4Writer::write_start(Cursor::new(Vec::new()), &config).map_err(codec_error_from)?; + writer.add_track(&track).map_err(codec_error_from)?; + let full_frames = duration_samples / AAC_FRAME_SAMPLES; + let final_duration = duration_samples % AAC_FRAME_SAMPLES; + let sample_count = full_frames + usize::from(final_duration > 0); + for index in 0..sample_count { + let start_time = u64::try_from(index * AAC_FRAME_SAMPLES).map_err(codec_error_from)?; + let duration = if index == full_frames && final_duration > 0 { + final_duration + } else { + AAC_FRAME_SAMPLES + }; + writer + .write_sample( + 1, + &Mp4Sample { + start_time, + duration: u32::try_from(duration).map_err(codec_error_from)?, + rendering_offset: 0, + is_sync: true, + bytes: Bytes::copy_from_slice(frame), + }, + ) + .map_err(codec_error_from)?; + } + writer.write_end().map_err(codec_error_from)?; + repair_m4a(writer.into_writer().into_inner(), duration_samples) +} + +fn multi_track_aac_fixture() -> Result> { + use bytes::Bytes; + use mp4::{ + AacConfig, AudioObjectType, ChannelConfig, FourCC, MediaConfig, Mp4Config, Mp4Sample, + Mp4Writer, SampleFreqIndex, TrackConfig, TrackType, + }; + use oxideav_aac::encoder::{EncoderConfig, StreamEncoder}; + + let mut encoder = StreamEncoder::new(EncoderConfig { + sample_rate: 48_000, + channels: 2, + bitrate: 128_000, + }) + .map_err(codec_error_from)?; + let adts = encoder + .encode_all(&vec![4_000; AAC_FRAME_SAMPLES * 12 * 2]) + .map_err(codec_error_from)?; + let frames = aac_frames(&adts)?; + let track = TrackConfig { + track_type: TrackType::Audio, + timescale: VOICE_RATE, + language: "und".into(), + media_conf: MediaConfig::AacConfig(AacConfig { + bitrate: 128_000, + profile: AudioObjectType::AacLowComplexity, + freq_index: SampleFreqIndex::Freq48000, + chan_conf: ChannelConfig::Stereo, + }), + }; + let config = Mp4Config { + major_brand: FourCC::from(*b"M4A "), + minor_version: 0, + compatible_brands: vec![FourCC::from(*b"M4A "), FourCC::from(*b"isom")], + timescale: VOICE_RATE, + }; + let mut writer = + Mp4Writer::write_start(Cursor::new(Vec::new()), &config).map_err(codec_error_from)?; + writer.add_track(&track).map_err(codec_error_from)?; + writer.add_track(&track).map_err(codec_error_from)?; + for (track_id, frame_count) in [(1, 4), (2, 9)] { + let mut start_time = 0u64; + for frame in frames.iter().skip(1).take(frame_count) { + writer + .write_sample( + track_id, + &Mp4Sample { + start_time, + duration: u32::try_from(AAC_FRAME_SAMPLES).map_err(codec_error_from)?, + rendering_offset: 0, + is_sync: true, + bytes: Bytes::copy_from_slice(frame), + }, + ) + .map_err(codec_error_from)?; + start_time += AAC_FRAME_SAMPLES as u64; + } + } + writer.write_end().map_err(codec_error_from)?; + let mut bytes = writer.into_writer().into_inner(); + repair_test_sl_config_descriptors(&mut bytes)?; + Ok(bytes) +} + +fn repair_test_sl_config_descriptors(bytes: &mut [u8]) -> Result<()> { + let esds_types: Vec = bytes + .windows(4) + .enumerate() + .filter_map(|(index, bytes)| (bytes == b"esds").then_some(index)) + .collect(); + for esds_type in esds_types { + let atom_start = esds_type + .checked_sub(4) + .ok_or_else(|| codec_error("invalid test esds atom"))?; + let atom_size = read_be_u32(bytes, atom_start)? as usize; + let atom_end = atom_start + .checked_add(atom_size) + .ok_or_else(|| codec_error("test esds size overflow"))?; + let descriptor_start = esds_type + 8; + let descriptors = bytes + .get(descriptor_start..atom_end) + .ok_or_else(|| codec_error("truncated test esds"))?; + let sl_config = descriptors + .windows(2) + .position(|window| window == [0x06, 0x00]) + .ok_or_else(|| codec_error("missing test SLConfig descriptor"))?; + bytes[descriptor_start + sl_config + 1] = 1; + bytes[descriptor_start + sl_config + 2] = 2; + } + Ok(()) +} + +fn aac_frames(adts: &[u8]) -> Result> { + let mut frames = Vec::new(); + let mut position = 0; + while position < adts.len() { + if adts.len() - position < 7 || adts[position] != 0xff { + return Err(codec_error("invalid test AAC framing")); + } + let header = if adts[position + 1] & 1 == 0 { 9 } else { 7 }; + let length = (usize::from(adts[position + 3] & 3) << 11) + | (usize::from(adts[position + 4]) << 3) + | usize::from(adts[position + 5] >> 5); + if length < header || position + length > adts.len() { + return Err(codec_error("truncated test AAC frame")); + } + frames.push(&adts[position + header..position + length]); + position += length; + } + Ok(frames) +} diff --git a/crates/tinyvoice/src/codec/mod.rs b/crates/tinyvoice/src/codec/mod.rs new file mode 100644 index 0000000..f75d75c --- /dev/null +++ b/crates/tinyvoice/src/codec/mod.rs @@ -0,0 +1,854 @@ +//! In-memory voice-note decoding, encoding, probing, and waveform extraction. +//! +//! The public functions accept byte slices and return owned values. They do no +//! filesystem, device, network, process, or runtime work. Audio is normalized +//! to mono 48 kHz for Opus/AAC output and 8 kHz for AMR-NB output. + +use std::io::Cursor; + +use opus_pure::{ + Application, MAX_PACKET_BYTES, MAX_PACKET_SAMPLES, OggOpusReader, OggOpusWriter, OpusEncoder, + OpusHead, Trim, +}; +use symphonia::core::{ + codecs::audio::AudioDecoder, + formats::{FormatOptions, FormatReader, probe::Hint}, + io::MediaSourceStream, + meta::MetadataOptions, +}; + +#[cfg(feature = "amr-nb")] +use codec_core::{ + codecs::amr::{ + AmrCodec, AmrFrameType, AmrMode, AmrPayloadFrame, AmrStorageReader, AmrVariant, storage, + }, + types::{CodecConfig, CodecType, CodedFrame, FrameKind, VariableRateCodec}, +}; + +use crate::{Error, Result, audio}; + +/// The supported voice-note container and codec combinations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum AudioCodec { + /// PCM signed 16-bit little-endian WAV. + WavPcm16, + /// Opus audio in an Ogg container. + OggOpus, + /// AAC-LC audio in an M4A/ISO-BMFF container. + M4aAac, + /// MPEG Layer III audio (typically returned by text-to-speech services). + Mp3, + /// AMR-NB audio in its storage format, used by `DingTalk` voice messages. + AmrNb, +} + +/// Container facts obtained by [`probe`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AudioInfo { + /// Detected container and codec. + pub codec: AudioCodec, + /// Decoded duration, rounded down to whole milliseconds. + pub duration_ms: u64, + /// Decoded audio sample rate in Hz. + pub sample_rate: u32, + /// Decoded channel count. + pub channels: u16, +} + +const MAX_INPUT_BYTES: usize = 32 * 1024 * 1024; +const MAX_DECODED_SAMPLES: usize = 32_000_000; +const MAX_DURATION_SECONDS: u64 = 300; +const AMR_NB_RATE: u32 = 8_000; +#[cfg(feature = "amr-nb")] +const AMR_NB_FRAME_SAMPLES: usize = 160; +#[cfg(feature = "amr-nb")] +const MAX_AMR_NB_FRAMES: usize = 15_000; +const VOICE_RATE: u32 = 48_000; +const OPUS_FRAME: usize = 960; +const AAC_FRAME_SAMPLES: usize = 1024; +const AAC_BITRATE: u32 = 64_000; + +/// Detect a supported input, validate and decode it, then report its facts. +/// +/// # Errors +/// +/// Returns [`Error::AudioCodec`] for unsupported, corrupt, truncated, or +/// over-limit media. +pub fn probe(bytes: &[u8]) -> Result { + let (codec, samples, rate, channels) = decode(bytes)?; + let duration_ms = (samples.len() as u64).saturating_mul(1000) / u64::from(rate); + Ok(AudioInfo { + codec, + duration_ms, + sample_rate: rate, + channels, + }) +} + +/// Convert bytes in `from` format to the requested voice-note format. +/// +/// The declared source format is checked against the encoded input. Encoded +/// Opus/AAC output is mono 48 kHz; AMR-NB output is mono 8 kHz in 20 ms +/// frames. WAV-to-WAV requests preserve the original bytes. MP3 is accepted as +/// a source format but is not an encoding target. +/// +/// # Errors +/// +/// Returns [`Error::AudioCodec`] when input or output audio cannot be handled. +pub fn transcode(bytes: &[u8], from: AudioCodec, target: AudioCodec) -> Result> { + let (source, samples, rate, _) = decode_as(bytes, from)?; + if source == target && source == AudioCodec::WavPcm16 { + return Ok(bytes.to_vec()); + } + let target_rate = if target == AudioCodec::AmrNb { + AMR_NB_RATE + } else { + VOICE_RATE + }; + let samples = if rate == target_rate { + samples + } else { + audio::resample(&samples, rate, target_rate)? + }; + match target { + AudioCodec::WavPcm16 => audio::f32_mono_to_wav(&samples, VOICE_RATE), + AudioCodec::OggOpus => encode_opus(&samples), + AudioCodec::M4aAac => encode_aac_m4a(&samples), + AudioCodec::Mp3 => Err(codec_error("MP3 encoding is not supported")), + AudioCodec::AmrNb => encode_amr_nb(&samples), + } +} + +/// Return normalized peak-amplitude bytes for a decoded waveform. +/// +/// The output has exactly `buckets` entries. Each entry is a peak amplitude +/// normalized against the clip's loudest bucket, matching the compact byte +/// shape used by voice-note clients. +/// +/// # Errors +/// +/// Returns [`Error::AudioCodec`] for invalid audio or when `buckets` is zero or +/// exceeds 4096. +pub fn waveform(bytes: &[u8], buckets: usize) -> Result> { + if buckets == 0 || buckets > 4096 { + return Err(codec_error( + "waveform bucket count must be between 1 and 4096", + )); + } + let (_, samples, rate, _) = decode(bytes)?; + let samples = if rate == VOICE_RATE { + samples + } else { + audio::resample(&samples, rate, VOICE_RATE)? + }; + if samples.is_empty() { + return Err(codec_error("audio contains no samples")); + } + let mut peaks = Vec::with_capacity(buckets); + for bucket in 0..buckets { + let (start, end) = waveform_bucket_range(samples.len(), buckets, bucket)?; + let peak = samples[start..end] + .iter() + .fold(0.0f32, |largest, sample| largest.max(sample.abs())); + peaks.push(peak); + } + let max_peak = peaks.iter().copied().fold(0.0f32, f32::max); + if max_peak == 0.0 { + return Ok(vec![0; buckets]); + } + Ok(peaks + .into_iter() + .map(|peak| normalized_peak_byte(peak / max_peak)) + .collect()) +} + +fn waveform_bucket_range( + sample_count: usize, + bucket_count: usize, + index: usize, +) -> Result<(usize, usize)> { + let sample_count_u128 = u128::try_from(sample_count).map_err(codec_error_from)?; + let bucket_count_u128 = u128::try_from(bucket_count).map_err(codec_error_from)?; + let index_u128 = u128::try_from(index).map_err(codec_error_from)?; + let start = usize::try_from(index_u128 * sample_count_u128 / bucket_count_u128) + .map_err(codec_error_from)?; + let end = usize::try_from((index_u128 + 1) * sample_count_u128 / bucket_count_u128) + .map_err(codec_error_from)?; + Ok((start, end.max(start.saturating_add(1)).min(sample_count))) +} + +/// Convert a known finite amplitude ratio in `[0, 1]` to its byte scale. +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn normalized_peak_byte(ratio: f32) -> u8 { + (ratio.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +fn decode(bytes: &[u8]) -> Result<(AudioCodec, Vec, u32, u16)> { + if bytes.is_empty() || bytes.len() > MAX_INPUT_BYTES { + return Err(codec_error( + "audio input is empty or exceeds the 32 MiB limit", + )); + } + if bytes.starts_with(b"OggS") { + decode_opus(bytes) + .map(|(samples, rate, channels)| (AudioCodec::OggOpus, samples, rate, channels)) + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE") { + decode_symphonia(bytes, AudioCodec::WavPcm16, "wav") + } else if bytes.get(4..8) == Some(b"ftyp") { + decode_symphonia(bytes, AudioCodec::M4aAac, "m4a") + } else if looks_like_mp3(bytes) { + decode_symphonia(bytes, AudioCodec::Mp3, "mp3") + } else if bytes.starts_with(b"#!AMR\n") { + decode_amr_nb(bytes) + } else { + Err(codec_error("unsupported audio container")) + } +} + +fn decode_as(bytes: &[u8], expected: AudioCodec) -> Result<(AudioCodec, Vec, u32, u16)> { + if bytes.is_empty() || bytes.len() > MAX_INPUT_BYTES { + return Err(codec_error( + "audio input is empty or exceeds the 32 MiB limit", + )); + } + let decoded = match expected { + AudioCodec::OggOpus if bytes.starts_with(b"OggS") => decode_opus(bytes) + .map(|(samples, rate, channels)| (AudioCodec::OggOpus, samples, rate, channels)), + AudioCodec::WavPcm16 if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE") => { + decode_symphonia(bytes, AudioCodec::WavPcm16, "wav") + } + AudioCodec::M4aAac if bytes.get(4..8) == Some(b"ftyp") => { + decode_symphonia(bytes, AudioCodec::M4aAac, "m4a") + } + AudioCodec::Mp3 if looks_like_mp3(bytes) => decode_symphonia(bytes, AudioCodec::Mp3, "mp3"), + AudioCodec::AmrNb if bytes.starts_with(b"#!AMR\n") => decode_amr_nb(bytes), + _ => { + return Err(codec_error( + "audio input does not match the declared source codec", + )); + } + }?; + Ok(decoded) +} + +fn looks_like_mp3(bytes: &[u8]) -> bool { + bytes.starts_with(b"ID3") + || bytes + .get(..2) + .is_some_and(|header| header[0] == 0xff && header[1] & 0xe0 == 0xe0) +} + +#[cfg(feature = "amr-nb")] +fn encode_amr_nb(samples: &[f32]) -> Result> { + if samples.is_empty() { + return Err(codec_error("audio contains no samples")); + } + let frame_count = samples.len().div_ceil(AMR_NB_FRAME_SAMPLES); + if frame_count > MAX_AMR_NB_FRAMES { + return Err(codec_error("AMR-NB audio exceeds the five minute limit")); + } + + let mut amr_codec = + AmrCodec::new(&CodecConfig::new(CodecType::AmrNb)).map_err(codec_error_from)?; + let mut frames = Vec::with_capacity(frame_count); + for chunk in samples.chunks(AMR_NB_FRAME_SAMPLES) { + let mut pcm = [0i16; AMR_NB_FRAME_SAMPLES]; + for (destination, source) in pcm.iter_mut().zip(chunk) { + *destination = pcm16_sample(*source); + } + let coded_frame = amr_codec.encode_frame(&pcm).map_err(codec_error_from)?; + let frame_type = match coded_frame.kind { + FrameKind::Speech => AmrFrameType::Speech( + AmrMode::new(AmrVariant::NarrowBand, coded_frame.mode).map_err(codec_error_from)?, + ), + FrameKind::ComfortNoise => AmrFrameType::Sid(AmrVariant::NarrowBand), + FrameKind::NoData => AmrFrameType::NoData, + FrameKind::Lost => return Err(codec_error("AMR-NB encoder returned a lost frame")), + }; + frames.push( + AmrPayloadFrame::new(frame_type, coded_frame.quality_ok, coded_frame.data) + .map_err(codec_error_from)?, + ); + } + storage::write(AmrVariant::NarrowBand, &frames).map_err(codec_error_from) +} + +#[cfg(not(feature = "amr-nb"))] +fn encode_amr_nb(_samples: &[f32]) -> Result> { + Err(codec_error("AMR-NB codec support is not enabled")) +} + +#[cfg(feature = "amr-nb")] +fn decode_amr_nb(bytes: &[u8]) -> Result<(AudioCodec, Vec, u32, u16)> { + let mut validation = AmrStorageReader::new(bytes).map_err(codec_error_from)?; + if validation.variant() != AmrVariant::NarrowBand { + return Err(codec_error("AMR-WB storage is not supported")); + } + let mut frame_count = 0usize; + while validation.next_frame().map_err(codec_error_from)?.is_some() { + frame_count = frame_count + .checked_add(1) + .ok_or_else(|| codec_error("AMR-NB frame count overflow"))?; + if frame_count > MAX_AMR_NB_FRAMES { + return Err(codec_error("AMR-NB audio exceeds the five minute limit")); + } + } + if frame_count == 0 { + return Err(codec_error("AMR-NB file contains no audio frames")); + } + + let mut reader = AmrStorageReader::new(bytes).map_err(codec_error_from)?; + let mut amr_codec = + AmrCodec::new(&CodecConfig::new(CodecType::AmrNb)).map_err(codec_error_from)?; + let mut samples = Vec::with_capacity(frame_count * AMR_NB_FRAME_SAMPLES); + while let Some(frame) = reader.next_frame().map_err(codec_error_from)? { + let coded_frame = match frame.frame_type { + AmrFrameType::Speech(mode) => CodedFrame::speech(mode.index(), frame.data), + AmrFrameType::Sid(AmrVariant::NarrowBand) => CodedFrame::comfort_noise(frame.data), + AmrFrameType::NoData => CodedFrame::no_data(), + AmrFrameType::SpeechLost | AmrFrameType::Sid(AmrVariant::WideBand) => { + return Err(codec_error("AMR storage frame does not match AMR-NB")); + } + }; + let coded_frame = if frame.quality_ok { + coded_frame + } else { + coded_frame.with_bad_quality() + }; + let decoded = amr_codec + .decode_frame(&coded_frame) + .map_err(codec_error_from)?; + if decoded.len() != AMR_NB_FRAME_SAMPLES { + return Err(codec_error("AMR-NB decoder returned an invalid frame size")); + } + samples.extend( + decoded + .into_iter() + .map(|sample| f32::from(sample) / 32_768.0), + ); + } + Ok((AudioCodec::AmrNb, samples, AMR_NB_RATE, 1)) +} + +#[cfg(not(feature = "amr-nb"))] +fn decode_amr_nb(_bytes: &[u8]) -> Result<(AudioCodec, Vec, u32, u16)> { + Err(codec_error("AMR-NB codec support is not enabled")) +} + +fn decode_opus(bytes: &[u8]) -> Result<(Vec, u32, u16)> { + let mut reader = OggOpusReader::new(Cursor::new(bytes.to_vec())).map_err(codec_error_from)?; + let head = reader.head().clone(); + let channels = usize::from(head.channel_count); + if channels == 0 || channels > 2 { + return Err(codec_error("voice Opus supports one or two channels")); + } + let mut decoder = head.decoder(48_000).map_err(codec_error_from)?; + let mut trim = Trim::new(&head, 48_000, channels).map_err(codec_error_from)?; + let mut pcm = vec![0.0; MAX_PACKET_SAMPLES * channels]; + let mut mono = Vec::new(); + let mut saw_packet = false; + let mut saw_eos = false; + while let Some(packet) = reader.read_packet().map_err(codec_error_from)? { + saw_packet = true; + saw_eos |= packet.end_of_stream; + let count = decoder + .decode(&packet.data, MAX_PACKET_SAMPLES, &mut pcm) + .map_err(codec_error_from)?; + let trimmed = trim.keep(&packet, &pcm[..count * channels]); + for frame in trimmed.chunks_exact(channels) { + mono.push(frame.iter().sum::() / f32::from(head.channel_count)); + } + if !within_duration(mono.len(), VOICE_RATE) { + return Err(codec_error("decoded audio exceeds the five minute limit")); + } + } + if !saw_packet || !saw_eos || mono.is_empty() { + return Err(codec_error( + "Ogg Opus stream is truncated or contains no audio", + )); + } + Ok(( + mono, + VOICE_RATE, + u16::try_from(channels).map_err(codec_error_from)?, + )) +} + +fn decode_symphonia( + bytes: &[u8], + codec: AudioCodec, + extension: &str, +) -> Result<(AudioCodec, Vec, u32, u16)> { + let mut hint = Hint::new(); + hint.with_extension(extension); + let source = MediaSourceStream::new( + Box::new(Cursor::new(bytes.to_vec())), + symphonia::core::io::MediaSourceStreamOptions::default(), + ); + let mut format = symphonia::default::get_probe() + .probe( + &hint, + source, + FormatOptions::default(), + MetadataOptions::default(), + ) + .map_err(|error| codec_error(format!("audio container probe failed: {error}")))?; + let track = format + .default_track(symphonia::core::formats::TrackType::Audio) + .ok_or_else(|| codec_error("audio stream has no default track"))?; + let track_id = track.id; + let codec_params = track + .codec_params + .as_ref() + .and_then(|params| params.audio()) + .ok_or_else(|| codec_error("audio codec parameters are missing"))? + .clone(); + let expected_codec = match codec { + AudioCodec::WavPcm16 => symphonia::core::codecs::audio::well_known::CODEC_ID_PCM_S16LE, + AudioCodec::M4aAac => symphonia::core::codecs::audio::well_known::CODEC_ID_AAC, + AudioCodec::Mp3 => symphonia::core::codecs::audio::well_known::CODEC_ID_MP3, + AudioCodec::OggOpus => return Err(codec_error("Ogg Opus uses its dedicated decoder")), + AudioCodec::AmrNb => return Err(codec_error("AMR-NB uses its dedicated decoder")), + }; + if codec_params.codec != expected_codec { + return Err(codec_error( + "audio stream does not match its declared codec", + )); + } + let rate = codec_params + .sample_rate + .ok_or_else(|| codec_error("audio sample rate is missing"))?; + if !(8_000..=192_000).contains(&rate) { + return Err(codec_error( + "audio sample rate must be between 8 and 192 kHz", + )); + } + let mut decoder = symphonia::default::get_codecs() + .make_audio_decoder( + &codec_params, + &symphonia::core::codecs::audio::AudioDecoderOptions::default(), + ) + .map_err(codec_error_from)?; + let decoder_padding = if codec == AudioCodec::M4aAac { + AAC_FRAME_SAMPLES + } else { + 0 + }; + let (mut samples, channel_count) = + decode_packets(&mut *format, track_id, &mut *decoder, rate, decoder_padding)?; + if samples.is_empty() { + return Err(codec_error("audio contains no samples")); + } + if codec == AudioCodec::M4aAac { + let mp4 = mp4::Mp4Reader::read_header(Cursor::new(bytes.to_vec()), bytes.len() as u64) + .map_err(codec_error_from)?; + let playable_samples = m4a_playable_samples(&mp4, track_id, rate, samples.len())?; + if !within_duration(playable_samples, rate) || playable_samples > MAX_DECODED_SAMPLES { + return Err(codec_error("decoded audio exceeds the five minute limit")); + } + samples.truncate(playable_samples); + if samples.is_empty() { + return Err(codec_error("M4A audio duration is empty")); + } + } + Ok(( + codec, + samples, + rate, + u16::try_from(channel_count).map_err(codec_error_from)?, + )) +} + +fn m4a_playable_samples( + mp4: &mp4::Mp4Reader>>, + track_id: u32, + rate: u32, + decoded_samples: usize, +) -> Result { + let audio_track = mp4 + .tracks() + .get(&track_id) + .filter(|track| { + track + .track_type() + .is_ok_and(|kind| kind == mp4::TrackType::Audio) + }) + .ok_or_else(|| codec_error("decoded M4A audio track metadata is missing"))?; + let edit_list = audio_track + .trak + .edts + .as_ref() + .and_then(|edts| edts.elst.as_ref()); + let (duration_units, duration_timescale) = if let Some(elst) = edit_list { + let duration_units = elst + .entries + .iter() + .try_fold(0u64, |total, entry| { + total.checked_add(entry.segment_duration) + }) + .ok_or_else(|| codec_error("M4A edit list duration overflows"))?; + (duration_units, mp4.timescale()) + } else { + ( + audio_track.trak.mdia.mdhd.duration, + audio_track.trak.mdia.mdhd.timescale, + ) + }; + if duration_timescale == 0 + || u128::from(duration_units) + > u128::from(MAX_DURATION_SECONDS) * u128::from(duration_timescale) + { + return Err(codec_error("M4A duration exceeds the five minute limit")); + } + Ok( + (u128::from(duration_units) * u128::from(rate) / u128::from(duration_timescale)) + .min(decoded_samples as u128) as usize, + ) +} + +fn decode_packets( + format: &mut dyn FormatReader, + track_id: u32, + decoder: &mut dyn AudioDecoder, + rate: u32, + decoder_padding: usize, +) -> Result<(Vec, usize)> { + let mut samples = Vec::new(); + let mut channel_count = 0usize; + loop { + let packet = match format.next_packet() { + Ok(Some(packet)) => packet, + Ok(None) => break, + Err(error) => { + return Err(codec_error(format!( + "audio container packet read failed: {error}" + ))); + } + }; + if packet.track_id != track_id { + continue; + } + let audio_buffer = decoder + .decode(&packet) + .map_err(|error| codec_error(format!("audio packet decode failed: {error}")))?; + let channels = audio_buffer.spec().channels().count(); + if channels == 0 || channels > 8 { + return Err(codec_error("audio channel count is unsupported")); + } + channel_count = channels; + let mut interleaved = vec![0.0f32; audio_buffer.samples_interleaved()]; + audio_buffer.copy_to_slice_interleaved::(&mut interleaved); + for frame in interleaved.chunks_exact(channels) { + samples.push( + frame.iter().sum::() + / f32::from(u8::try_from(channels).map_err(codec_error_from)?), + ); + } + if !within_duration_with_padding(samples.len(), rate, decoder_padding) + || samples.len() > MAX_DECODED_SAMPLES + { + return Err(codec_error("decoded audio exceeds the five minute limit")); + } + } + Ok((samples, channel_count)) +} + +fn encode_opus(samples: &[f32]) -> Result> { + if samples.is_empty() { + return Err(codec_error("audio contains no samples")); + } + let mut encoder = OpusEncoder::new(48_000, 1, Application::Voip).map_err(codec_error_from)?; + encoder.bitrate_bps = 24_000; + let head = OpusHead::for_encoder(&encoder, VOICE_RATE); + let mut writer = OggOpusWriter::new(Vec::new(), head.clone()).map_err(codec_error_from)?; + let needed_granule = u64::from(head.pre_skip) + samples.len() as u64; + let packet_count = usize::try_from(needed_granule.div_ceil(960)).map_err(codec_error_from)?; + let mut packet = vec![0; MAX_PACKET_BYTES]; + for index in 0..packet_count { + let start = index * OPUS_FRAME; + let end = (start + OPUS_FRAME).min(samples.len()); + let mut frame = [0.0f32; OPUS_FRAME]; + if start < samples.len() { + frame[..end - start].copy_from_slice(&samples[start..end]); + } + let count = encoder + .encode(&frame, OPUS_FRAME, &mut packet) + .map_err(codec_error_from)?; + let duration = if index + 1 == packet_count { + u32::try_from(needed_granule.saturating_sub(writer.granule().cast_unsigned())) + .map_err(codec_error_from)? + } else { + 960 + }; + writer + .write_packet_with_duration(&packet[..count], duration) + .map_err(codec_error_from)?; + } + writer.finish().map_err(codec_error_from) +} + +fn encode_aac_m4a(samples: &[f32]) -> Result> { + use bytes::Bytes; + use mp4::{ + AacConfig, AudioObjectType, ChannelConfig, FourCC, MediaConfig, Mp4Config, Mp4Sample, + Mp4Writer, SampleFreqIndex, TrackConfig, TrackType, + }; + use oxideav_aac::encoder::{EncoderConfig, StreamEncoder}; + + if samples.is_empty() { + return Err(codec_error("audio contains no samples")); + } + let mut encoder = StreamEncoder::new(EncoderConfig { + sample_rate: VOICE_RATE, + channels: 1, + bitrate: AAC_BITRATE, + }) + .map_err(codec_error_from)?; + let pcm: Vec = samples.iter().map(|sample| pcm16_sample(*sample)).collect(); + let adts = encoder.encode_all(&pcm).map_err(codec_error_from)?; + let frames = split_adts(&adts)?; + if frames.len() < 2 { + return Err(codec_error("AAC encoder returned too few frames")); + } + let config = AacConfig { + bitrate: AAC_BITRATE, + profile: AudioObjectType::AacLowComplexity, + freq_index: SampleFreqIndex::Freq48000, + chan_conf: ChannelConfig::Mono, + }; + let track = TrackConfig { + track_type: TrackType::Audio, + timescale: VOICE_RATE, + language: "und".into(), + media_conf: MediaConfig::AacConfig(config), + }; + let mp4_config = Mp4Config { + major_brand: FourCC::from(*b"M4A "), + minor_version: 0, + compatible_brands: vec![FourCC::from(*b"M4A "), FourCC::from(*b"isom")], + timescale: VOICE_RATE, + }; + let mut writer = + Mp4Writer::write_start(Cursor::new(Vec::new()), &mp4_config).map_err(codec_error_from)?; + writer.add_track(&track).map_err(codec_error_from)?; + let usable = &frames[1..]; + let mut start_time = 0u64; + for frame in usable { + let duration = 1024; + writer + .write_sample( + 1, + &Mp4Sample { + start_time, + duration, + rendering_offset: 0, + is_sync: true, + bytes: Bytes::copy_from_slice(frame), + }, + ) + .map_err(codec_error_from)?; + start_time += u64::from(duration); + } + writer.write_end().map_err(codec_error_from)?; + repair_m4a(writer.into_writer().into_inner(), samples.len()) +} + +/// Repair the MP4 muxer's empty `SLConfig` descriptor and add a gapless edit. +/// +/// `mp4` 0.14 writes an empty-valued `SLConfig` descriptor placeholder, while +/// ISO/IEC 14496-1 requires its one-byte predefined value. Fill that existing +/// byte without changing atom sizes. The edit list clips AAC's 1024-sample +/// frame padding to the original PCM duration. +#[allow(clippy::too_many_lines)] +fn repair_m4a(mut bytes: Vec, duration_samples: usize) -> Result> { + let esds_type = find_last(&bytes, b"esds") + .ok_or_else(|| codec_error("M4A muxer omitted the AAC decoder configuration"))?; + let esds_start = esds_type + .checked_sub(4) + .ok_or_else(|| codec_error("invalid M4A AAC decoder configuration"))?; + let descriptor_end = esds_start + .checked_add(read_be_u32(&bytes, esds_start)? as usize) + .ok_or_else(|| codec_error("M4A AAC decoder configuration size overflow"))?; + let descriptor_start = esds_type + .checked_add(8) + .ok_or_else(|| codec_error("M4A descriptor offset overflow"))?; + let descriptors = bytes + .get(descriptor_start..descriptor_end) + .ok_or_else(|| codec_error("truncated M4A AAC descriptors"))?; + let sl_config = descriptors + .windows(3) + .rposition(|window| window == [0x06, 0x00, 0x00]) + .and_then(|offset| descriptor_start.checked_add(offset)) + .ok_or_else(|| codec_error("M4A AAC decoder configuration has no SLConfig descriptor"))?; + *bytes + .get_mut(sl_config + 1) + .ok_or_else(|| codec_error("truncated M4A SLConfig descriptor"))? = 1; + *bytes + .get_mut(sl_config + 2) + .ok_or_else(|| codec_error("truncated M4A SLConfig predefined value"))? = 2; + + let mdia_type = + find_last(&bytes, b"mdia").ok_or_else(|| codec_error("M4A media atom is missing"))?; + let trak_type = find_last(&bytes[..mdia_type], b"trak") + .ok_or_else(|| codec_error("M4A track atom is missing"))?; + let movie_type = + find_last(&bytes, b"moov").ok_or_else(|| codec_error("M4A movie atom is missing"))?; + let edit = edit_list_atom(duration_samples)?; + let edit_len = u32::try_from(edit.len()).map_err(codec_error_from)?; + for atom_type in [b"trak".as_slice(), b"moov"] { + let position = if atom_type == b"trak" { + trak_type + } else { + movie_type + }; + let size_position = position + .checked_sub(4) + .ok_or_else(|| codec_error("invalid M4A atom position"))?; + let size = read_be_u32(&bytes, size_position)?; + let size_end = size_position + .checked_add(4) + .ok_or_else(|| codec_error("M4A atom position overflow"))?; + bytes + .get_mut(size_position..size_end) + .ok_or_else(|| codec_error("truncated M4A atom size"))? + .copy_from_slice( + &size + .checked_add(edit_len) + .ok_or_else(|| codec_error("M4A atom is too large"))? + .to_be_bytes(), + ); + } + let tkhd_type = find_last(&bytes[..mdia_type], b"tkhd") + .ok_or_else(|| codec_error("M4A track header is missing"))?; + let tkhd_duration = tkhd_type + .checked_add(24) + .ok_or_else(|| codec_error("M4A track header offset overflow"))?; + let tkhd_end = tkhd_duration + .checked_add(4) + .ok_or_else(|| codec_error("M4A track header offset overflow"))?; + bytes + .get_mut(tkhd_duration..tkhd_end) + .ok_or_else(|| codec_error("truncated M4A track header"))? + .copy_from_slice( + &u32::try_from(duration_samples) + .map_err(codec_error_from)? + .to_be_bytes(), + ); + let mvhd_type = find_last(&bytes[..mdia_type], b"mvhd") + .ok_or_else(|| codec_error("M4A movie header is missing"))?; + let mvhd_duration = mvhd_type + .checked_add(20) + .ok_or_else(|| codec_error("M4A movie header offset overflow"))?; + let mvhd_end = mvhd_duration + .checked_add(4) + .ok_or_else(|| codec_error("M4A movie header offset overflow"))?; + bytes + .get_mut(mvhd_duration..mvhd_end) + .ok_or_else(|| codec_error("truncated M4A movie header"))? + .copy_from_slice( + &u32::try_from(duration_samples) + .map_err(codec_error_from)? + .to_be_bytes(), + ); + let edit_position = mdia_type + .checked_sub(4) + .ok_or_else(|| codec_error("invalid M4A media atom position"))?; + bytes.splice(edit_position..edit_position, edit); + Ok(bytes) +} + +fn edit_list_atom(duration_samples: usize) -> Result> { + let duration = u32::try_from(duration_samples).map_err(codec_error_from)?; + let mut elst = Vec::with_capacity(28); + elst.extend_from_slice(&[0, 0, 0, 0]); // version and flags + elst.extend_from_slice(&1u32.to_be_bytes()); + elst.extend_from_slice(&duration.to_be_bytes()); + elst.extend_from_slice(&0i32.to_be_bytes()); // no leading media-time trim + elst.extend_from_slice(&1i16.to_be_bytes()); // playback rate 1.0 + elst.extend_from_slice(&0i16.to_be_bytes()); + let elst = atom(*b"elst", &elst)?; + atom(*b"edts", &elst) +} + +fn atom(kind: [u8; 4], payload: &[u8]) -> Result> { + let size = u32::try_from( + payload + .len() + .checked_add(8) + .ok_or_else(|| codec_error("M4A atom size overflow"))?, + ) + .map_err(codec_error_from)?; + let mut out = Vec::with_capacity(size as usize); + out.extend_from_slice(&size.to_be_bytes()); + out.extend_from_slice(&kind); + out.extend_from_slice(payload); + Ok(out) +} + +fn find_last(bytes: &[u8], needle: &[u8]) -> Option { + bytes + .windows(needle.len()) + .rposition(|window| window == needle) +} + +fn read_be_u32(bytes: &[u8], offset: usize) -> Result { + let end = offset + .checked_add(4) + .ok_or_else(|| codec_error("M4A atom offset overflow"))?; + let data = bytes + .get(offset..end) + .ok_or_else(|| codec_error("truncated M4A atom"))?; + Ok(u32::from_be_bytes([data[0], data[1], data[2], data[3]])) +} + +fn split_adts(bytes: &[u8]) -> Result> { + let mut frames = Vec::new(); + let mut position = 0; + while position < bytes.len() { + if bytes.len() - position < 7 + || bytes[position] != 0xff + || bytes[position + 1] & 0xf6 != 0xf0 + { + return Err(codec_error("AAC encoder returned invalid ADTS framing")); + } + let header = if bytes[position + 1] & 1 == 0 { 9 } else { 7 }; + let length = (usize::from(bytes[position + 3] & 3) << 11) + | (usize::from(bytes[position + 4]) << 3) + | usize::from(bytes[position + 5] >> 5); + if length < header || position + length > bytes.len() { + return Err(codec_error("AAC encoder returned a truncated ADTS frame")); + } + frames.push(&bytes[position + header..position + length]); + position += length; + } + Ok(frames) +} + +fn codec_error(message: impl Into) -> Error { + Error::AudioCodec(message.into()) +} +fn codec_error_from(error: impl std::fmt::Display) -> Error { + codec_error(error.to_string()) +} + +fn within_duration(samples: usize, sample_rate: u32) -> bool { + within_duration_with_padding(samples, sample_rate, 0) +} + +fn within_duration_with_padding(samples: usize, sample_rate: u32, padding_samples: usize) -> bool { + let maximum = u64::from(sample_rate) + .saturating_mul(MAX_DURATION_SECONDS) + .saturating_add(u64::try_from(padding_samples).unwrap_or(u64::MAX)); + u64::try_from(samples).is_ok_and(|count| count <= maximum) +} + +/// Convert clamped normalized PCM to the encoder's signed integer range. +#[allow(clippy::cast_possible_truncation)] +fn pcm16_sample(sample: f32) -> i16 { + (sample.clamp(-1.0, 1.0) * 32767.0).round() as i16 +} + +#[cfg(test)] +#[path = "codec_tests.rs"] +mod tests; diff --git a/crates/tinyvoice/src/error/mod.rs b/crates/tinyvoice/src/error/mod.rs index 85be001..14eb7a8 100644 --- a/crates/tinyvoice/src/error/mod.rs +++ b/crates/tinyvoice/src/error/mod.rs @@ -57,6 +57,10 @@ pub enum Error { /// A microphone capture operation failed. #[error("capture failed: {0}")] Capture(String), + + /// An encoded audio container or codec could not be read or written. + #[error("audio codec error: {0}")] + AudioCodec(String), } /// The result type returned by every fallible function in this crate. diff --git a/crates/tinyvoice/src/lib.rs b/crates/tinyvoice/src/lib.rs index 3759b41..b0d7218 100644 --- a/crates/tinyvoice/src/lib.rs +++ b/crates/tinyvoice/src/lib.rs @@ -59,6 +59,7 @@ mod error; pub mod audio; #[cfg(feature = "capture")] pub mod capture; +pub mod codec; #[cfg(feature = "hotkey")] pub mod hotkey; pub mod intent; diff --git a/crates/tinyvoice/tests/codecs.rs b/crates/tinyvoice/tests/codecs.rs new file mode 100644 index 0000000..9779d50 --- /dev/null +++ b/crates/tinyvoice/tests/codecs.rs @@ -0,0 +1,241 @@ +//! Public API coverage for voice-note codecs using a checked-in WAV fixture. + +use std::io::Cursor; +use tinyvoice::Result; +use tinyvoice::codec::{self, AudioCodec}; + +const VOICE_ENVELOPE_FIXTURE: &[u8] = include_bytes!("fixtures/voice-envelope.wav"); +const OPUS_INTEROP_FIXTURE: &[u8] = include_bytes!("fixtures/opus-interop.wav"); +const MP3_FIXTURE: &[u8] = include_bytes!("fixtures/mp3-test.bit"); + +fn window_rms(bytes: &[u8], sample_rate: u32, window_ms: usize) -> Vec { + let (sample_bytes, _trailing_byte) = bytes.as_chunks::<2>(); + let samples = sample_bytes + .iter() + .map(|sample| f64::from(i16::from_le_bytes(*sample)) / f64::from(i16::MAX)) + .collect::>(); + let window = sample_rate as usize * window_ms / 1_000; + samples + .chunks(window) + .map(|window| { + let (sum, count) = window.iter().fold((0.0, 0.0), |(sum, count), sample| { + (sum + sample * sample, count + 1.0) + }); + (sum / count).sqrt() + }) + .collect() +} + +fn assert_envelope_retained(reference: &[u8], reference_rate: u32, decoded: &[u8]) { + let expected = window_rms(reference, reference_rate, 40); + let actual = window_rms(decoded, 48_000, 40); + assert_eq!( + actual.len(), + expected.len(), + "decoded clip duration changed" + ); + let scale = expected.iter().sum::() / actual.iter().sum::(); + let normalized_error = expected + .iter() + .zip(actual.iter()) + .map(|(expected, actual)| (expected - actual * scale).abs()) + .sum::() + / expected.iter().sum::(); + assert!( + normalized_error < 0.22, + "voice envelope drifted: {normalized_error:.3}" + ); + assert!( + actual.iter().any(|rms| *rms > 0.05), + "decoded signal is silent" + ); +} + +#[test] +fn probe_waveform_and_passthrough_work_for_a_real_wav_fixture() -> Result<()> { + let info = codec::probe(VOICE_ENVELOPE_FIXTURE)?; + assert_eq!(info.codec, AudioCodec::WavPcm16); + assert_eq!(info.sample_rate, 44_100); + assert_eq!(info.channels, 1); + assert!(info.duration_ms > 2_300); + + let peaks = codec::waveform(VOICE_ENVELOPE_FIXTURE, 256)?; + assert_eq!(peaks.len(), 256); + assert!(peaks.iter().any(|peak| *peak > 0)); + assert_eq!( + codec::transcode( + VOICE_ENVELOPE_FIXTURE, + AudioCodec::WavPcm16, + AudioCodec::WavPcm16 + )?, + VOICE_ENVELOPE_FIXTURE + ); + Ok(()) +} + +#[test] +fn opus_and_aac_voice_notes_round_trip_through_independent_container_decoders() -> Result<()> { + let opus = codec::transcode( + OPUS_INTEROP_FIXTURE, + AudioCodec::WavPcm16, + AudioCodec::OggOpus, + )?; + assert!(opus.starts_with(b"OggS")); + let opus_info = codec::probe(&opus)?; + assert_eq!(opus_info.codec, AudioCodec::OggOpus); + assert_eq!(opus_info.sample_rate, 48_000); + assert_eq!(opus_info.channels, 1); + assert!(opus_info.duration_ms > 0); + + // Decode the emitted packets with a separately implemented Opus decoder; + // this guards against an encoder/decoder pair agreeing on invalid packets. + let mut packet_reader = opus_pure::OggOpusReader::new(Cursor::new(opus.clone())) + .map_err(|error| tinyvoice::Error::AudioCodec(error.to_string()))?; + let mut independent = opus_decoder::OpusDecoder::new(48_000, 1) + .map_err(|error| tinyvoice::Error::AudioCodec(error.to_string()))?; + let mut independent_samples = Vec::new(); + while let Some(packet) = packet_reader + .read_packet() + .map_err(|error| tinyvoice::Error::AudioCodec(error.to_string()))? + { + let mut frame = vec![0.0; opus_decoder::OpusDecoder::MAX_FRAME_SIZE_48K]; + let count = independent + .decode_float(&packet.data, &mut frame, false) + .map_err(|error| tinyvoice::Error::AudioCodec(error.to_string()))?; + independent_samples.extend_from_slice(&frame[..count]); + } + assert!( + independent_samples + .iter() + .any(|sample| sample.abs() > 0.001) + ); + + let m4a = codec::transcode(&opus, AudioCodec::OggOpus, AudioCodec::M4aAac)?; + assert!(m4a.windows(4).any(|window| window == b"ftyp")); + let aac_info = codec::probe(&m4a)?; + assert_eq!(aac_info.codec, AudioCodec::M4aAac); + assert_eq!(aac_info.sample_rate, 48_000); + assert_eq!(aac_info.channels, 1); + assert!(aac_info.duration_ms > 0); + assert!(aac_info.duration_ms.abs_diff(opus_info.duration_ms) <= 1); + assert!(codec::waveform(&m4a, 256)?.iter().any(|peak| *peak > 0)); + + let wav = codec::transcode(&m4a, AudioCodec::M4aAac, AudioCodec::WavPcm16)?; + assert!(wav.starts_with(b"RIFF")); + let wav_info = codec::probe(&wav)?; + assert_eq!(wav_info.codec, AudioCodec::WavPcm16); + assert_eq!(wav_info.sample_rate, 48_000); + assert!(wav_info.duration_ms.abs_diff(opus_info.duration_ms) <= 1); + Ok(()) +} + +#[test] +fn multiframe_voice_survives_opus_aac_round_trip_with_gapless_duration() -> Result<()> { + let original = codec::transcode( + VOICE_ENVELOPE_FIXTURE, + AudioCodec::WavPcm16, + AudioCodec::WavPcm16, + )?; + let original_info = codec::probe(&original)?; + assert!(original_info.duration_ms > 2_300); + + let opus = codec::transcode(&original, AudioCodec::WavPcm16, AudioCodec::OggOpus)?; + let aac = codec::transcode(&opus, AudioCodec::OggOpus, AudioCodec::M4aAac)?; + let wav = codec::transcode(&aac, AudioCodec::M4aAac, AudioCodec::WavPcm16)?; + let decoded_info = codec::probe(&wav)?; + assert_eq!(decoded_info.sample_rate, 48_000); + assert!(decoded_info.duration_ms.abs_diff(original_info.duration_ms) <= 1); + assert_envelope_retained(&original[44..], original_info.sample_rate, &wav[44..]); + Ok(()) +} + +#[test] +fn mp3_tts_source_decodes_and_transcodes_to_voice_formats() -> Result<()> { + let info = codec::probe(MP3_FIXTURE)?; + assert_eq!(info.codec, AudioCodec::Mp3); + assert!(info.duration_ms > 0); + + let opus = codec::transcode(MP3_FIXTURE, AudioCodec::Mp3, AudioCodec::OggOpus)?; + let opus_info = codec::probe(&opus)?; + assert_eq!(opus_info.channels, 1); + assert_eq!(opus_info.sample_rate, 48_000); + + let wav = codec::transcode(MP3_FIXTURE, AudioCodec::Mp3, AudioCodec::WavPcm16)?; + let wav_info = codec::probe(&wav)?; + assert_eq!(wav_info.channels, 1); + assert_eq!(wav_info.sample_rate, 48_000); + assert!(wav_info.duration_ms.abs_diff(info.duration_ms) <= 30); + let aac = codec::transcode(MP3_FIXTURE, AudioCodec::Mp3, AudioCodec::M4aAac)?; + let aac_info = codec::probe(&aac)?; + assert_eq!(aac_info.codec, AudioCodec::M4aAac); + assert_eq!(aac_info.sample_rate, 48_000); + assert!(aac_info.duration_ms.abs_diff(wav_info.duration_ms) <= 1); + let aac_wav = codec::transcode(&aac, AudioCodec::M4aAac, AudioCodec::WavPcm16)?; + assert_envelope_retained(&wav[44..], wav_info.sample_rate, &aac_wav[44..]); + assert!(codec::transcode(MP3_FIXTURE, AudioCodec::WavPcm16, AudioCodec::OggOpus).is_err()); + Ok(()) +} + +#[test] +fn same_format_opus_transcode_normalizes_stereo_to_mono_48khz() -> Result<()> { + let mut encoder = opus_pure::OpusEncoder::new(48_000, 2, opus_pure::Application::Voip) + .map_err(|error| tinyvoice::Error::AudioCodec(error.to_string()))?; + let head = opus_pure::OpusHead::for_encoder(&encoder, 48_000); + let mut writer = opus_pure::OggOpusWriter::new(Vec::new(), head.clone()) + .map_err(|error| tinyvoice::Error::AudioCodec(error.to_string()))?; + let pcm: Vec = (0..960).flat_map(|_| [0.1, 0.1]).collect(); + let mut packet = vec![0; opus_pure::MAX_PACKET_BYTES]; + let count = encoder + .encode(&pcm, 960, &mut packet) + .map_err(|error| tinyvoice::Error::AudioCodec(error.to_string()))?; + writer + .write_packet_with_duration(&packet[..count], 960) + .map_err(|error| tinyvoice::Error::AudioCodec(error.to_string()))?; + let stereo = writer + .finish() + .map_err(|error| tinyvoice::Error::AudioCodec(error.to_string()))?; + + let mono = codec::transcode(&stereo, AudioCodec::OggOpus, AudioCodec::OggOpus)?; + let info = codec::probe(&mono)?; + assert_eq!(info.codec, AudioCodec::OggOpus); + assert_eq!(info.channels, 1); + assert_eq!(info.sample_rate, 48_000); + Ok(()) +} + +#[test] +fn malformed_and_truncated_audio_are_rejected() -> Result<()> { + assert!(codec::probe(b"not audio").is_err()); + assert!(codec::probe(&VOICE_ENVELOPE_FIXTURE[..VOICE_ENVELOPE_FIXTURE.len() - 10]).is_err()); + assert!(codec::waveform(VOICE_ENVELOPE_FIXTURE, 0).is_err()); + assert!(codec::waveform(VOICE_ENVELOPE_FIXTURE, 4097).is_err()); + let truncated_opus = codec::transcode( + VOICE_ENVELOPE_FIXTURE, + AudioCodec::WavPcm16, + AudioCodec::OggOpus, + )?; + assert!(codec::probe(&truncated_opus[..truncated_opus.len() / 2]).is_err()); + assert!( + codec::transcode( + &truncated_opus[..truncated_opus.len() / 2], + AudioCodec::OggOpus, + AudioCodec::WavPcm16, + ) + .is_err() + ); + let truncated_aac = codec::transcode( + VOICE_ENVELOPE_FIXTURE, + AudioCodec::WavPcm16, + AudioCodec::M4aAac, + )?; + assert!(codec::probe(&truncated_aac[..truncated_aac.len() / 2]).is_err()); + assert!( + codec::transcode( + &truncated_aac[..truncated_aac.len() / 2], + AudioCodec::M4aAac, + AudioCodec::WavPcm16, + ) + .is_err() + ); + Ok(()) +} diff --git a/crates/tinyvoice/tests/fixtures/CC0-1.0.txt b/crates/tinyvoice/tests/fixtures/CC0-1.0.txt new file mode 100644 index 0000000..670154e --- /dev/null +++ b/crates/tinyvoice/tests/fixtures/CC0-1.0.txt @@ -0,0 +1,116 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see + diff --git a/crates/tinyvoice/tests/fixtures/README.md b/crates/tinyvoice/tests/fixtures/README.md new file mode 100644 index 0000000..7d95482 --- /dev/null +++ b/crates/tinyvoice/tests/fixtures/README.md @@ -0,0 +1,40 @@ +# Codec test fixtures + +`voice-envelope.wav` is a deterministic, locally generated 2.370-second mono +PCM voice-band signal. Its harmonics and alternating voiced/silent sections +exercise multiframe encoding, duration clipping, and envelope retention without +network access or third-party audio provenance. + +`opus-interop.wav` is a deterministic, locally generated 2.5-millisecond mono +PCM tone used to exercise the independent Opus decoder against one encoder +packet. Longer multiframe Opus/AAC paths use `voice-envelope.wav`. + +`mp3-test.bit` is the Layer III test stream `vectors/l3-si.bit` from +[minimp3](https://github.com/lieff/minimp3), checked in as a source-format +fixture to cover common `audio/mpeg` text-to-speech responses. The upstream +repository dedicates its contents to CC0; the notice is included as +[`CC0-1.0.txt`](CC0-1.0.txt). + +`amr/amrnb_mode0.amr` and `amr/amrnb_mode0_reference.pcm` are the mode-0 +AMR-NB storage stream and expected decoded PCM from +[`rvoip-codec-core` 0.4.0](https://github.com/eisenzopf/rvoip/tree/f4d528eaabbac935bce9a6404d3ce4fdec35ed0f), pinned at +source commit `f4d528eaabbac935bce9a6404d3ce4fdec35ed0f`. The upstream +[decoder test](https://github.com/eisenzopf/rvoip/blob/f4d528eaabbac935bce9a6404d3ce4fdec35ed0f/crates/media/codec-core/src/codecs/amr/mod.rs#L954) +identifies the PCM as output from the 3GPP TS 26.073 reference decoder. The +published crate is MIT-licensed; this test-only fixture is retained with that +attribution in [`amr/LICENSE-MIT.txt`](amr/LICENSE-MIT.txt). The AMR file is +331 bytes and the PCM is 8,000 bytes (4,000 samples, or 500 ms of 8 kHz mono +signed 16-bit audio). + +`amr/amrnb_encoder_input.pcm` and `amr/amrnb_encoder_mode7_reference.amr` +are the matching 3GPP encoder input and mode-7 reference output from the same +crate and source commit. They pin AMR storage encoding, not only self-roundtrip +behavior. + +`amr/amrnb_dtx_mode0.amr` and `amr/amrnb_dtx_mode0_reference.pcm` are the +mode-0 AMR-NB discontinuous-transmission stream and expected decoded PCM from +the same pinned crate and source commit. The upstream decoder conformance test +uses this stream to exercise speech, SID comfort-noise, and `NO_DATA` frames; +the 1,116-byte AMR stream carries 150 frames (3 seconds at 8 kHz mono), and +the 48,000-byte PCM reference contains the corresponding 24,000 signed +16-bit samples. They are retained under the same MIT attribution above. diff --git a/crates/tinyvoice/tests/fixtures/amr/LICENSE-MIT.txt b/crates/tinyvoice/tests/fixtures/amr/LICENSE-MIT.txt new file mode 100644 index 0000000..cdc300d --- /dev/null +++ b/crates/tinyvoice/tests/fixtures/amr/LICENSE-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jonathan Eisenzopf + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/tinyvoice/tests/fixtures/amr/amrnb_dtx_mode0.amr b/crates/tinyvoice/tests/fixtures/amr/amrnb_dtx_mode0.amr new file mode 100644 index 0000000..4647311 Binary files /dev/null and b/crates/tinyvoice/tests/fixtures/amr/amrnb_dtx_mode0.amr differ diff --git a/crates/tinyvoice/tests/fixtures/amr/amrnb_dtx_mode0_reference.pcm b/crates/tinyvoice/tests/fixtures/amr/amrnb_dtx_mode0_reference.pcm new file mode 100644 index 0000000..2271949 Binary files /dev/null and b/crates/tinyvoice/tests/fixtures/amr/amrnb_dtx_mode0_reference.pcm differ diff --git a/crates/tinyvoice/tests/fixtures/amr/amrnb_encoder_input.pcm b/crates/tinyvoice/tests/fixtures/amr/amrnb_encoder_input.pcm new file mode 100644 index 0000000..c44bb17 Binary files /dev/null and b/crates/tinyvoice/tests/fixtures/amr/amrnb_encoder_input.pcm differ diff --git a/crates/tinyvoice/tests/fixtures/amr/amrnb_encoder_mode7_reference.amr b/crates/tinyvoice/tests/fixtures/amr/amrnb_encoder_mode7_reference.amr new file mode 100644 index 0000000..8f77210 Binary files /dev/null and b/crates/tinyvoice/tests/fixtures/amr/amrnb_encoder_mode7_reference.amr differ diff --git a/crates/tinyvoice/tests/fixtures/amr/amrnb_mode0.amr b/crates/tinyvoice/tests/fixtures/amr/amrnb_mode0.amr new file mode 100644 index 0000000..7901dc5 --- /dev/null +++ b/crates/tinyvoice/tests/fixtures/amr/amrnb_mode0.amr @@ -0,0 +1,2 @@ +#!AMR +Ù°$Oïañ ¨ìˆEOÝî£5PD�ÈE-iÞÏuX1ƒE-žÎWDeÇ�QÚE->銓 Ä‚E-œÎge݉$ftE-¬ÞQu:HH‡E-iÙÓ=Ö`BÒE-k¾Ku{ÁÚ!dE-=™Hà @E-ª>tEµÑ…vEKe>G>Ø(‡ˆbò—?¥ Sü�ñ•oóÀùúè1ÂE-j¾„ya\øE-j¾DÄ=1„NdE-«ÞÛ$—#€E-mÞ¨µ;¾*œE-iî«åÚ’AÂE-®ÙŠº$’Ì`E-žî–TEÓ$æ4E-mÞ‹…?P’E-nî¨Õ_Ö‘ÂE-kÞKu[ÝÚ!dE-­ÉWB`gHd \ No newline at end of file diff --git a/crates/tinyvoice/tests/fixtures/amr/amrnb_mode0_reference.pcm b/crates/tinyvoice/tests/fixtures/amr/amrnb_mode0_reference.pcm new file mode 100644 index 0000000..9b983dc Binary files /dev/null and b/crates/tinyvoice/tests/fixtures/amr/amrnb_mode0_reference.pcm differ diff --git a/crates/tinyvoice/tests/fixtures/mp3-test.bit b/crates/tinyvoice/tests/fixtures/mp3-test.bit new file mode 100644 index 0000000..94a5ef8 Binary files /dev/null and b/crates/tinyvoice/tests/fixtures/mp3-test.bit differ diff --git a/crates/tinyvoice/tests/fixtures/opus-interop.wav b/crates/tinyvoice/tests/fixtures/opus-interop.wav new file mode 100644 index 0000000..f36205f Binary files /dev/null and b/crates/tinyvoice/tests/fixtures/opus-interop.wav differ diff --git a/crates/tinyvoice/tests/fixtures/voice-envelope.wav b/crates/tinyvoice/tests/fixtures/voice-envelope.wav new file mode 100644 index 0000000..a0b69be Binary files /dev/null and b/crates/tinyvoice/tests/fixtures/voice-envelope.wav differ diff --git a/docs/specs/module-capture.md b/docs/specs/module-capture.md index 9d8c315..d3a3b9a 100644 --- a/docs/specs/module-capture.md +++ b/docs/specs/module-capture.md @@ -76,13 +76,13 @@ names, error detail, handles, audio, paths and request arguments. Local fixtures exercise lifecycle, bounds, faults and in-memory bus dispatch without hardware or external services. The actual compiled artifact verifier -checks all 53 declared members and permission denial without opening a device. +checks all 56 declared members and permission denial without opening a device. The native device bridge follows the repository's existing physical-device coverage exception; manager and processing code remain subject to the 90% per-file threshold. The release workflow installs ALSA development headers for Linux module builds. -OpenHuman must pin a released artifact compatible with current contract 1.10 +OpenHuman must pin a released artifact compatible with current contract 1.12 (this capture slice was introduced in 1.3) and verify its digest before switching callers. Module-owned hotkeys are specified in `hotkey-module-lifecycle.md`. diff --git a/docs/specs/voice-codecs.md b/docs/specs/voice-codecs.md new file mode 100644 index 0000000..b50df59 --- /dev/null +++ b/docs/specs/voice-codecs.md @@ -0,0 +1,44 @@ +# Voice-note codecs + +The `tinyvoice::codec` library API accepts complete in-memory byte slices. It +detects WAV/PCM16, Ogg/Opus, M4A/AAC-LC, MPEG Layer III, and (with the +`amr-nb` feature) AMR-NB storage input, and exposes +`probe`, `transcode(bytes, from, to)`, and `waveform`. The declared source +format is checked against the input bytes; MP3 is decode-only. It performs no +filesystem, network, process, or device I/O. Decoding is bounded to 32 MiB of +input, five minutes of audio, and 32 million decoded samples. Sample rates +outside 8–192 kHz are rejected. +Opus and AAC voice encodes are mono 48 kHz; Opus uses the VoIP profile at +24 kbps and AAC-LC uses 64 kbps. AMR-NB uses mono 8 kHz, 20 ms frames, and the +RFC 4867 storage format (`#!AMR\n`); its measured duration comes from decoded +frames and rounds up to the next 20 ms frame. WAV-to-WAV passthrough preserves +the original bytes. + +Opus encoding, decoding, and Ogg framing use `opus-pure`; M4A demux and AAC +decode use Symphonia; AAC-LC encoding uses `oxideav-aac`; and the `mp4` crate +muxes AAC access units. These are Rust dependencies enabled by default in the +library, so codec support is available without a system FFmpeg install or +native codec library. The optional `amr-nb` feature uses the pure-Rust +`rvoip-codec-core` implementation and raises the library and module minimum +Rust version to 1.91. A Linux x86_64 release build of `tinyvoice-module` +measured 20.8 MiB total / 7.8 MiB `.text` with `cargo bloat`; its report +attributed 618.7 KiB `.text` to `opus-pure` and 118.7 KiB to `oxideav-aac` +(Symphonia's decoder is split across several crates). This is the complete +module size, not the codec-only delta; measure product artifacts before +setting a package-size budget. The codecs remain in the `tinyvoice` library, +while module hosts call them through `CodecProbe`, `CodecTranscode`, and +`CodecWaveform` in the minimal `tinyvoice-bus` contract. + +`tinyvoice-module` transports complete media as Base64 and caps each request +and output at 8 MiB to stay within bus-message limits. The library itself +accepts up to 32 MiB for non-bus consumers. Waveforms contain one normalized +peak byte per requested bucket, with a maximum of 4096 buckets; 256 buckets +match Discord's voice-message waveform shape. + +Integration tests use the checked-in samples documented in +[`tests/fixtures/README.md`](../../crates/tinyvoice/tests/fixtures/README.md), +round-trip through encoded formats, compare AMR-NB against pinned 3GPP reference +storage and PCM vectors, decode Opus with a second pure-Rust implementation and +M4A with Symphonia, and exercise truncation and bucket bounds. CI builds the +AMR-enabled library and module on the declared Rust 1.91 minimum, then covers +the supported platform matrix. diff --git a/vendor/tinybus b/vendor/tinybus index eb5cf72..aa36051 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit eb5cf727071a6eb12f9efa3db8ec59e54039f336 +Subproject commit aa36051e2d384b09a1fb7871758fcf22c2a7e709