diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7c83ea..9798e3f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,9 +3,19 @@ on: workflow_dispatch: inputs: tag: - description: Existing version tag (vX.Y.Z) committed through a reviewed PR + description: Existing version tag for the immutable module release type: string required: true + release_mode: + description: Use the ordinary main path or reviewed-epic release admission + type: choice + options: [main, reviewed-epic] + default: main + required: true + reviewed_pr: + description: Positive canonical PR number whose merge commit the tag names (reviewed-epic mode only) + type: number + required: false push: tags: ['v*'] permissions: {} @@ -18,11 +28,15 @@ jobs: outputs: tag: ${{ steps.tag.outputs.tag }} version: ${{ steps.tag.outputs.version }} + release_mode: ${{ steps.tag.outputs.release_mode }} + reviewed_pr: ${{ steps.tag.outputs.reviewed_pr }} steps: - id: tag shell: bash env: TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} + RELEASE_MODE: ${{ github.event_name == 'push' && 'main' || inputs.release_mode }} + REVIEWED_PR: ${{ inputs.reviewed_pr }} run: | set -euo pipefail [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]] || { @@ -31,14 +45,49 @@ jobs: } echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + [[ "$RELEASE_MODE" == main || "$RELEASE_MODE" == reviewed-epic ]] || { + echo "release_mode must be main or reviewed-epic" >&2 + exit 2 + } + if [[ "$RELEASE_MODE" == reviewed-epic ]]; then + [[ "$REVIEWED_PR" =~ ^[1-9][0-9]*$ ]] || { + echo "reviewed_pr must be a positive integer in reviewed-epic mode" >&2 + exit 2 + } + else + [[ -z "$REVIEWED_PR" || "$REVIEWED_PR" == 0 ]] || { + echo "reviewed_pr is only valid in reviewed-epic mode" >&2 + exit 2 + } + REVIEWED_PR="" + fi + { + echo "release_mode=$RELEASE_MODE" + # The reusable workflow input is numeric; zero is the ordinary-path sentinel. + echo "reviewed_pr=${REVIEWED_PR:-0}" + } >> "$GITHUB_OUTPUT" module-release: needs: resolve + if: needs.resolve.outputs.release_mode == 'main' permissions: contents: write - uses: tinyhumansai/.github/.github/workflows/module-release.yml@4adc1080fb495098e7af1aade82ed7e6e3c69bb7 + uses: tinyhumansai/.github/.github/workflows/module-release.yml@1e28317c435a9c714002199e65d18a1067fa5777 with: tag: ${{ needs.resolve.outputs.tag }} version: ${{ needs.resolve.outputs.version }} module-crate: tinyvoice-module apt-packages: libasound2-dev libx11-dev libxtst-dev libxi-dev module-manifest: crates/tinyvoice-module/Cargo.toml + module-reviewed-epic-release: + needs: resolve + if: needs.resolve.outputs.release_mode == 'reviewed-epic' + permissions: + contents: write + pull-requests: read + checks: read + actions: read + uses: tinyhumansai/.github/.github/workflows/module-reviewed-epic-release.yml@1e28317c435a9c714002199e65d18a1067fa5777 + with: + tag: ${{ needs.resolve.outputs.tag }} + version: ${{ needs.resolve.outputs.version }} + reviewed-pr: ${{ fromJSON(needs.resolve.outputs.reviewed_pr || '0') }} diff --git a/Cargo.lock b/Cargo.lock index f41156b..550dbdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,12 +24,29 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -48,6 +65,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -200,12 +226,41 @@ dependencies = [ "windows-core", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "dasp_sample" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -261,6 +316,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -742,6 +807,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -832,6 +908,19 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tinybus" +version = "0.3.2" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.21", + "tokio", + "tracing", +] + [[package]] name = "tinyvoice" version = "0.3.2" @@ -856,12 +945,26 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "tinyvoice-client" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64", + "serde", + "serde_json", + "tinybus", + "tinyvoice-bus", + "tokio", +] + [[package]] name = "tokio" version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", "pin-project-lite", "tokio-macros", ] @@ -907,12 +1010,55 @@ 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", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 1a02754..d985718 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,8 @@ resolver = "3" # Every package lives under `crates/`. The loadable module is a nested # workspace because the vendored TinyBus manifests inherit their own workspace # metadata; build it through its own manifest. -members = ["crates/tinyvoice", "crates/tinyvoice-bus"] -default-members = ["crates/tinyvoice", "crates/tinyvoice-bus"] +members = ["crates/tinyvoice", "crates/tinyvoice-bus", "crates/tinyvoice-client"] +default-members = ["crates/tinyvoice", "crates/tinyvoice-bus", "crates/tinyvoice-client"] exclude = ["crates/tinyvoice-module", "vendor", "worktrees"] # Lints apply to the whole crate and to every target. CI runs clippy with diff --git a/README.md b/README.md index 19e4f3a..6098e77 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Ships two things from one repository: | --- | --- | --- | | `tinyvoice` (`crates/`) | `rlib` | A host that links the logic in-process | | `tinyvoice-bus` (`crates/`) | `rlib` | A host that only needs TinyBus names and value types | +| `tinyvoice-client` (`crates/`) | `rlib` | A typed TinyBus client for the loadable TinyVoice module | | `tinyvoice-module` (`crates/`) | `cdylib` | A host that loads it over the TinyBus module ABI | ## What belongs here, and what does not diff --git a/crates/tinyvoice-client/Cargo.toml b/crates/tinyvoice-client/Cargo.toml new file mode 100644 index 0000000..72901dd --- /dev/null +++ b/crates/tinyvoice-client/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "tinyvoice-client" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +license = "GPL-3.0-only" +description = "Typed TinyBus host client for the TinyVoice module." +repository = "https://github.com/tinyhumansai/tinyvoice" + +[dependencies] +# The contract carries audio samples as base64 text. +base64 = "0.22" +# TinyBus serializes contract values and deserializes their replies. +serde = "1" +# The module's transforms and stateful replies are inner JSON strings. +serde_json = "1" +# Shared TinyVoice member names, request types, and typed replies. +tinyvoice-bus = { version = "0.1.4", path = "../tinyvoice-bus" } +# TinyBus 0.3.2 adds ordinary peer-pinned clients for stateful voice leases. +tinybus = { version = ">=0.3.2, <0.4", path = "../../vendor/tinybus/crates/tinybus", default-features = false } + +[dev-dependencies] +# Implements the async in-memory service used by the broker tests. +async-trait = "0.1" +# Provides the async test executor and `#[tokio::test]` macro. +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "test-util"] } + +[lints] +workspace = true diff --git a/crates/tinyvoice-client/README.md b/crates/tinyvoice-client/README.md new file mode 100644 index 0000000..5d365c0 --- /dev/null +++ b/crates/tinyvoice-client/README.md @@ -0,0 +1,49 @@ +# tinyvoice-client + +`tinyvoice-client` is the typed host adapter for the loadable TinyVoice module. +It depends on `tinyvoice-bus` for the wire contract and TinyBus SDK for module +calls. It does not depend on the host-agnostic `tinyvoice` implementation or +the loadable `tinyvoice-module` crate. + +Construct it with `TinyVoiceClient::new(module_client)` using the host's one +configured `ModuleClient`. Stateless calls continue through that configured +client and preserve each contract member's positional tuple and typed reply. +Stateful owners call TinyBus 0.3.2's ordinary `ModuleClient::bind` once at +creation and retain the resulting private peer-pinned client for later calls. +This is ordinary bus routing: it pins the current unique peer through alias +handover, but does not load an artifact proof or make an attestation claim. + +`VadSession`, `RecorderSession`, `OwnedCaptureStream`, and +`OwnedHotkeySession` are explicit async leases. Their opaque resource handles +stay private. Closing an owner calls the corresponding module method on its +pinned peer; dropping a Rust handle does not imply remote cleanup. The existing +flat capture and hotkey methods remain compatibility calls and do not pin a +resource owner. OpenHuman keeps host policy such as volume clamping, microphone +permission, hotkey policy, and conversion from persisted configuration into +`VadConfig` in its own adapter. + +`reserve_capture_owned` returns a pinned reservation before `CAPTURE_START`. +Keep that owner while calling `start`; after a timeout or cancellation, its +explicit `close` targets the creating peer and uses the module's existing +pending-start cancellation path. A successful start returns an +`OwnedCaptureStream` for polling and explicit stop. A reservation that never +starts can be released explicitly with the same owner's `close`. If the +reservation reply is lost before the caller learns its handle, the module's +existing expiry still bounds that unknown reservation. The flat +`reserve_capture`, `start_capture`, `poll_capture`, and `stop_capture` methods +remain available for compatibility; they use whichever peer the supplied +ordinary client addresses at each call. + +The client keeps the configured `ModuleClient` deadline and reporting sink. +It does not add a second timeout. `VoiceCallError::kind()` exposes the closed +failure category; its `Debug` and `Display` omit remote prose. The explicit +`raw_message()` and `raw_wire_name()` accessors return untrusted remote values +and require a caller-owned redaction policy before display or logging. + +The recording-broker tests use only TinyBus's in-memory transport. They record +actual service invocations and verify wire arguments, returned types, peer +handover, and owner-close behavior; they do not load a published artifact, +access a microphone, or contact a backend. + +The client and module manifests target the published TinyBus 0.3.2 line, and +the vendored gitlink and both Cargo lockfiles resolve to that source. diff --git a/crates/tinyvoice-client/src/lib.rs b/crates/tinyvoice-client/src/lib.rs new file mode 100644 index 0000000..6c46dae --- /dev/null +++ b/crates/tinyvoice-client/src/lib.rs @@ -0,0 +1,1049 @@ +//! Typed host calls to the `TinyVoice` `TinyBus` module. +//! +//! The client owns the method names, positional payloads, and conversions at +//! the module boundary. Hosts provide one configured +//! [`ModuleClient`]; loading, deadlines, and +//! terminal reporting remain `TinyBus` responsibilities. + +use std::fmt; + +use serde::{Serialize, de::DeserializeOwned}; +use tinybus::module::{ModuleCallError, ModuleCallFailure, ModuleClient, PinnedModuleClient}; + +pub use tinyvoice_bus as contract; +pub use tinyvoice_bus::capture::{ + CaptureBatch, CaptureError, CaptureFormat, CaptureHandle, CapturePollRequest, CaptureResult, + CaptureStream, MicrophonePermission, RawChunk, RecordingFinishRequest, RecordingStartRequest, +}; +pub use tinyvoice_bus::hotkey::{ + ActivationMode, HostKeyFact, HotkeyBatch, HotkeyError, HotkeyEvent, HotkeyFeedRequest, + HotkeyHandle, HotkeyHandleRequest, HotkeyReadRequest, HotkeyReply, HotkeyRequest, + HotkeyReserveRequest, HotkeySource, HotkeyStartRequest, HotkeyState, SequencedHostFact, +}; +pub use tinyvoice_bus::transcript::Mode as HallucinationMode; +pub use tinyvoice_bus::{ + GateVerdict, IndexedVadEvent, RecordingGates, SessionAction, SessionAdvance, SessionInput, + SessionPhase, SessionStatus, UtteranceDisposition, VadConfig, VadEvent, VoiceIntent, +}; + +/// Registry id of the loadable module these calls use. +pub const MODULE_ID: &str = "tinyvoice"; + +/// Result of a typed `TinyVoice` operation. +pub type Result = std::result::Result; + +/// A terminal `TinyBus` failure with its untrusted remote prose kept private by +/// default. +/// +/// `Debug`, `Display`, and `TinyBus` terminal reports contain only the closed +/// error classification. A caller that has an explicit user-facing policy may +/// retrieve remote prose through [`raw_message`](Self::raw_message); it must +/// not log that value or forward it to telemetry without redaction. +#[derive(Clone, PartialEq, Eq)] +pub struct VoiceCallError { + kind: ModuleCallError, + wire_name: String, + message: String, +} + +impl VoiceCallError { + fn from_failure(failure: ModuleCallFailure) -> Self { + Self { + kind: failure.kind, + wire_name: failure.wire_name, + message: failure.message, + } + } + + fn local(kind: ModuleCallError, wire_name: &'static str, message: String) -> Self { + Self { + kind, + wire_name: wire_name.to_owned(), + message, + } + } + + /// Return the closed error category without exposing remote prose. + #[must_use] + pub const fn kind(&self) -> ModuleCallError { + self.kind + } + + /// Return the remote wire error name for an explicitly handled failure. + /// + /// This value is untrusted remote input. Do not log or report it without + /// applying a redaction policy. + #[must_use] + pub fn raw_wire_name(&self) -> &str { + &self.wire_name + } + + /// Return the remote error prose for an explicitly handled failure. + /// + /// This value can contain call arguments or credentials. Do not log or + /// report it without applying a redaction policy. + #[must_use] + pub fn raw_message(&self) -> &str { + &self.message + } +} + +impl fmt::Debug for VoiceCallError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VoiceCallError") + .field("kind", &self.kind) + .field("wire_name", &"") + .field("message", &"") + .finish() + } +} + +impl fmt::Display for VoiceCallError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.kind.fmt(formatter) + } +} + +impl std::error::Error for VoiceCallError {} + +/// Typed `TinyVoice` calls over one host-configured `TinyBus` module client. +#[derive(Clone)] +pub struct TinyVoiceClient { + module: ModuleClient, + pinned: Option, +} + +impl fmt::Debug for TinyVoiceClient { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TinyVoiceClient") + .field("module_id", &MODULE_ID) + .finish_non_exhaustive() + } +} + +impl TinyVoiceClient { + /// Wrap the host's configured `TinyBus` client. + /// + /// This stores the given client and does not open a connection or create a + /// second resolver. Calls use its module access, reporting sink, fixture, + /// and configured deadline. + #[must_use] + pub fn new(module: ModuleClient) -> Self { + Self { + module, + pinned: None, + } + } + + async fn bind(&self) -> Result { + let pinned = self + .module + .bind(MODULE_ID) + .await + .map_err(VoiceCallError::from_failure)?; + Ok(Self { + module: self.module.clone(), + pinned: Some(pinned), + }) + } + + /// Classify a wake-word-stripped transcript into a fast-path intent. + /// + /// Unknown intent variants from a newer compatible module decode to + /// [`VoiceIntent::Unknown`]. + /// + /// # Errors + /// + /// Returns the sanitized `TinyBus` failure category. Its raw remote details + /// are available only through the explicit getters on [`VoiceCallError`]. + pub async fn route(&self, transcript: &str) -> Result { + self.call_json( + tinyvoice_bus::names::methods::ROUTE, + (transcript,), + "intent", + ) + .await + } + + /// Sequence a transcript through the module's wake-word gate. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure or a sanitized malformed-reply + /// error. + pub async fn sequence_utterance( + &self, + transcript: &str, + wake_word: &str, + ) -> Result { + self.call_json( + tinyvoice_bus::names::methods::SEQUENCE_UTTERANCE, + (transcript, wake_word), + "utterance disposition", + ) + .await + } + + /// Apply the module's minimum-duration and silence gates. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure or a sanitized malformed-reply + /// error. + pub async fn gate_recording( + &self, + duration_secs: f32, + peak_rms: f32, + gates: RecordingGates, + ) -> Result { + self.call_json( + tinyvoice_bus::names::methods::GATE_RECORDING, + ( + duration_secs, + peak_rms, + gates.min_duration_secs, + gates.silence_threshold, + ), + "recording gate verdict", + ) + .await + } + + /// Check whether a transcript is a likely speech-recognition hallucination. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure. + pub async fn is_hallucinated(&self, text: &str, mode: HallucinationMode) -> Result { + self.call( + tinyvoice_bus::names::methods::IS_HALLUCINATED, + (text, hallucination_mode_wire(mode)), + ) + .await + } + + /// Downmix, resample, optionally silence-gate, and frame samples as WAV. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure or malformed audio-reply error. + pub async fn prepare_capture( + &self, + samples: &[f32], + source_rate: u32, + channels: u16, + gate_threshold: f32, + ) -> Result> { + let wav: String = self + .call( + tinyvoice_bus::names::methods::PREPARE_CAPTURE, + ( + encode_samples(samples), + source_rate, + channels, + gate_threshold, + ), + ) + .await?; + self.decode_audio(&wav) + } + + /// Frame mono `f32` samples as a 16-bit PCM WAV file. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure or malformed audio-reply error. + pub async fn encode_wav(&self, samples: &[f32], sample_rate: u32) -> Result> { + let wav: String = self + .call( + tinyvoice_bus::names::methods::ENCODE_WAV, + (encode_samples(samples), sample_rate), + ) + .await?; + self.decode_audio(&wav) + } + + /// Downmix and resample an interleaved buffer to 16 kHz mono samples. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure or malformed sample-reply error. + pub async fn prepare_frames( + &self, + samples: &[f32], + source_rate: u32, + channels: u16, + ) -> Result> { + let encoded: String = self + .call( + tinyvoice_bus::names::methods::PREPARE_FRAMES, + (encode_samples(samples), source_rate, channels), + ) + .await?; + self.decode_samples(&encoded) + } + + /// Calculate RMS energy for each fixed-size sample frame. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure. + pub async fn frame_energies(&self, samples: &[f32], frame_len: u32) -> Result> { + self.call( + tinyvoice_bus::names::methods::FRAME_ENERGIES, + (encode_samples(samples), frame_len), + ) + .await + } + + /// Frame mono 16-bit samples as WAV without a lossy conversion. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure or malformed audio-reply error. + pub async fn encode_wav_pcm16( + &self, + samples: &[i16], + sample_rate: u32, + channels: u16, + ) -> Result> { + use base64::Engine as _; + let bytes: Vec = samples + .iter() + .flat_map(|sample| sample.to_le_bytes()) + .collect(); + let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); + let wav: String = self + .call( + tinyvoice_bus::names::methods::ENCODE_WAV_PCM16, + (encoded, sample_rate, channels), + ) + .await?; + self.decode_audio(&wav) + } + + /// Open a module-owned VAD session. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure. + pub async fn open_vad(&self, config: VadConfig) -> Result { + let client = self.bind().await?; + let config = encode_json(&config, "VAD config")?; + let id = client + .call(tinyvoice_bus::names::methods::VAD_OPEN, (config,)) + .await?; + Ok(VadSession { client, id }) + } + + /// Reserve an opaque capture lease before native microphone startup. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a capture refusal remains the + /// typed inner [`CaptureError`]. + pub async fn reserve_capture( + &self, + permission: MicrophonePermission, + ) -> Result> { + self.call( + tinyvoice_bus::names::methods::RESERVE_CAPTURE, + (RecordingStartRequest { + permission, + handle: None, + },), + ) + .await + } + + /// Start module-owned continuous capture with a known reservation. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a capture refusal remains the + /// typed inner [`CaptureError`]. + pub async fn start_capture( + &self, + handle: CaptureHandle, + permission: MicrophonePermission, + ) -> Result> { + self.call( + tinyvoice_bus::names::methods::CAPTURE_START, + (RecordingStartRequest { + permission, + handle: Some(handle), + },), + ) + .await + } + + /// Reserve a continuous capture on one pinned module peer. + /// + /// The returned owner is available before fallible native startup begins. + /// Retain it while calling [`OwnedCaptureReservation::start`], so a timeout + /// or canceled start can still be stopped through the same pinned peer. + /// Host microphone permission remains an input supplied by the caller. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a capture refusal remains the + /// typed inner [`CaptureError`]. + pub async fn reserve_capture_owned( + &self, + permission: MicrophonePermission, + ) -> Result> { + let client = self.bind().await?; + let reservation = client + .call( + tinyvoice_bus::names::methods::RESERVE_CAPTURE, + (RecordingStartRequest { + permission, + handle: None, + },), + ) + .await?; + let handle = match reservation { + Ok(handle) => handle, + Err(error) => return Ok(Err(error)), + }; + Ok(Ok(OwnedCaptureReservation { + client, + handle, + permission, + })) + } + + /// Read a bounded batch from a continuous capture lease. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a capture refusal remains the + /// typed inner [`CaptureError`]. + pub async fn poll_capture( + &self, + request: CapturePollRequest, + ) -> Result> { + self.call(tinyvoice_bus::names::methods::CAPTURE_POLL, (request,)) + .await + } + + /// Request that the module stop the capture lease. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a capture refusal remains the + /// typed inner [`CaptureError`]. + pub async fn stop_capture(&self, handle: CaptureHandle) -> Result> { + self.call(tinyvoice_bus::names::methods::CAPTURE_STOP, (handle,)) + .await + } + + /// Request shutdown of every module-owned capture lease. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a capture refusal remains the + /// typed inner [`CaptureError`]. + pub async fn shutdown_capture(&self) -> Result> { + self.call(tinyvoice_bus::names::methods::CAPTURE_SHUTDOWN, ()) + .await + } + + /// Reserve a native or host-fed hotkey listener. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the + /// typed inner [`HotkeyError`]. + pub async fn reserve_hotkey( + &self, + request: HotkeyRequest, + ) -> Result> { + self.call( + tinyvoice_bus::names::methods::HOTKEY_RESERVE, + (HotkeyReserveRequest { request },), + ) + .await + } + + /// Reserve a host-fed or native hotkey lease on one pinned module peer. + /// + /// Host permission and platform policy remain with the caller. The + /// returned owner carries its private handle through start, feed, read, + /// and explicit close. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the + /// typed inner [`HotkeyError`]. + pub async fn reserve_hotkey_owned( + &self, + request: HotkeyRequest, + ) -> Result> { + let client = self.bind().await?; + let handle: std::result::Result = client + .call( + tinyvoice_bus::names::methods::HOTKEY_RESERVE, + (HotkeyReserveRequest { request },), + ) + .await?; + Ok(handle.map(|handle| OwnedHotkeySession { client, handle })) + } + + /// Start a reserved listener, preserving the module's typed result. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the + /// typed inner [`HotkeyError`]. + pub async fn start_hotkey( + &self, + handle: HotkeyHandle, + ) -> Result> { + self.call( + tinyvoice_bus::names::methods::HOTKEY_START, + (HotkeyStartRequest { handle },), + ) + .await + } + + /// Read an acknowledged/replayable activation batch. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the + /// typed inner [`HotkeyError`]. + pub async fn read_hotkey( + &self, + request: HotkeyReadRequest, + ) -> Result> { + self.call(tinyvoice_bus::names::methods::HOTKEY_READ, (request,)) + .await + } + + /// Feed a bounded batch of host-owned generic key facts. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the + /// typed inner [`HotkeyError`]. + pub async fn feed_hotkey( + &self, + request: HotkeyFeedRequest, + ) -> Result> { + self.call(tinyvoice_bus::names::methods::HOTKEY_FEED, (request,)) + .await + } + + /// Stop a listener after native cleanup has completed. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the + /// typed inner [`HotkeyError`]. + pub async fn stop_hotkey( + &self, + handle: HotkeyHandle, + ) -> Result> { + self.call( + tinyvoice_bus::names::methods::HOTKEY_STOP, + (HotkeyHandleRequest { handle },), + ) + .await + } + + /// Shut down every module-owned hotkey listener. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the + /// typed inner [`HotkeyError`]. + pub async fn shutdown_hotkeys(&self) -> Result> { + self.call(tinyvoice_bus::names::methods::HOTKEY_SHUTDOWN, ()) + .await + } + + async fn call(&self, method: &str, args: A) -> Result + where + A: Serialize + Send, + R: DeserializeOwned, + { + let result = if let Some(pinned) = &self.pinned { + pinned.call_detailed(method, args).await + } else { + self.module.call_detailed(MODULE_ID, method, args).await + }; + result.map_err(VoiceCallError::from_failure) + } + + async fn call_json(&self, method: &str, args: A, field: &str) -> Result + where + A: Serialize + Send, + R: DeserializeOwned, + { + let response: String = self.call(method, args).await?; + serde_json::from_str(&response) + .map_err(|error| self.malformed_reply(format!("could not decode {field}: {error}"))) + } + + fn decode_audio(&self, encoded: &str) -> Result> { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|error| { + self.malformed_reply(format!("module returned invalid audio: {error}")) + }) + } + + fn decode_samples(&self, encoded: &str) -> Result> { + let bytes = self.decode_audio(encoded)?; + if !bytes.len().is_multiple_of(4) { + return Err(self.malformed_reply(format!( + "module returned {} audio bytes, not a whole number of samples", + bytes.len() + ))); + } + let (samples, _) = bytes.as_chunks::<4>(); + Ok(samples.iter().copied().map(f32::from_le_bytes).collect()) + } + + fn malformed_reply(&self, message: String) -> VoiceCallError { + self.module.report_malformed_reply(MODULE_ID); + VoiceCallError::local( + ModuleCallError::ModuleFault, + "ai.tinyhumans.tinybus.Error.BadArguments", + message, + ) + } +} + +/// A module-owned VAD session. Its opaque id is omitted from debug output. +/// +/// The handle is an explicit async lease: call [`close`](Self::close) when it +/// is no longer needed. Dropping it does not perform remote cleanup. +#[derive(Clone)] +pub struct VadSession { + client: TinyVoiceClient, + id: u64, +} + +impl fmt::Debug for VadSession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VadSession") + .field("id", &"") + .finish_non_exhaustive() + } +} + +impl VadSession { + /// Open a session with the supplied VAD tuning. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure. + pub async fn open(client: &TinyVoiceClient, config: VadConfig) -> Result { + client.open_vad(config).await + } + + /// Push frame energies and return the events emitted for this batch. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure or malformed-reply error. + pub async fn push(&self, frame_ms: u32, energies: &[f32]) -> Result> { + self.client + .call_json( + tinyvoice_bus::names::methods::VAD_PUSH, + (self.id, frame_ms, energies), + "VAD events", + ) + .await + } + + /// Report whether the session is currently inside an utterance. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure. + pub async fn is_speaking(&self) -> Result { + self.client + .call(tinyvoice_bus::names::methods::VAD_IS_SPEAKING, (self.id,)) + .await + } + + /// Drop any in-flight utterance without emitting an event. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure. + pub async fn reset(&self) -> Result<()> { + self.client + .call(tinyvoice_bus::names::methods::VAD_RESET, (self.id,)) + .await + } + + /// Release the session. Dropping the Rust handle alone does not close it. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure. + pub async fn close(&self) -> Result<()> { + self.client + .call(tinyvoice_bus::names::methods::VAD_CLOSE, (self.id,)) + .await + } +} + +/// A reserved capture bound to the module peer that created it. +/// +/// Keep this owner while [`start`](Self::start) is pending or may still finish +/// after the caller's request deadline. Call [`close`](Self::close) explicitly +/// to cancel pending startup or stop a published stream. Dropping the value +/// performs no remote cleanup. +#[derive(Clone)] +pub struct OwnedCaptureReservation { + client: TinyVoiceClient, + handle: CaptureHandle, + permission: MicrophonePermission, +} + +impl fmt::Debug for OwnedCaptureReservation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OwnedCaptureReservation") + .field("handle", &"") + .field("permission", &self.permission) + .finish_non_exhaustive() + } +} + +impl OwnedCaptureReservation { + /// Start the reserved stream on its pinned module peer. + /// + /// Retain this reservation while awaiting the result. If the call times out + /// or is canceled, [`close`](Self::close) still addresses the creating + /// peer's known handle. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a capture refusal remains the + /// typed inner [`CaptureError`]. + pub async fn start(&self) -> Result> { + let stream: CaptureResult = self + .client + .call( + tinyvoice_bus::names::methods::CAPTURE_START, + (RecordingStartRequest { + permission: self.permission, + handle: Some(self.handle.clone()), + },), + ) + .await?; + match stream { + Ok(stream) => Ok(Ok(OwnedCaptureStream { + client: self.client.clone(), + handle: stream.handle, + format: stream.format, + })), + Err(error) => Ok(Err(error)), + } + } + + /// Release this known reservation, cancel pending startup, or stop its stream. + /// + /// The module releases a known reservation even if `start` has not begun. + /// If the reservation reply itself is lost before the caller learns the + /// handle, the module's existing expiry bounds that unknown reservation. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a capture refusal remains the + /// typed inner [`CaptureError`]. + pub async fn close(&self) -> Result> { + self.client + .call( + tinyvoice_bus::names::methods::CAPTURE_STOP, + (self.handle.clone(),), + ) + .await + } +} + +/// A continuous stream bound to the module peer that started it. +/// +/// The opaque bus handle is private and omitted from debug output. Call +/// [`close`](Self::close) explicitly; dropping the value performs no remote +/// cleanup. +#[derive(Clone)] +pub struct OwnedCaptureStream { + client: TinyVoiceClient, + handle: CaptureHandle, + format: CaptureFormat, +} + +impl fmt::Debug for OwnedCaptureStream { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OwnedCaptureStream") + .field("handle", &"") + .field("format", &self.format) + .finish_non_exhaustive() + } +} + +impl OwnedCaptureStream { + /// Return the native sample format selected when the stream started. + #[must_use] + pub const fn format(&self) -> CaptureFormat { + self.format + } + + /// Read a bounded batch from this stream's pinned module peer. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a capture refusal remains the + /// typed inner [`CaptureError`]. + pub async fn poll(&self, max_chunks: usize) -> Result> { + self.client + .call( + tinyvoice_bus::names::methods::CAPTURE_POLL, + (CapturePollRequest { + handle: self.handle.clone(), + max_chunks, + },), + ) + .await + } + + /// Stop this stream on its pinned peer after native cleanup completes. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a capture refusal remains the + /// typed inner [`CaptureError`]. + pub async fn close(&self) -> Result> { + self.client + .call( + tinyvoice_bus::names::methods::CAPTURE_STOP, + (self.handle.clone(),), + ) + .await + } +} + +/// A module-owned hotkey recording session. Its opaque id is omitted from +/// debug output. +/// +/// This is an explicit async lease; call [`close`](Self::close) when it is no +/// longer needed. Dropping it does not perform remote cleanup. +#[derive(Clone)] +pub struct RecorderSession { + client: TinyVoiceClient, + id: u64, +} + +impl fmt::Debug for RecorderSession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RecorderSession") + .field("id", &"") + .finish_non_exhaustive() + } +} + +impl RecorderSession { + /// Open a stopped recording session. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure. + pub async fn open(client: &TinyVoiceClient) -> Result { + let client = client.bind().await?; + let id = client + .call(tinyvoice_bus::names::methods::SESSION_OPEN, ()) + .await?; + Ok(Self { client, id }) + } + + /// Apply an input to the recording state machine. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure or malformed-reply error. + pub async fn advance(&self, input: SessionInput) -> Result { + let encoded = encode_json(&input, "recording session input")?; + self.client + .call_json( + tinyvoice_bus::names::methods::SESSION_ADVANCE, + (self.id, encoded), + "recording session advance", + ) + .await + } + + /// Read the session's phase and latest generation. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure or malformed-reply error. + pub async fn status(&self) -> Result { + self.client + .call_json( + tinyvoice_bus::names::methods::SESSION_STATUS, + (self.id,), + "recording session status", + ) + .await + } + + /// Release the session. Dropping the Rust handle alone does not close it. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure. + pub async fn close(&self) -> Result<()> { + self.client + .call(tinyvoice_bus::names::methods::SESSION_CLOSE, (self.id,)) + .await + } +} + +/// A hotkey lease bound to the module peer that reserved it. +/// +/// The opaque bus handle is private and omitted from debug output. Call +/// [`close`](Self::close) explicitly; dropping this value performs no remote +/// cleanup. +#[derive(Clone)] +pub struct OwnedHotkeySession { + client: TinyVoiceClient, + handle: HotkeyHandle, +} + +impl fmt::Debug for OwnedHotkeySession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OwnedHotkeySession") + .field("handle", &"") + .finish_non_exhaustive() + } +} + +impl OwnedHotkeySession { + /// Start the reserved listener. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the + /// typed inner [`HotkeyError`]. + pub async fn start(&self) -> Result> { + self.client + .call( + tinyvoice_bus::names::methods::HOTKEY_START, + (HotkeyStartRequest { + handle: self.handle.clone(), + },), + ) + .await + } + + /// Read a retained activation batch, acknowledging only the supplied batch. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the + /// typed inner [`HotkeyError`]. + pub async fn read( + &self, + acknowledged_batch: Option, + ) -> Result> { + self.client + .call( + tinyvoice_bus::names::methods::HOTKEY_READ, + (HotkeyReadRequest { + handle: self.handle.clone(), + acknowledged_batch, + },), + ) + .await + } + + /// Feed host-owned generic key facts to this listener. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the + /// typed inner [`HotkeyError`]. + pub async fn feed( + &self, + generation: u64, + facts: Vec, + overflow: bool, + ) -> Result> { + self.client + .call( + tinyvoice_bus::names::methods::HOTKEY_FEED, + (HotkeyFeedRequest { + handle: self.handle.clone(), + generation, + facts, + overflow, + },), + ) + .await + } + + /// Stop the listener on its pinned module peer after cleanup completes. + /// + /// # Errors + /// + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the + /// typed inner [`HotkeyError`]. + pub async fn close(&self) -> Result> { + self.client + .call( + tinyvoice_bus::names::methods::HOTKEY_STOP, + (HotkeyHandleRequest { + handle: self.handle.clone(), + },), + ) + .await + } +} + +fn encode_json(value: &T, label: &str) -> Result { + serde_json::to_string(value).map_err(|error| { + VoiceCallError::local( + ModuleCallError::ModuleFault, + "ai.tinyhumans.tinybus.Error.BadArguments", + format!("could not encode {label}: {error}"), + ) + }) +} + +fn encode_samples(samples: &[f32]) -> String { + use base64::Engine as _; + let bytes: Vec = samples + .iter() + .flat_map(|sample| sample.to_le_bytes()) + .collect(); + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn hallucination_mode_wire(mode: HallucinationMode) -> &'static str { + match mode { + HallucinationMode::Dictation => "dictation", + HallucinationMode::Conversation => "conversation", + } +} + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "lib_ownership_tests.rs"] +mod ownership_tests; diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs new file mode 100644 index 0000000..fac5d22 --- /dev/null +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -0,0 +1,1542 @@ +//! Alias-handover regression tests for owned `TinyVoice` session projections. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use super::*; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::{Value, json}; +use tinybus::broker::Broker; +use tinybus::module::{ + AccessFuture, FailureReport, FailureReporter, FailureSink, ModuleAccess, ModuleClient, + ProxyFailure, ReleaseRecord, +}; +use tinybus::service::Interface; +use tinybus::transport::memory::MemoryBus; +use tinybus::{Connection, InterfaceName, MemberName, ObjectPath, Proxy}; +use tinyvoice_bus::{SessionAction, SessionAdvance, SessionInput, SessionPhase, SessionStatus}; + +const MODULE_ID: &str = "tinyvoice"; +const VAD_ID: u64 = 41; +const RECORDER_ID: u64 = 73; +const CAPTURE_ID: &str = "capture-shared"; +const HOTKEY_ID: &str = "hotkey-shared"; + +static RELEASE: ReleaseRecord = ReleaseRecord { + id: MODULE_ID, + bus_name: tinyvoice_bus::BUS_NAME, + object_path: tinyvoice_bus::OBJECT_PATH, + version: "0.3.2", + release_url: "https://example.invalid/tinyvoice", + assets: &[], +}; + +#[derive(Default)] +struct IgnoreFailures; + +impl FailureSink for IgnoreFailures { + fn capture(&self, _report: &FailureReport<'_>) {} +} + +struct FixtureAccess { + reporter: FailureReporter, +} + +impl ModuleAccess for FixtureAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == MODULE_ID).then_some(&RELEASE) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>( + &'a self, + _id: &'a str, + ) -> AccessFuture<'a, std::result::Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn proxy<'a>( + &'a self, + _bus_name: &'a str, + _object_path: &'a str, + ) -> AccessFuture<'a, std::result::Result> { + Box::pin(async { Err(ProxyFailure::Transport) }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Peer { + A, + B, +} + +#[derive(Default)] +struct PeerState { + calls: Vec<(String, Value)>, + vad_open: bool, + recorders: HashMap, + capture: CaptureFixtureState, + hotkey: HotkeyFixtureState, +} + +#[derive(Default)] +enum CaptureFixtureState { + #[default] + Empty, + Reserved, + Starting { + canceled: bool, + }, + Live, +} + +#[derive(Default)] +enum HotkeyFixtureState { + #[default] + Empty, + Reserved, + Running { + active: bool, + }, +} + +type SharedPeerState = Arc>; + +#[derive(Default)] +struct CaptureStartGate { + entered: tokio::sync::Notify, + release: tokio::sync::Notify, + completed: tokio::sync::Notify, + stop_entered: tokio::sync::Notify, +} + +struct OwnershipVoice { + peer: Peer, + state: SharedPeerState, + capture_start_gate: Option>, +} + +impl OwnershipVoice { + fn call_vad(&self, method: &str, args: &Value) -> tinybus::Result { + let values = args + .as_array() + .ok_or_else(|| tinybus::Error::failed("expected positional method arguments"))?; + let mut state = self + .state + .lock() + .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; + state.calls.push((method.to_owned(), args.clone())); + match method { + tinyvoice_bus::names::methods::ROUTE => Ok(match self.peer { + Peer::A => json!(r#"{"intent":"pause"}"#), + Peer::B => json!(r#"{"intent":"resume"}"#), + }), + tinyvoice_bus::names::methods::VAD_OPEN => { + state.vad_open = true; + Ok(json!(VAD_ID)) + } + tinyvoice_bus::names::methods::VAD_PUSH => { + let id = positional_id(values)?; + ensure_vad_open(&state, id)?; + let events = match self.peer { + Peer::A => r#"[{"frame":0,"kind":"speech_start"}]"#, + Peer::B => { + r#"[{"frame":0,"kind":"speech_end","voiced_ms":20,"emit":true,"forced":false}]"# + } + }; + Ok(json!(events)) + } + tinyvoice_bus::names::methods::VAD_IS_SPEAKING => { + let id = positional_id(values)?; + ensure_vad_open(&state, id)?; + Ok(json!(self.peer == Peer::B)) + } + tinyvoice_bus::names::methods::VAD_RESET => { + let id = positional_id(values)?; + ensure_vad_open(&state, id)?; + Ok(Value::Null) + } + tinyvoice_bus::names::methods::VAD_CLOSE => { + let id = positional_id(values)?; + ensure_vad_open(&state, id)?; + state.vad_open = false; + Ok(Value::Null) + } + _ => Err(tinybus::Error::failed("unexpected VAD fixture method")), + } + } + + fn call_recorder(&self, method: &str, args: &Value) -> tinybus::Result { + let values = args + .as_array() + .ok_or_else(|| tinybus::Error::failed("expected positional method arguments"))?; + let mut state = self + .state + .lock() + .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; + state.calls.push((method.to_owned(), args.clone())); + match method { + tinyvoice_bus::names::methods::SESSION_OPEN => { + state.recorders.insert( + RECORDER_ID, + SessionStatus { + phase: SessionPhase::Stopped, + generation: 0, + }, + ); + Ok(json!(RECORDER_ID)) + } + tinyvoice_bus::names::methods::SESSION_ADVANCE => { + let id = positional_id(values)?; + let input_json = values + .get(1) + .and_then(Value::as_str) + .ok_or_else(|| tinybus::Error::failed("missing recorder input"))?; + let input: SessionInput = serde_json::from_str(input_json) + .map_err(|_| tinybus::Error::failed("invalid recorder input"))?; + let status = state + .recorders + .get_mut(&id) + .ok_or_else(|| tinybus::Error::failed("unknown recorder id"))?; + let reply = advance_fixture_session(status, input); + let encoded = serde_json::to_string(&reply) + .map_err(|_| tinybus::Error::failed("could not encode recorder reply"))?; + Ok(json!(encoded)) + } + tinyvoice_bus::names::methods::SESSION_STATUS => { + let id = positional_id(values)?; + let status = state + .recorders + .get(&id) + .ok_or_else(|| tinybus::Error::failed("unknown recorder id"))?; + let encoded = serde_json::to_string(status) + .map_err(|_| tinybus::Error::failed("could not encode recorder status"))?; + Ok(json!(encoded)) + } + tinyvoice_bus::names::methods::SESSION_CLOSE => { + let id = positional_id(values)?; + state + .recorders + .remove(&id) + .ok_or_else(|| tinybus::Error::failed("unknown recorder id"))?; + Ok(Value::Null) + } + _ => Err(tinybus::Error::failed("unexpected recorder fixture method")), + } + } + + fn call_capture_request(&self, method: &str, args: &Value) -> tinybus::Result { + let values = args + .as_array() + .ok_or_else(|| tinybus::Error::failed("expected positional method arguments"))?; + let mut state = self + .state + .lock() + .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; + state.calls.push((method.to_owned(), args.clone())); + match method { + tinyvoice_bus::names::methods::RESERVE_CAPTURE => { + state.capture = CaptureFixtureState::Reserved; + Ok(json!({"Ok": CAPTURE_ID})) + } + tinyvoice_bus::names::methods::CAPTURE_POLL => { + let request = values + .first() + .and_then(Value::as_object) + .ok_or_else(|| tinybus::Error::failed("missing capture poll request"))?; + if request.get("handle").and_then(Value::as_str) != Some(CAPTURE_ID) + || !matches!(&state.capture, CaptureFixtureState::Live) + { + Ok(json!({"Err": {"code": "unknown_handle"}})) + } else { + let sample = if self.peer == Peer::A { 0.25 } else { 0.75 }; + Ok(json!({ + "Ok": { + "chunks": [{"samples": [sample]}], + "closed": false + } + })) + } + } + _ => Err(tinybus::Error::failed("unexpected capture fixture method")), + } + } + + async fn call_capture_start(&self, args: Value) -> tinybus::Result { + let request = args + .as_array() + .and_then(|values| values.first()) + .and_then(Value::as_object) + .ok_or_else(|| tinybus::Error::failed("missing capture start request"))?; + let handle = request + .get("handle") + .and_then(Value::as_str) + .ok_or_else(|| tinybus::Error::failed("missing capture reservation"))?; + let gate = self.capture_start_gate.clone(); + { + let mut state = self + .state + .lock() + .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; + state.calls.push(( + tinyvoice_bus::names::methods::CAPTURE_START.to_owned(), + args.clone(), + )); + if !matches!(&state.capture, CaptureFixtureState::Reserved) || handle != CAPTURE_ID { + return Ok(json!({"Err": {"code": "unknown_handle"}})); + } + state.capture = if gate.is_some() { + CaptureFixtureState::Starting { canceled: false } + } else { + CaptureFixtureState::Live + }; + } + if let Some(gate) = &gate { + gate.entered.notify_one(); + gate.release.notified().await; + let mut state = self + .state + .lock() + .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; + if matches!( + &state.capture, + CaptureFixtureState::Starting { canceled: true } + ) { + state.capture = CaptureFixtureState::Empty; + gate.completed.notify_one(); + return Ok(json!({"Err": {"code": "cancelled"}})); + } + if !matches!( + &state.capture, + CaptureFixtureState::Starting { canceled: false } + ) { + return Err(tinybus::Error::failed( + "capture startup lost its reservation", + )); + } + state.capture = CaptureFixtureState::Live; + gate.completed.notify_one(); + } + Ok(json!({ + "Ok": { + "handle": CAPTURE_ID, + "format": {"source_rate": 48_000, "channels": 1} + } + })) + } + + async fn call_capture_stop(&self, args: Value) -> tinybus::Result { + let handle = args + .as_array() + .and_then(|values| values.first()) + .and_then(Value::as_str); + let gate = self.capture_start_gate.clone(); + let pending = { + let mut state = self + .state + .lock() + .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; + state.calls.push(( + tinyvoice_bus::names::methods::CAPTURE_STOP.to_owned(), + args.clone(), + )); + if handle != Some(CAPTURE_ID) { + return Ok(json!({"Err": {"code": "unknown_handle"}})); + } + if matches!(&state.capture, CaptureFixtureState::Starting { .. }) { + if let CaptureFixtureState::Starting { canceled } = &mut state.capture { + *canceled = true; + } + true + } else if matches!( + &state.capture, + CaptureFixtureState::Live | CaptureFixtureState::Reserved + ) { + state.capture = CaptureFixtureState::Empty; + false + } else { + return Ok(json!({"Err": {"code": "unknown_handle"}})); + } + }; + if pending && let Some(gate) = gate { + gate.stop_entered.notify_one(); + gate.completed.notified().await; + } + Ok(json!({"Ok": null})) + } + + fn call_hotkey(&self, method: &str, args: &Value) -> tinybus::Result { + let values = args + .as_array() + .ok_or_else(|| tinybus::Error::failed("expected positional method arguments"))?; + let mut state = self + .state + .lock() + .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; + state.calls.push((method.to_owned(), args.clone())); + match method { + tinyvoice_bus::names::methods::HOTKEY_RESERVE => { + state.hotkey = HotkeyFixtureState::Reserved; + Ok(json!({"Ok": HOTKEY_ID})) + } + tinyvoice_bus::names::methods::HOTKEY_START => { + ensure_hotkey_open(&state, values.first().and_then(Value::as_object))?; + state.hotkey = HotkeyFixtureState::Running { active: false }; + Ok(hotkey_status_json("running", false)) + } + tinyvoice_bus::names::methods::HOTKEY_FEED => { + ensure_hotkey_open(&state, values.first().and_then(Value::as_object))?; + state.hotkey = HotkeyFixtureState::Running { active: true }; + Ok(hotkey_status_json("running", true)) + } + tinyvoice_bus::names::methods::HOTKEY_READ => { + ensure_hotkey_open(&state, values.first().and_then(Value::as_object))?; + let active = self.peer == Peer::B + && matches!(&state.hotkey, HotkeyFixtureState::Running { active: true }); + let event = if self.peer == Peer::A { + "pressed" + } else { + "released" + }; + Ok(json!({ + "Ok": { + "generation": 1, + "batch": 1, + "events": [{"generation": 1, "sequence": 1, "event": event}], + "reset": false, + "active": active + } + })) + } + tinyvoice_bus::names::methods::HOTKEY_STOP => { + ensure_hotkey_open(&state, values.first().and_then(Value::as_object))?; + state.hotkey = HotkeyFixtureState::Empty; + Ok(hotkey_status_json("stopped", false)) + } + _ => Err(tinybus::Error::failed("unexpected hotkey fixture method")), + } + } +} + +fn hotkey_status_json(state: &str, active: bool) -> Value { + json!({ + "Ok": { + "state": state, + "generation": 1, + "active": active, + "continuity_lost": false + } + }) +} + +#[async_trait] +impl Interface for OwnershipVoice { + fn name(&self) -> InterfaceName { + InterfaceName::new(tinyvoice_bus::BUS_NAME).unwrap_or_else(|_| std::process::abort()) + } + + fn members(&self) -> Vec { + tinyvoice_bus::METHODS + .iter() + .filter_map(|name| MemberName::new(*name).ok()) + .collect() + } + + async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { + let method = member.as_str(); + match method { + tinyvoice_bus::names::methods::ROUTE + | tinyvoice_bus::names::methods::VAD_OPEN + | tinyvoice_bus::names::methods::VAD_PUSH + | tinyvoice_bus::names::methods::VAD_IS_SPEAKING + | tinyvoice_bus::names::methods::VAD_RESET + | tinyvoice_bus::names::methods::VAD_CLOSE => self.call_vad(method, &args), + tinyvoice_bus::names::methods::SESSION_OPEN + | tinyvoice_bus::names::methods::SESSION_ADVANCE + | tinyvoice_bus::names::methods::SESSION_STATUS + | tinyvoice_bus::names::methods::SESSION_CLOSE => self.call_recorder(method, &args), + tinyvoice_bus::names::methods::RESERVE_CAPTURE + | tinyvoice_bus::names::methods::CAPTURE_POLL => { + self.call_capture_request(method, &args) + } + tinyvoice_bus::names::methods::CAPTURE_START => self.call_capture_start(args).await, + tinyvoice_bus::names::methods::CAPTURE_STOP => self.call_capture_stop(args).await, + tinyvoice_bus::names::methods::HOTKEY_RESERVE + | tinyvoice_bus::names::methods::HOTKEY_START + | tinyvoice_bus::names::methods::HOTKEY_FEED + | tinyvoice_bus::names::methods::HOTKEY_READ + | tinyvoice_bus::names::methods::HOTKEY_STOP => self.call_hotkey(method, &args), + _ => Err(tinybus::Error::failed( + "unexpected ownership fixture method", + )), + } + } +} + +fn positional_id(values: &[Value]) -> tinybus::Result { + values + .first() + .and_then(Value::as_u64) + .ok_or_else(|| tinybus::Error::failed("missing resource id")) +} + +fn ensure_vad_open(state: &PeerState, id: u64) -> tinybus::Result<()> { + if id == VAD_ID && state.vad_open { + Ok(()) + } else { + Err(tinybus::Error::failed("unknown VAD id")) + } +} + +fn ensure_hotkey_open( + state: &PeerState, + request: Option<&serde_json::Map>, +) -> tinybus::Result<()> { + if request + .and_then(|request| request.get("handle")) + .and_then(Value::as_str) + == Some(HOTKEY_ID) + && !matches!(&state.hotkey, HotkeyFixtureState::Empty) + { + Ok(()) + } else { + Err(tinybus::Error::failed("unknown hotkey handle")) + } +} + +fn advance_fixture_session(status: &mut SessionStatus, input: SessionInput) -> SessionAdvance { + let (accepted, action) = match input { + SessionInput::Run if status.phase == SessionPhase::Stopped => { + status.phase = SessionPhase::Idle; + (true, SessionAction::None) + } + SessionInput::Pressed if status.phase == SessionPhase::Idle => { + status.phase = SessionPhase::Recording; + status.generation += 1; + ( + true, + SessionAction::StartRecording { + generation: status.generation, + }, + ) + } + SessionInput::SetupReady { + stop_buffered: false, + } if status.phase == SessionPhase::Recording => ( + true, + SessionAction::RecordingLive { + generation: status.generation, + }, + ), + _ => (false, SessionAction::None), + }; + + SessionAdvance { + accepted, + phase: status.phase, + generation: status.generation, + action, + } +} + +struct AliasHandoffFixture { + client: TinyVoiceClient, + direct_a: TinyVoiceClient, + caller: Connection, + service_a: Connection, + service_b: Connection, + state_a: SharedPeerState, + state_b: SharedPeerState, +} + +async fn handoff_fixture() -> tinybus::Result { + handoff_fixture_with_capture_start_gate(None, None).await +} + +async fn handoff_fixture_with_capture_start_gate( + capture_start_gate: Option>, + client_timeout: Option, +) -> tinybus::Result { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + + let service_a = Connection::connect(bus.connect().await?).await?; + let service_b = Connection::connect(bus.connect().await?).await?; + let state_a = Arc::new(Mutex::new(PeerState::default())); + let state_b = Arc::new(Mutex::new(PeerState::default())); + let object_path = ObjectPath::new(tinyvoice_bus::OBJECT_PATH)?; + + service_a + .serve_at( + object_path.clone(), + OwnershipVoice { + peer: Peer::A, + state: state_a.clone(), + capture_start_gate: capture_start_gate.clone(), + }, + ) + .await?; + service_b + .serve_at( + object_path, + OwnershipVoice { + peer: Peer::B, + state: state_b.clone(), + capture_start_gate: None, + }, + ) + .await?; + service_a.request_name(tinyvoice_bus::BUS_NAME).await?; + + let caller = Connection::connect(bus.connect().await?).await?; + let alias_proxy = caller.proxy( + tinyvoice_bus::BUS_NAME, + tinyvoice_bus::OBJECT_PATH, + tinyvoice_bus::BUS_NAME, + )?; + let service_a_unique = service_a + .unique_name() + .ok_or_else(|| tinybus::Error::failed("service A has no unique name"))?; + let direct_a_proxy = caller.proxy( + service_a_unique.as_str(), + tinyvoice_bus::OBJECT_PATH, + tinyvoice_bus::BUS_NAME, + )?; + + let client = make_client_with_timeout(alias_proxy, client_timeout); + let direct_a = make_client_with_timeout(direct_a_proxy, client_timeout); + Ok(AliasHandoffFixture { + client, + direct_a, + caller, + service_a, + service_b, + state_a, + state_b, + }) +} + +fn make_client_with_timeout(proxy: Proxy, timeout: Option) -> TinyVoiceClient { + let failures = Arc::new(IgnoreFailures); + let mut module = ModuleClient::new(FixtureAccess { + reporter: FailureReporter::new(failures), + }) + .with_fixture(proxy); + if let Some(timeout) = timeout { + module = module.with_timeout(timeout); + } + TinyVoiceClient::new(module) +} + +async fn reserve_and_start_owned_capture( + client: &TinyVoiceClient, +) -> (OwnedCaptureReservation, OwnedCaptureStream) { + let reservation = client + .reserve_capture_owned(MicrophonePermission::Granted) + .await + .expect("reserve owned capture") + .expect("capture reservation succeeds"); + let stream = reservation + .start() + .await + .expect("start owned capture") + .expect("capture stream starts"); + (reservation, stream) +} + +impl AliasHandoffFixture { + async fn move_public_alias_to_b(&self) -> tinybus::Result<()> { + self.service_a.release_name(tinyvoice_bus::BUS_NAME).await?; + self.service_b.request_name(tinyvoice_bus::BUS_NAME).await?; + let owner = self.caller.name_owner(tinyvoice_bus::BUS_NAME).await?; + if owner != self.service_b.unique_name() { + return Err(tinybus::Error::failed( + "public TinyVoice alias did not move to service B", + )); + } + Ok(()) + } +} + +fn methods(state: &SharedPeerState) -> Vec { + state + .lock() + .expect("ownership fixture state lock") + .calls + .iter() + .map(|(method, _)| method.clone()) + .collect() +} + +#[tokio::test] +async fn vad_session_stays_with_its_creator_across_a_live_public_alias_handover() { + let fixture = handoff_fixture() + .await + .expect("start both service peers on one broker"); + let a = fixture + .client + .open_vad(VadConfig::default()) + .await + .expect("open VAD session on A"); + assert_eq!(a.id, VAD_ID); + + fixture + .move_public_alias_to_b() + .await + .expect("move public name while A remains connected"); + assert_eq!( + fixture + .direct_a + .route("prove service A is still live") + .await + .expect("A stays directly callable by unique name"), + VoiceIntent::Pause + ); + assert_eq!( + fixture + .client + .route("prove replacement service B is usable") + .await + .expect("the public alias now reaches B"), + VoiceIntent::Resume + ); + + let b = fixture + .client + .open_vad(VadConfig::default()) + .await + .expect("open colliding VAD session on B"); + assert_eq!(b.id, a.id, "module-local VAD ids collide across peers"); + let b_push = b.push(20, &[0.8]).await; + let b_speaking = b.is_speaking().await; + assert_eq!( + b_push, + Ok(vec![IndexedVadEvent { + frame: 0, + event: tinyvoice_bus::VadEvent::SpeechEnd { + voiced_ms: 20, + emit: true, + forced: false, + }, + }]), + "B returns a usable B-specific VAD canary" + ); + assert_eq!(b_speaking, Ok(true)); + + // Run every A operation before asserting so the ingress evidence captures + // push, read, reset, and close even while this regression is still present. + let a_push = a.push(20, &[0.8]).await; + let a_speaking = a.is_speaking().await; + let a_reset = a.reset().await; + let a_close = a.close().await; + let b_still_open = b.is_speaking().await; + + assert_eq!( + (a_push, a_speaking, a_reset, a_close, b_still_open), + ( + Ok(vec![IndexedVadEvent { + frame: 0, + event: tinyvoice_bus::VadEvent::SpeechStart, + }]), + Ok(false), + Ok(()), + Ok(()), + Ok(true), + ), + "A's owner projection must remain on A; closing A must leave B live" + ); + assert_eq!( + methods(&fixture.state_a), + [ + tinyvoice_bus::names::methods::VAD_OPEN, + tinyvoice_bus::names::methods::ROUTE, + tinyvoice_bus::names::methods::VAD_PUSH, + tinyvoice_bus::names::methods::VAD_IS_SPEAKING, + tinyvoice_bus::names::methods::VAD_RESET, + tinyvoice_bus::names::methods::VAD_CLOSE, + ] + .map(str::to_owned) + ); + assert_eq!( + methods(&fixture.state_b), + [ + tinyvoice_bus::names::methods::ROUTE, + tinyvoice_bus::names::methods::VAD_OPEN, + tinyvoice_bus::names::methods::VAD_PUSH, + tinyvoice_bus::names::methods::VAD_IS_SPEAKING, + tinyvoice_bus::names::methods::VAD_IS_SPEAKING, + ] + .map(str::to_owned), + "B receives its own usable calls and no call from A's projection" + ); +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn recorder_session_stays_with_its_creator_across_a_live_public_alias_handover() { + let fixture = handoff_fixture() + .await + .expect("start both service peers on one broker"); + let a = RecorderSession::open(&fixture.client) + .await + .expect("open recorder on A"); + assert_eq!(a.id, RECORDER_ID); + + fixture + .move_public_alias_to_b() + .await + .expect("move public name while A remains connected"); + assert_eq!( + fixture + .direct_a + .route("prove service A is still live") + .await + .expect("A stays directly callable by unique name"), + VoiceIntent::Pause + ); + assert_eq!( + fixture + .client + .route("prove replacement service B is usable") + .await + .expect("the public alias now reaches B"), + VoiceIntent::Resume + ); + + let b = RecorderSession::open(&fixture.client) + .await + .expect("open colliding recorder on B"); + assert_eq!(b.id, a.id, "module-local recorder ids collide across peers"); + let b_run = b.advance(SessionInput::Run).await; + let b_pressed = b.advance(SessionInput::Pressed).await; + let b_ready = b + .advance(SessionInput::SetupReady { + stop_buffered: false, + }) + .await; + let b_status = b.status().await; + assert_eq!( + (b_run, b_pressed, b_ready, b_status), + ( + Ok(SessionAdvance { + accepted: true, + phase: SessionPhase::Idle, + generation: 0, + action: SessionAction::None, + }), + Ok(SessionAdvance { + accepted: true, + phase: SessionPhase::Recording, + generation: 1, + action: SessionAction::StartRecording { generation: 1 }, + }), + Ok(SessionAdvance { + accepted: true, + phase: SessionPhase::Recording, + generation: 1, + action: SessionAction::RecordingLive { generation: 1 }, + }), + Ok(SessionStatus { + phase: SessionPhase::Recording, + generation: 1, + }), + ), + "B's colliding session is live and returns valid B-side behavior" + ); + + // Run all A operations after B owns the public alias, then check the A + // canaries and exact endpoint ingress together. + let a_run = a.advance(SessionInput::Run).await; + let a_status = a.status().await; + let a_close = a.close().await; + let b_still_open = b.status().await; + assert_eq!( + (a_run, a_status, a_close, b_still_open), + ( + Ok(SessionAdvance { + accepted: true, + phase: SessionPhase::Idle, + generation: 0, + action: SessionAction::None, + }), + Ok(SessionStatus { + phase: SessionPhase::Idle, + generation: 0, + }), + Ok(()), + Ok(SessionStatus { + phase: SessionPhase::Recording, + generation: 1, + }), + ), + "A's owner projection must stay on A; closing A must leave B live" + ); + assert_eq!( + methods(&fixture.state_a), + [ + tinyvoice_bus::names::methods::SESSION_OPEN, + tinyvoice_bus::names::methods::ROUTE, + tinyvoice_bus::names::methods::SESSION_ADVANCE, + tinyvoice_bus::names::methods::SESSION_STATUS, + tinyvoice_bus::names::methods::SESSION_CLOSE, + ] + .map(str::to_owned) + ); + assert_eq!( + methods(&fixture.state_b), + [ + tinyvoice_bus::names::methods::ROUTE, + tinyvoice_bus::names::methods::SESSION_OPEN, + tinyvoice_bus::names::methods::SESSION_ADVANCE, + tinyvoice_bus::names::methods::SESSION_ADVANCE, + tinyvoice_bus::names::methods::SESSION_ADVANCE, + tinyvoice_bus::names::methods::SESSION_STATUS, + tinyvoice_bus::names::methods::SESSION_STATUS, + ] + .map(str::to_owned), + "B receives its own usable calls and no call from A's projection" + ); +} + +#[tokio::test] +async fn owned_capture_stream_stays_with_its_creator_across_alias_handover() { + let fixture = handoff_fixture() + .await + .expect("start both service peers on one broker"); + let (_a_reservation, a) = reserve_and_start_owned_capture(&fixture.client).await; + assert_eq!( + a.format(), + CaptureFormat { + source_rate: 48_000, + channels: 1, + } + ); + + fixture + .move_public_alias_to_b() + .await + .expect("move public name while A remains connected"); + assert_eq!( + fixture + .direct_a + .route("prove service A is still live") + .await + .expect("A stays directly callable by unique name"), + VoiceIntent::Pause + ); + assert_eq!( + fixture + .client + .route("prove replacement service B is usable") + .await + .expect("the public alias now reaches B"), + VoiceIntent::Resume + ); + + let (_b_reservation, b) = reserve_and_start_owned_capture(&fixture.client).await; + assert_eq!(a.handle, b.handle, "capture handles collide across peers"); + let b_batch = b.poll(1).await; + assert!( + matches!( + b_batch, + Ok(Ok(CaptureBatch { chunks, closed: false })) + if chunks == vec![RawChunk { samples: vec![0.75] }] + ), + "B's stream returns a usable B-specific capture canary" + ); + + let a_batch = a.poll(1).await; + let a_close = a.close().await; + let b_still_open = b.poll(1).await; + assert!( + matches!( + a_batch, + Ok(Ok(CaptureBatch { chunks, closed: false })) + if chunks == vec![RawChunk { samples: vec![0.25] }] + ), + "A's poll reaches its own stream" + ); + assert_eq!(a_close, Ok(Ok(())), "A's stream closes on A"); + assert!( + matches!( + b_still_open, + Ok(Ok(CaptureBatch { chunks, closed: false })) + if chunks == vec![RawChunk { samples: vec![0.75] }] + ), + "closing A leaves B's stream live" + ); + assert_eq!( + methods(&fixture.state_a), + [ + tinyvoice_bus::names::methods::RESERVE_CAPTURE, + tinyvoice_bus::names::methods::CAPTURE_START, + tinyvoice_bus::names::methods::ROUTE, + tinyvoice_bus::names::methods::CAPTURE_POLL, + tinyvoice_bus::names::methods::CAPTURE_STOP, + ] + .map(str::to_owned) + ); + assert_eq!( + methods(&fixture.state_b), + [ + tinyvoice_bus::names::methods::ROUTE, + tinyvoice_bus::names::methods::RESERVE_CAPTURE, + tinyvoice_bus::names::methods::CAPTURE_START, + tinyvoice_bus::names::methods::CAPTURE_POLL, + tinyvoice_bus::names::methods::CAPTURE_POLL, + ] + .map(str::to_owned), + "B receives its own usable calls and no call from A's projection" + ); +} + +#[tokio::test] +async fn closing_an_unstarted_capture_reservation_releases_its_handle() { + let fixture = handoff_fixture() + .await + .expect("start both service peers on one broker"); + let reservation = fixture + .client + .reserve_capture_owned(MicrophonePermission::Granted) + .await + .expect("reserve capture on A") + .expect("A reservation succeeds"); + + assert_eq!(reservation.close().await, Ok(Ok(()))); + assert!(matches!( + reservation.start().await, + Ok(Err(CaptureError::UnknownHandle)) + )); + assert!(matches!( + fixture + .client + .poll_capture(CapturePollRequest { + handle: reservation.handle.clone(), + max_chunks: 1, + }) + .await, + Ok(Err(CaptureError::UnknownHandle)) + )); + assert_eq!( + methods(&fixture.state_a), + [ + tinyvoice_bus::names::methods::RESERVE_CAPTURE, + tinyvoice_bus::names::methods::CAPTURE_STOP, + tinyvoice_bus::names::methods::CAPTURE_START, + tinyvoice_bus::names::methods::CAPTURE_POLL, + ] + .map(str::to_owned) + ); +} + +#[tokio::test(start_paused = true)] +async fn timed_out_capture_start_does_not_leave_an_unowned_stream() { + let gate = Arc::new(CaptureStartGate::default()); + let fixture = + handoff_fixture_with_capture_start_gate(Some(gate.clone()), Some(Duration::from_secs(1))) + .await + .expect("start gated capture service"); + let owner = fixture + .client + .reserve_capture_owned(MicrophonePermission::Granted) + .await + .expect("reserve owned capture before startup") + .expect("reservation succeeds"); + let start_owner = owner.clone(); + let (result_tx, result_rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let result = start_owner.start().await; + let _ = result_tx.send(result); + }); + + // The reservation call has completed and CAPTURE_START has reached the + // remote service, which is deliberately held before native publication. + tokio::time::timeout(Duration::from_secs(5), gate.entered.notified()) + .await + .expect("CAPTURE_START reaches the delayed service"); + tokio::time::advance(Duration::from_secs(1)).await; + let timed_out = tokio::time::timeout(Duration::from_secs(5), result_rx) + .await + .expect("the caller receives its deadline result") + .expect("the client task reports its deadline result"); + assert!( + timed_out.is_err(), + "the start RPC must hit its configured deadline" + ); + + let close_owner = owner.clone(); + let close_task = tokio::spawn(async move { close_owner.close().await }); + tokio::time::timeout(Duration::from_secs(5), gate.stop_entered.notified()) + .await + .expect("explicit close reaches the pending reservation"); + gate.release.notify_one(); + let close_result = tokio::time::timeout(Duration::from_secs(5), close_task) + .await + .expect("explicit close waits for startup cleanup") + .expect("close task completes"); + assert_eq!(close_result, Ok(Ok(()))); + let late_poll = fixture + .client + .poll_capture(CapturePollRequest { + handle: CaptureHandle(CAPTURE_ID.to_owned()), + max_chunks: 1, + }) + .await; + assert!( + matches!(late_poll, Ok(Err(CaptureError::UnknownHandle))), + "closing the retained owner after timeout leaves no late stream" + ); + assert_eq!( + methods(&fixture.state_a), + [ + tinyvoice_bus::names::methods::RESERVE_CAPTURE, + tinyvoice_bus::names::methods::CAPTURE_START, + tinyvoice_bus::names::methods::CAPTURE_STOP, + tinyvoice_bus::names::methods::CAPTURE_POLL, + ] + .map(str::to_owned) + ); +} + +#[tokio::test] +async fn canceled_capture_start_does_not_leave_an_unowned_stream() { + let gate = Arc::new(CaptureStartGate::default()); + let fixture = handoff_fixture_with_capture_start_gate(Some(gate.clone()), None) + .await + .expect("start gated capture service"); + let owner = fixture + .client + .reserve_capture_owned(MicrophonePermission::Granted) + .await + .expect("reserve owned capture before startup") + .expect("reservation succeeds"); + let start_owner = owner.clone(); + let start_task = tokio::spawn(async move { start_owner.start().await }); + + tokio::time::timeout(Duration::from_secs(5), gate.entered.notified()) + .await + .expect("CAPTURE_START reaches the delayed service"); + start_task.abort(); + let aborted = tokio::time::timeout(Duration::from_secs(5), start_task) + .await + .expect("the caller task observes cancellation") + .expect_err("the caller task is canceled") + .is_cancelled(); + + let close_owner = owner.clone(); + let close_task = tokio::spawn(async move { close_owner.close().await }); + tokio::time::timeout(Duration::from_secs(5), gate.stop_entered.notified()) + .await + .expect("explicit close reaches the pending reservation"); + gate.release.notify_one(); + let close_result = tokio::time::timeout(Duration::from_secs(5), close_task) + .await + .expect("explicit close waits for startup cleanup") + .expect("close task completes"); + assert_eq!(close_result, Ok(Ok(()))); + let late_poll = fixture + .client + .poll_capture(CapturePollRequest { + handle: CaptureHandle(CAPTURE_ID.to_owned()), + max_chunks: 1, + }) + .await; + assert!(aborted, "the caller's start future was canceled"); + assert!( + matches!(late_poll, Ok(Err(CaptureError::UnknownHandle))), + "closing the retained owner after cancellation leaves no late stream" + ); +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn owned_hotkey_session_stays_with_its_creator_across_alias_handover() { + let fixture = handoff_fixture() + .await + .expect("start both service peers on one broker"); + let request = HotkeyRequest { + key: "Fn".to_owned(), + mode: ActivationMode::Push, + source: HotkeySource::Host, + }; + let a = fixture + .client + .reserve_hotkey_owned(request.clone()) + .await + .expect("reserve hotkey on A") + .expect("A hotkey reservation succeeds"); + + fixture + .move_public_alias_to_b() + .await + .expect("move public name while A remains connected"); + assert_eq!( + fixture + .direct_a + .route("prove service A is still live") + .await + .expect("A stays directly callable by unique name"), + VoiceIntent::Pause + ); + assert_eq!( + fixture + .client + .route("prove replacement service B is usable") + .await + .expect("the public alias now reaches B"), + VoiceIntent::Resume + ); + + let b = fixture + .client + .reserve_hotkey_owned(request) + .await + .expect("reserve colliding hotkey on B") + .expect("B hotkey reservation succeeds"); + assert_eq!(a.handle, b.handle, "hotkey handles collide across peers"); + let b_started = b.start().await; + let b_feed = b + .feed( + 1, + vec![SequencedHostFact { + sequence: 1, + fact: HostKeyFact::Down, + }], + false, + ) + .await; + let b_batch = b.read(None).await; + assert_eq!( + (b_started, b_feed, b_batch), + ( + Ok(Ok(HotkeyReply { + state: tinyvoice_bus::HotkeyState::Running, + generation: Some(1), + active: false, + continuity_lost: false, + })), + Ok(Ok(HotkeyReply { + state: tinyvoice_bus::HotkeyState::Running, + generation: Some(1), + active: true, + continuity_lost: false, + })), + Ok(Ok(HotkeyBatch { + generation: 1, + batch: 1, + events: vec![tinyvoice_bus::hotkey::SequencedHotkeyEvent { + generation: 1, + sequence: 1, + event: HotkeyEvent::Released, + }], + reset: false, + active: true, + })), + ), + "B's start/feed/read path returns usable B-specific canaries" + ); + + let a_started = a.start().await; + let a_feed = a + .feed( + 1, + vec![SequencedHostFact { + sequence: 1, + fact: HostKeyFact::Down, + }], + false, + ) + .await; + let a_batch = a.read(None).await; + let a_close = a.close().await; + let b_still_open = b.read(None).await; + assert_eq!( + (a_started, a_feed, a_batch, a_close, b_still_open), + ( + Ok(Ok(HotkeyReply { + state: tinyvoice_bus::HotkeyState::Running, + generation: Some(1), + active: false, + continuity_lost: false, + })), + Ok(Ok(HotkeyReply { + state: tinyvoice_bus::HotkeyState::Running, + generation: Some(1), + active: true, + continuity_lost: false, + })), + Ok(Ok(HotkeyBatch { + generation: 1, + batch: 1, + events: vec![tinyvoice_bus::hotkey::SequencedHotkeyEvent { + generation: 1, + sequence: 1, + event: HotkeyEvent::Pressed, + }], + reset: false, + active: false, + })), + Ok(Ok(HotkeyReply { + state: tinyvoice_bus::HotkeyState::Stopped, + generation: Some(1), + active: false, + continuity_lost: false, + })), + Ok(Ok(HotkeyBatch { + generation: 1, + batch: 1, + events: vec![tinyvoice_bus::hotkey::SequencedHotkeyEvent { + generation: 1, + sequence: 1, + event: HotkeyEvent::Released, + }], + reset: false, + active: true, + })), + ), + "A's listener operations stay on A, and closing A leaves B live" + ); + assert_eq!( + methods(&fixture.state_a), + [ + tinyvoice_bus::names::methods::HOTKEY_RESERVE, + tinyvoice_bus::names::methods::ROUTE, + tinyvoice_bus::names::methods::HOTKEY_START, + tinyvoice_bus::names::methods::HOTKEY_FEED, + tinyvoice_bus::names::methods::HOTKEY_READ, + tinyvoice_bus::names::methods::HOTKEY_STOP, + ] + .map(str::to_owned) + ); + assert_eq!( + methods(&fixture.state_b), + [ + tinyvoice_bus::names::methods::ROUTE, + tinyvoice_bus::names::methods::HOTKEY_RESERVE, + tinyvoice_bus::names::methods::HOTKEY_START, + tinyvoice_bus::names::methods::HOTKEY_FEED, + tinyvoice_bus::names::methods::HOTKEY_READ, + tinyvoice_bus::names::methods::HOTKEY_READ, + ] + .map(str::to_owned), + "B receives its own usable calls and no call from A's projection" + ); +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn owned_resources_fail_closed_after_their_peer_detaches() { + let fixture = handoff_fixture() + .await + .expect("start both service peers on one broker"); + let a_vad = fixture + .client + .open_vad(VadConfig::default()) + .await + .expect("open A VAD"); + let a_recorder = RecorderSession::open(&fixture.client) + .await + .expect("open A recorder"); + let (_a_reservation, a_capture) = reserve_and_start_owned_capture(&fixture.client).await; + let a_hotkey = fixture + .client + .reserve_hotkey_owned(HotkeyRequest { + key: "Fn".to_owned(), + mode: ActivationMode::Push, + source: HotkeySource::Host, + }) + .await + .expect("reserve A hotkey") + .expect("A hotkey reserves"); + + fixture + .move_public_alias_to_b() + .await + .expect("move alias while A remains live"); + assert_eq!( + fixture + .direct_a + .route("A unique-peer positive control") + .await + .expect("A remains callable before detach"), + VoiceIntent::Pause + ); + let b_vad = fixture + .client + .open_vad(VadConfig::default()) + .await + .expect("open colliding B VAD"); + let b_recorder = RecorderSession::open(&fixture.client) + .await + .expect("open colliding B recorder"); + let (_b_reservation, b_capture) = reserve_and_start_owned_capture(&fixture.client).await; + let b_hotkey = fixture + .client + .reserve_hotkey_owned(HotkeyRequest { + key: "Fn".to_owned(), + mode: ActivationMode::Push, + source: HotkeySource::Host, + }) + .await + .expect("reserve colliding B hotkey") + .expect("B hotkey reserves"); + assert_eq!(a_vad.id, b_vad.id); + assert_eq!(a_recorder.id, b_recorder.id); + assert_eq!(a_capture.handle, b_capture.handle); + assert_eq!(a_hotkey.handle, b_hotkey.handle); + + b_recorder + .advance(SessionInput::Run) + .await + .expect("B recorder responds"); + b_hotkey + .start() + .await + .expect("B hotkey start RPC succeeds") + .expect("B hotkey starts"); + b_hotkey + .feed( + 1, + vec![SequencedHostFact { + sequence: 1, + fact: HostKeyFact::Down, + }], + false, + ) + .await + .expect("B hotkey feed RPC succeeds") + .expect("B accepts host-owned input"); + assert_eq!( + b_vad.push(20, &[0.8]).await.expect("B VAD is usable").len(), + 1 + ); + assert!( + b_capture + .poll(1) + .await + .expect("B capture is usable") + .is_ok() + ); + assert!( + b_hotkey + .read(None) + .await + .expect("B hotkey is usable") + .is_ok() + ); + + fixture + .service_a + .close() + .await + .expect("detach A's service connection"); + assert_eq!( + fixture + .client + .route("B alias remains usable after A detaches") + .await + .expect("B remains the public alias owner"), + VoiceIntent::Resume + ); + + let a_vad_push = a_vad.push(20, &[0.8]).await; + let a_vad_speaking = a_vad.is_speaking().await; + let a_vad_reset = a_vad.reset().await; + let a_vad_close = a_vad.close().await; + let a_recorder_advance = a_recorder.advance(SessionInput::Run).await; + let a_recorder_status = a_recorder.status().await; + let a_recorder_close = a_recorder.close().await; + let a_capture_poll = a_capture.poll(1).await; + let a_capture_close = a_capture.close().await; + let a_hotkey_start = a_hotkey.start().await; + let a_hotkey_feed = a_hotkey + .feed( + 1, + vec![SequencedHostFact { + sequence: 1, + fact: HostKeyFact::Down, + }], + false, + ) + .await; + let a_hotkey_read = a_hotkey.read(None).await; + let a_hotkey_close = a_hotkey.close().await; + assert!( + a_vad_push.is_err() + && a_vad_speaking.is_err() + && a_vad_reset.is_err() + && a_vad_close.is_err() + && a_recorder_advance.is_err() + && a_recorder_status.is_err() + && a_recorder_close.is_err() + && a_capture_poll.is_err() + && a_capture_close.is_err() + && a_hotkey_start.is_err() + && a_hotkey_feed.is_err() + && a_hotkey_read.is_err() + && a_hotkey_close.is_err(), + "all operations against detached A fail instead of falling back to B" + ); + + assert_eq!( + b_vad.is_speaking().await, + Ok(true), + "A's failed VAD operations did not reach or close B" + ); + assert_eq!( + b_recorder.status().await, + Ok(SessionStatus { + phase: SessionPhase::Idle, + generation: 0, + }), + "A's failed recorder operations did not reach or close B" + ); + assert!( + b_capture + .poll(1) + .await + .expect("B capture remains pinned") + .is_ok() + ); + assert!( + b_hotkey + .read(None) + .await + .expect("B hotkey remains pinned") + .is_ok() + ); + assert_eq!( + methods(&fixture.state_b), + [ + tinyvoice_bus::names::methods::VAD_OPEN, + tinyvoice_bus::names::methods::SESSION_OPEN, + tinyvoice_bus::names::methods::RESERVE_CAPTURE, + tinyvoice_bus::names::methods::CAPTURE_START, + tinyvoice_bus::names::methods::HOTKEY_RESERVE, + tinyvoice_bus::names::methods::SESSION_ADVANCE, + tinyvoice_bus::names::methods::HOTKEY_START, + tinyvoice_bus::names::methods::HOTKEY_FEED, + tinyvoice_bus::names::methods::VAD_PUSH, + tinyvoice_bus::names::methods::CAPTURE_POLL, + tinyvoice_bus::names::methods::HOTKEY_READ, + tinyvoice_bus::names::methods::ROUTE, + tinyvoice_bus::names::methods::VAD_IS_SPEAKING, + tinyvoice_bus::names::methods::SESSION_STATUS, + tinyvoice_bus::names::methods::CAPTURE_POLL, + tinyvoice_bus::names::methods::HOTKEY_READ, + ] + .map(str::to_owned), + "B receives its own lifecycle calls and no A traffic after detach" + ); +} diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs new file mode 100644 index 0000000..09ddbe2 --- /dev/null +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -0,0 +1,734 @@ +//! Product-facing wire tests over a real in-memory `TinyBus` broker. + +// These behavior tests intentionally use assertion helpers that panic on failure. +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use serde_json::{Value, json}; +use tinybus::broker::Broker; +use tinybus::module::{ + AccessFuture, FailureReport, FailureReporter, FailureSink, ModuleAccess, ModuleCallError, + ModuleClient, ProxyFailure, ReleaseRecord, +}; +use tinybus::service::Interface; +use tinybus::transport::memory::MemoryBus; +use tinybus::{Connection, InterfaceName, MemberName, ObjectPath, Proxy}; + +use super::*; + +const MODULE_ID: &str = "tinyvoice"; + +static RELEASE: ReleaseRecord = ReleaseRecord { + id: MODULE_ID, + bus_name: tinyvoice_bus::BUS_NAME, + object_path: tinyvoice_bus::OBJECT_PATH, + version: "0.3.2", + release_url: "https://example.invalid/tinyvoice", + assets: &[], +}; + +#[derive(Default)] +struct RecordingFailures(Mutex>); + +impl FailureSink for RecordingFailures { + fn capture(&self, report: &FailureReport<'_>) { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push((report.module.to_owned(), report.reason.code().to_owned())); + } +} + +struct FixtureAccess { + reporter: FailureReporter, +} + +impl ModuleAccess for FixtureAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == MODULE_ID).then_some(&RELEASE) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>( + &'a self, + _id: &'a str, + ) -> AccessFuture<'a, std::result::Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn proxy<'a>( + &'a self, + _bus_name: &'a str, + _object_path: &'a str, + ) -> AccessFuture<'a, std::result::Result> { + Box::pin(async { Err(ProxyFailure::Transport) }) + } +} + +#[derive(Clone, Copy)] +enum FailureMode { + Unavailable, + Incompatible, + Execution, +} + +#[derive(Default)] +struct MockState { + calls: Vec<(String, Value)>, + failure: Option, + replies: Vec<(String, Value)>, +} + +type State = Arc>; + +struct MockVoice { + state: State, +} + +#[async_trait] +impl Interface for MockVoice { + fn name(&self) -> InterfaceName { + InterfaceName::new(tinyvoice_bus::BUS_NAME).unwrap_or_else(|_| std::process::abort()) + } + + fn members(&self) -> Vec { + tinyvoice_bus::METHODS + .iter() + .filter_map(|name| MemberName::new(*name).ok()) + .collect() + } + + async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { + let name = member.as_str(); + let mut state = self + .state + .lock() + .map_err(|_| tinybus::Error::failed("fixture state is poisoned"))?; + state.calls.push((name.to_owned(), args)); + + if let Some(mode) = state.failure { + let secret = "sensitive transcript marker"; + return Err(match mode { + FailureMode::Unavailable => tinybus::Error::ModuleUnavailable { + module: MODULE_ID.to_owned(), + state: "stopped".to_owned(), + detail: "module stopped".to_owned(), + }, + FailureMode::Incompatible => tinybus::Error::UnknownMethod { + interface: InterfaceName::new(tinyvoice_bus::BUS_NAME) + .unwrap_or_else(|_| std::process::abort()), + member: MemberName::new(name).unwrap_or_else(|_| std::process::abort()), + }, + FailureMode::Execution => tinybus::Error::failed(secret), + }); + } + + if let Some((_, reply)) = state.replies.iter().find(|(method, _)| method == name) { + return Ok(reply.clone()); + } + + let reply = match name { + tinyvoice_bus::names::methods::ROUTE => json!(r#"{"intent":"pause"}"#), + tinyvoice_bus::names::methods::SEQUENCE_UTTERANCE => { + json!(r#"{"disposition":"command","command":"open slack"}"#) + } + tinyvoice_bus::names::methods::GATE_RECORDING => json!("\"pass\""), + tinyvoice_bus::names::methods::IS_HALLUCINATED + | tinyvoice_bus::names::methods::VAD_IS_SPEAKING => json!(true), + tinyvoice_bus::names::methods::PREPARE_CAPTURE + | tinyvoice_bus::names::methods::ENCODE_WAV + | tinyvoice_bus::names::methods::ENCODE_WAV_PCM16 => json!("AQ=="), + tinyvoice_bus::names::methods::PREPARE_FRAMES => json!("AAAAAA=="), + tinyvoice_bus::names::methods::FRAME_ENERGIES => json!([0.25]), + tinyvoice_bus::names::methods::VAD_OPEN => json!(41), + tinyvoice_bus::names::methods::VAD_PUSH => json!("[]"), + tinyvoice_bus::names::methods::SESSION_OPEN => json!(73), + tinyvoice_bus::names::methods::SESSION_ADVANCE => { + json!( + r#"{"accepted":true,"phase":"idle","generation":0,"action":{"action":"none"}}"# + ) + } + tinyvoice_bus::names::methods::SESSION_STATUS => { + json!(r#"{"phase":"idle","generation":0}"#) + } + tinyvoice_bus::names::methods::RESERVE_CAPTURE => json!({"Ok": "capture-1"}), + tinyvoice_bus::names::methods::CAPTURE_START => json!({ + "Ok": { + "handle": "capture-1", + "format": {"source_rate": 48_000, "channels": 1} + } + }), + tinyvoice_bus::names::methods::CAPTURE_POLL => { + json!({"Ok": {"chunks": [], "closed": false}}) + } + tinyvoice_bus::names::methods::CAPTURE_STOP + | tinyvoice_bus::names::methods::CAPTURE_SHUTDOWN => json!({"Ok": null}), + tinyvoice_bus::names::methods::HOTKEY_RESERVE => json!({"Ok": "hotkey-1"}), + tinyvoice_bus::names::methods::HOTKEY_START + | tinyvoice_bus::names::methods::HOTKEY_STOP + | tinyvoice_bus::names::methods::HOTKEY_SHUTDOWN + | tinyvoice_bus::names::methods::HOTKEY_FEED => json!({ + "Ok": { + "state": "running", + "generation": 1, + "active": false, + "continuity_lost": false + } + }), + tinyvoice_bus::names::methods::HOTKEY_READ => json!({ + "Ok": { + "generation": 1, + "batch": 1, + "events": [], + "reset": false, + "active": false + } + }), + _ => Value::Null, + }; + Ok(reply) + } +} + +async fn fixture() -> tinybus::Result<(Connection, TinyVoiceClient, State, Arc)> +{ + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await?).await?; + let state = Arc::new(Mutex::new(MockState::default())); + service + .serve_at( + ObjectPath::new(tinyvoice_bus::OBJECT_PATH)?, + MockVoice { + state: Arc::clone(&state), + }, + ) + .await?; + service.request_name(tinyvoice_bus::BUS_NAME).await?; + let caller = Connection::connect(bus.connect().await?).await?; + let proxy = caller.proxy( + tinyvoice_bus::BUS_NAME, + tinyvoice_bus::OBJECT_PATH, + tinyvoice_bus::BUS_NAME, + )?; + let failures = Arc::new(RecordingFailures::default()); + let module = ModuleClient::new(FixtureAccess { + reporter: FailureReporter::new(failures.clone()), + }) + .with_fixture(proxy); + Ok((service, TinyVoiceClient::new(module), state, failures)) +} + +fn encoded(samples: &[f32]) -> String { + use base64::Engine as _; + let bytes: Vec = samples + .iter() + .flat_map(|sample| sample.to_le_bytes()) + .collect(); + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn recorded(state: &State) -> Vec<(String, Value)> { + state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .calls + .clone() +} + +fn context(label: &str, result: Result) -> std::io::Result { + result.map_err(|error| std::io::Error::other(format!("{label}: {:?}", error.kind()))) +} + +#[tokio::test] +async fn stateless_wrappers_preserve_every_wire_member_and_positional_argument() +-> std::result::Result<(), Box> { + let (_service, client, state, _) = fixture().await?; + let samples = [1.0_f32, -1.0]; + let gates = tinyvoice_bus::RecordingGates { + min_duration_secs: 0.3, + silence_threshold: 0.01, + }; + + assert_eq!( + client.route("pause").await?, + tinyvoice_bus::VoiceIntent::Pause + ); + assert_eq!( + client + .sequence_utterance("hey tiny open slack", "Hey Tiny") + .await?, + tinyvoice_bus::UtteranceDisposition::Command { + command: "open slack".to_owned() + } + ); + assert_eq!( + client.gate_recording(1.0, 0.5, gates).await?, + tinyvoice_bus::GateVerdict::Pass + ); + assert!( + client + .is_hallucinated("okay", tinyvoice_bus::Mode::Dictation) + .await? + ); + assert_eq!(client.prepare_capture(&samples, 32_000, 2, 0.0).await?, [1]); + assert_eq!(client.encode_wav(&samples, 16_000).await?, [1]); + assert_eq!(client.prepare_frames(&samples, 32_000, 2).await?, [0.0]); + assert_eq!(client.frame_energies(&samples, 2).await?, [0.25]); + assert_eq!(client.encode_wav_pcm16(&[-1_i16, 2], 16_000, 1).await?, [1]); + + assert_eq!( + recorded(&state), + [ + ( + tinyvoice_bus::names::methods::ROUTE.to_owned(), + json!(["pause"]) + ), + ( + tinyvoice_bus::names::methods::SEQUENCE_UTTERANCE.to_owned(), + json!(["hey tiny open slack", "Hey Tiny"]) + ), + ( + tinyvoice_bus::names::methods::GATE_RECORDING.to_owned(), + json!([1.0_f32, 0.5_f32, 0.3_f32, 0.01_f32]) + ), + ( + tinyvoice_bus::names::methods::IS_HALLUCINATED.to_owned(), + json!(["okay", "dictation"]) + ), + ( + tinyvoice_bus::names::methods::PREPARE_CAPTURE.to_owned(), + json!([encoded(&samples), 32_000, 2, 0.0]) + ), + ( + tinyvoice_bus::names::methods::ENCODE_WAV.to_owned(), + json!([encoded(&samples), 16_000]) + ), + ( + tinyvoice_bus::names::methods::PREPARE_FRAMES.to_owned(), + json!([encoded(&samples), 32_000, 2]) + ), + ( + tinyvoice_bus::names::methods::FRAME_ENERGIES.to_owned(), + json!([encoded(&samples), 2]) + ), + ( + tinyvoice_bus::names::methods::ENCODE_WAV_PCM16.to_owned(), + json!(["//8CAA==", 16_000, 1]) + ), + ] + ); + Ok(()) +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly() +-> std::result::Result<(), Box> { + let (_service, client, state, _) = fixture().await?; + let vad_config = tinyvoice_bus::VadConfig::default(); + let vad = VadSession::open(&client, vad_config).await?; + assert!(!format!("{vad:?}").contains("41")); + assert_eq!( + context("VAD push", vad.push(20, &[0.2, 0.0]).await)?, + Vec::::new() + ); + assert!(context("VAD speaking", vad.is_speaking().await)?); + context("VAD reset", vad.reset().await)?; + let vad_id = vad.id; + context("VAD close", vad.close().await)?; + drop(vad); + + let recorder = RecorderSession::open(&client).await?; + assert!(!format!("{recorder:?}").contains("73")); + assert_eq!( + context( + "recorder advance", + recorder.advance(tinyvoice_bus::SessionInput::Run).await, + )?, + tinyvoice_bus::SessionAdvance { + accepted: true, + phase: tinyvoice_bus::SessionPhase::Idle, + generation: 0, + action: tinyvoice_bus::SessionAction::None, + } + ); + assert_eq!( + context("recorder status", recorder.status().await)?, + tinyvoice_bus::SessionStatus { + phase: tinyvoice_bus::SessionPhase::Idle, + generation: 0, + } + ); + let recorder_id = recorder.id; + context("recorder close", recorder.close().await)?; + drop(recorder); + + let abandoned_vad = VadSession::open(&client, vad_config).await?; + drop(abandoned_vad); + let abandoned_recorder = RecorderSession::open(&client).await?; + drop(abandoned_recorder); + + let calls = recorded(&state); + assert_eq!( + calls, + [ + ( + tinyvoice_bus::names::methods::VAD_OPEN.to_owned(), + json!([ + r#"{"onset_threshold":0.01,"hangover_ms":800,"min_speech_ms":300,"max_utterance_ms":30000}"# + ]) + ), + ( + tinyvoice_bus::names::methods::VAD_PUSH.to_owned(), + json!([vad_id, 20, [0.2_f32, 0.0_f32]]) + ), + ( + tinyvoice_bus::names::methods::VAD_IS_SPEAKING.to_owned(), + json!([vad_id]) + ), + ( + tinyvoice_bus::names::methods::VAD_RESET.to_owned(), + json!([vad_id]) + ), + ( + tinyvoice_bus::names::methods::VAD_CLOSE.to_owned(), + json!([vad_id]) + ), + ( + tinyvoice_bus::names::methods::SESSION_OPEN.to_owned(), + json!([]) + ), + ( + tinyvoice_bus::names::methods::SESSION_ADVANCE.to_owned(), + json!([recorder_id, r#"{"input":"run"}"#]) + ), + ( + tinyvoice_bus::names::methods::SESSION_STATUS.to_owned(), + json!([recorder_id]) + ), + ( + tinyvoice_bus::names::methods::SESSION_CLOSE.to_owned(), + json!([recorder_id]) + ), + ( + tinyvoice_bus::names::methods::VAD_OPEN.to_owned(), + json!([ + r#"{"onset_threshold":0.01,"hangover_ms":800,"min_speech_ms":300,"max_utterance_ms":30000}"# + ]) + ), + ( + tinyvoice_bus::names::methods::SESSION_OPEN.to_owned(), + json!([]) + ), + ] + ); + assert_eq!( + calls + .iter() + .filter(|(member, _)| member == tinyvoice_bus::names::methods::VAD_CLOSE) + .count(), + 1, + "dropping an open VAD handle must not invent a remote close" + ); + assert_eq!( + calls + .iter() + .filter(|(member, _)| member == tinyvoice_bus::names::methods::SESSION_CLOSE) + .count(), + 1, + "dropping an open recorder handle must not invent a remote close" + ); + Ok(()) +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors() +-> std::result::Result<(), Box> { + let (_service, client, state, _) = fixture().await?; + let permission = tinyvoice_bus::capture::MicrophonePermission::Granted; + let capture = client + .reserve_capture(permission) + .await? + .expect("capture reservation"); + let stream = client + .start_capture(capture.clone(), permission) + .await? + .expect("capture stream"); + let poll = tinyvoice_bus::capture::CapturePollRequest { + handle: stream.handle.clone(), + max_chunks: 2, + }; + assert_eq!( + client + .poll_capture(poll.clone()) + .await? + .expect("poll") + .chunks, + Vec::::new() + ); + client + .stop_capture(stream.handle.clone()) + .await? + .expect("capture stop"); + client.shutdown_capture().await?.expect("capture shutdown"); + + let request = tinyvoice_bus::HotkeyRequest { + key: "space".to_owned(), + mode: tinyvoice_bus::ActivationMode::Push, + source: tinyvoice_bus::HotkeySource::Host, + }; + let handle = client + .reserve_hotkey(request.clone()) + .await? + .expect("hotkey reservation"); + let started = client + .start_hotkey(handle.clone()) + .await? + .expect("hotkey start"); + let read = tinyvoice_bus::HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: None, + }; + assert_eq!( + client + .read_hotkey(read.clone()) + .await? + .expect("hotkey read") + .events, + Vec::::new() + ); + let feed = tinyvoice_bus::HotkeyFeedRequest { + handle: handle.clone(), + generation: started.generation.expect("started generation"), + facts: vec![tinyvoice_bus::SequencedHostFact { + sequence: 1, + fact: tinyvoice_bus::HostKeyFact::Down, + }], + overflow: false, + }; + assert_eq!( + client + .feed_hotkey(feed.clone()) + .await? + .expect("host feed") + .state, + started.state + ); + client + .stop_hotkey(handle.clone()) + .await? + .expect("hotkey stop"); + client.shutdown_hotkeys().await?.expect("hotkey shutdown"); + + let denied_hotkey_reply: Value = json!({"Err": "permission_denied"}); + let denied_capture_reply: Value = json!({"Err": {"code": "permission_denied"}}); + state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .replies + .extend([ + ( + tinyvoice_bus::names::methods::HOTKEY_RESERVE.to_owned(), + denied_hotkey_reply, + ), + ( + tinyvoice_bus::names::methods::RESERVE_CAPTURE.to_owned(), + denied_capture_reply, + ), + ]); + assert_eq!( + client.reserve_hotkey(request.clone()).await?, + Err(tinyvoice_bus::HotkeyError::PermissionDenied) + ); + assert_eq!( + client.reserve_capture(permission).await?, + Err(tinyvoice_bus::capture::CaptureError::PermissionDenied) + ); + + let calls = recorded(&state); + assert_eq!( + calls, + [ + ( + tinyvoice_bus::names::methods::RESERVE_CAPTURE.to_owned(), + json!([{"permission":"granted","handle":null}]) + ), + ( + tinyvoice_bus::names::methods::CAPTURE_START.to_owned(), + json!([{"permission":"granted","handle":"capture-1"}]) + ), + ( + tinyvoice_bus::names::methods::CAPTURE_POLL.to_owned(), + json!([{"handle":"capture-1","max_chunks":2}]) + ), + ( + tinyvoice_bus::names::methods::CAPTURE_STOP.to_owned(), + json!(["capture-1"]) + ), + ( + tinyvoice_bus::names::methods::CAPTURE_SHUTDOWN.to_owned(), + json!([]) + ), + ( + tinyvoice_bus::names::methods::HOTKEY_RESERVE.to_owned(), + json!([{"request":{"key":"space","mode":"push","source":"host"}}]) + ), + ( + tinyvoice_bus::names::methods::HOTKEY_START.to_owned(), + json!([{"handle":"hotkey-1"}]) + ), + ( + tinyvoice_bus::names::methods::HOTKEY_READ.to_owned(), + json!([{"handle":"hotkey-1","acknowledged_batch":null}]) + ), + ( + tinyvoice_bus::names::methods::HOTKEY_FEED.to_owned(), + json!([{"handle":"hotkey-1","generation":1,"facts":[{"sequence":1,"fact":"down"}],"overflow":false}]) + ), + ( + tinyvoice_bus::names::methods::HOTKEY_STOP.to_owned(), + json!([{"handle":"hotkey-1"}]) + ), + ( + tinyvoice_bus::names::methods::HOTKEY_SHUTDOWN.to_owned(), + json!([]) + ), + ( + tinyvoice_bus::names::methods::HOTKEY_RESERVE.to_owned(), + json!([{"request":{"key":"space","mode":"push","source":"host"}}]) + ), + ( + tinyvoice_bus::names::methods::RESERVE_CAPTURE.to_owned(), + json!([{"permission":"granted","handle":null}]) + ), + ] + ); + Ok(()) +} + +#[tokio::test] +async fn unexpected_intents_and_module_errors_remain_closed_and_redacted() +-> std::result::Result<(), Box> { + let (_service, client, state, failures) = fixture().await?; + state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .replies + .push(( + tinyvoice_bus::names::methods::ROUTE.to_owned(), + json!(r#"{"intent":"newer_unrecognized_intent"}"#), + )); + assert_eq!( + client.route("unrecognized command").await?, + tinyvoice_bus::VoiceIntent::Unknown + ); + + state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .failure = Some(FailureMode::Unavailable); + let unavailable = client.route("secret transcript").await.unwrap_err(); + assert_eq!(unavailable.kind(), ModuleCallError::Unavailable); + assert!(!format!("{unavailable:?}").contains("module stopped")); + assert!(!unavailable.to_string().contains("module stopped")); + + state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .failure = Some(FailureMode::Execution); + let failure = client.route("secret transcript").await.unwrap_err(); + assert_eq!(failure.kind(), ModuleCallError::ModuleFault); + assert_eq!(failure.raw_message(), "sensitive transcript marker"); + assert!(format!("{failure:?}").contains("")); + assert!(!format!("{failure:?}").contains("sensitive transcript marker")); + assert!(!failure.to_string().contains("sensitive transcript marker")); + assert!(!failure.to_string().contains("sensitive transcript marker")); + + state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .failure = Some(FailureMode::Incompatible); + let incompatible = client.route("secret transcript").await.unwrap_err(); + assert_eq!(incompatible.kind(), ModuleCallError::IncompatibleContract); + assert!(!format!("{incompatible:?}").contains("sensitive transcript marker")); + + { + let mut state = state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.failure = None; + let route_reply = state + .replies + .iter_mut() + .find(|(member, _)| member == tinyvoice_bus::names::methods::ROUTE) + .expect("route override"); + route_reply.1 = json!(r#"{"payload":"sensitive transcript marker"}"#); + } + let malformed = client.route("secret transcript").await.unwrap_err(); + assert_eq!(malformed.kind(), ModuleCallError::ModuleFault); + assert!(!format!("{malformed:?}").contains("sensitive transcript marker")); + assert!( + !malformed + .to_string() + .contains("sensitive transcript marker") + ); + + let reports = failures + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(reports.len(), 4); + assert_eq!( + reports[0], + (MODULE_ID.to_owned(), "module_unavailable".to_owned()) + ); + assert_eq!( + reports[1], + (MODULE_ID.to_owned(), "module_fault".to_owned()) + ); + assert_eq!( + reports[2], + (MODULE_ID.to_owned(), "incompatible_contract".to_owned()) + ); + assert_eq!( + reports[3], + (MODULE_ID.to_owned(), "module_fault".to_owned()) + ); + assert!(!format!("{reports:?}").contains("sensitive transcript marker")); + Ok(()) +} + +#[tokio::test] +async fn proxy_startup_failures_keep_the_transport_classification() +-> std::result::Result<(), Box> { + let failures = Arc::new(RecordingFailures::default()); + let module = ModuleClient::new(FixtureAccess { + reporter: FailureReporter::new(failures.clone()), + }); + let client = TinyVoiceClient::new(module); + let error = client.route("private transcript").await.unwrap_err(); + + assert_eq!(error.kind(), ModuleCallError::TransportFailed); + assert!(!format!("{error:?}").contains("private transcript")); + assert!(!error.to_string().contains("private transcript")); + assert_eq!( + failures + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_slice(), + [(MODULE_ID.to_owned(), "transport_failed".to_owned())] + ); + Ok(()) +} diff --git a/crates/tinyvoice-module/Cargo.lock b/crates/tinyvoice-module/Cargo.lock index 82aaf2b..616fe1b 100644 --- a/crates/tinyvoice-module/Cargo.lock +++ b/crates/tinyvoice-module/Cargo.lock @@ -64,15 +64,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" @@ -425,12 +416,6 @@ 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" @@ -463,17 +448,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" @@ -955,6 +929,28 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_casemap" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2edfd120d8445de853cf53a1abec916f638f933a70a6f4bf8f7e2d7b7f884e9" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059fcda0fc8d33effc6633a8cf0d8a3c17caf7d7c91918fba9918744f501e4e8" + [[package]] name = "icu_collections" version = "2.3.0" @@ -963,6 +959,7 @@ checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "serde", "utf8_iter", "yoke", "zerofrom", @@ -977,6 +974,7 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", @@ -1031,6 +1029,8 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1507,6 +1507,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -1678,6 +1680,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rawzip" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9999039f74cd73c03158cddfe4362991703c74efe5ce5c8073026f80a5e48c" + [[package]] name = "rdev" version = "0.5.3" @@ -2146,7 +2154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -2248,11 +2256,13 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.1.5" +version = "0.3.2" dependencies = [ "async-trait", "flate2", + "icu_casemap", "libc", + "rawzip", "serde", "serde_json", "sha2 0.10.9", @@ -2263,13 +2273,13 @@ dependencies = [ "tokio", "toml 1.1.6+spec-1.1.0", "tracing", + "unicode-normalization", "ureq", - "zip", ] [[package]] name = "tinybus-macros" -version = "0.1.5" +version = "0.3.2" dependencies = [ "proc-macro2", "quote", @@ -2278,7 +2288,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.1.5" +version = "0.3.2" dependencies = [ "async-trait", "serde", @@ -2411,6 +2421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -2495,7 +2506,7 @@ dependencies = [ [[package]] name = "tinyvoice-module" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "async-trait", @@ -2789,6 +2800,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -3410,6 +3430,7 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -3418,6 +3439,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -3434,37 +3456,8 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "arbitrary", - "crc32fast", - "crossbeam-utils", - "displaydoc", - "flate2", - "indexmap", - "memchr", - "thiserror 2.0.20", - "zopfli", -] - [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] diff --git a/crates/tinyvoice-module/Cargo.toml b/crates/tinyvoice-module/Cargo.toml index 86a3e1d..4f70d97 100644 --- a/crates/tinyvoice-module/Cargo.toml +++ b/crates/tinyvoice-module/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "tinyvoice-module" -version = "0.3.2" +version = "0.3.3" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" @@ -39,10 +39,10 @@ tinyvoice-bus = { version = "0.1.4", 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"] } -# 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"] } +# TinyBus provides the typed service interface and dynamic module host ABI. +tinybus = { version = ">=0.3.2, <0.4", default-features = false, features = ["macros", "modules"] } # The module-side SDK owns its runtime and exports the stable C entrypoints. -tinybus-module = ">=0.1.3, <0.2" +tinybus-module = ">=0.3.2, <0.4" # The module's methods are `async fn` on a served object, so it needs a runtime # even though every operation underneath is synchronous. tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 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/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index bbb4429..8be0af6 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -198,6 +198,8 @@ fn to_json(value: &T) -> TinyBusResult { clippy::unused_async, reason = "TinyBus interface methods require async signatures" )] +// This lint was added after the crate's Rust 1.88 MSRV. +#[allow(unknown_lints)] #[expect( clippy::unused_async_trait_impl, reason = "TinyBus interface methods must retain async signatures at this dynamic module boundary" diff --git a/vendor/tinybus b/vendor/tinybus index eb5cf72..cba2de0 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit eb5cf727071a6eb12f9efa3db8ec59e54039f336 +Subproject commit cba2de0e573f99fe4c7e45ba14e2d6b5a2a7757f