From 59592214659d1c7cbf3366ed6676fca6118167e4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 02:19:22 +0530 Subject: [PATCH 01/67] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to a newer revision. Auto-committed-on: macbook --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index eb5cf72..e5f1cd2 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit eb5cf727071a6eb12f9efa3db8ec59e54039f336 +Subproject commit e5f1cd2d2a7b905ee82410a61219292ca8a85c1f From 9a35a5f6a3d3c996b6595db3c045818f4bfcdb62 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 02:19:37 +0530 Subject: [PATCH 02/67] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to the latest upstream commit. Auto-committed-on: macbook --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index e5f1cd2..eb5cf72 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit e5f1cd2d2a7b905ee82410a61219292ca8a85c1f +Subproject commit eb5cf727071a6eb12f9efa3db8ec59e54039f336 From 2bba805899e210885b5982069e9c130eaf23f498 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 02:45:53 +0530 Subject: [PATCH 03/67] feat(client): add typed tinyvoice-client crate Adds a new workspace member providing a typed TinyBus client for the loadable TinyVoice module, and lists it in the root README alongside the existing crates. Auto-committed-on: macbook --- Cargo.toml | 4 +- README.md | 1 + crates/tinyvoice-client/Cargo.toml | 22 +++ crates/tinyvoice-client/README.md | 22 +++ crates/tinyvoice-client/src/lib.rs | 9 ++ crates/tinyvoice-client/src/lib_tests.rs | 166 +++++++++++++++++++++++ 6 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 crates/tinyvoice-client/Cargo.toml create mode 100644 crates/tinyvoice-client/README.md create mode 100644 crates/tinyvoice-client/src/lib.rs create mode 100644 crates/tinyvoice-client/src/lib_tests.rs 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..3067d85 --- /dev/null +++ b/crates/tinyvoice-client/Cargo.toml @@ -0,0 +1,22 @@ +[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] +serde = { version = "1", features = ["derive"] } +tinyvoice-bus = { version = "0.1.4", path = "../tinyvoice-bus" } +tinybus = { version = ">=0.2.1, <0.3", path = "../../vendor/tinybus/crates/tinybus", default-features = false } + +[dev-dependencies] +async-trait = "0.1" +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +tinybus = { version = ">=0.2.1, <0.3", path = "../../vendor/tinybus/crates/tinybus", default-features = false, features = ["macros"] } + +[lints] +workspace = true diff --git a/crates/tinyvoice-client/README.md b/crates/tinyvoice-client/README.md new file mode 100644 index 0000000..7aac23b --- /dev/null +++ b/crates/tinyvoice-client/README.md @@ -0,0 +1,22 @@ +# 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. + +The client wraps one configured `ModuleClient`. That keeps module loading, +compatibility checks, deadlines, and redacted failure reporting on the same +path the host already configures; the client must not open a second bus +connection or create a private resolver. Its methods preserve each contract +member's positional tuple and typed reply. + +`VadSession` and `RecorderSession` are explicit async leases. Closing them calls +the corresponding module method; dropping a Rust handle does not imply remote +cleanup. OpenHuman keeps host policy such as volume clamping and conversion +from persisted server configuration into `VadConfig` in its own adapter. + +The recording-broker tests use only TinyBus's in-memory transport. They record +actual service invocations and verify the wire arguments and returned types; +they do not load a published artifact, access a microphone, or contact a +backend. diff --git a/crates/tinyvoice-client/src/lib.rs b/crates/tinyvoice-client/src/lib.rs new file mode 100644 index 0000000..314384a --- /dev/null +++ b/crates/tinyvoice-client/src/lib.rs @@ -0,0 +1,9 @@ +//! Typed host calls to the TinyVoice TinyBus module. +//! +//! The client owns the method names and wire conversions. Hosts provide one +//! configured [`tinybus::module::ModuleClient`]; module loading, reporting, +//! deadlines, and transport remain TinyBus responsibilities. + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs new file mode 100644 index 0000000..57788ca --- /dev/null +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -0,0 +1,166 @@ +//! Product-facing wire tests over a real in-memory TinyBus broker. + +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, 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"; +type Calls = Arc>>; + +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) }) + } +} + +struct MockVoice { + calls: Calls, +} + +#[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(); + self.calls + .lock() + .map_err(|_| tinybus::Error::failed("fixture call log is poisoned"))? + .push((name.to_owned(), args)); + let reply = match name { + tinyvoice_bus::names::methods::ROUTE => json!(r#"{"intent":"pause"}"#), + tinyvoice_bus::names::methods::VAD_OPEN => json!(41), + tinyvoice_bus::names::methods::VAD_CLOSE + | tinyvoice_bus::names::methods::SESSION_CLOSE => Value::Null, + tinyvoice_bus::names::methods::SESSION_OPEN => json!(73), + _ => Value::Null, + }; + Ok(reply) + } +} + +async fn fixture() -> tinybus::Result<(Connection, TinyVoiceClient, Calls)> { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await?).await?; + let calls = Arc::new(Mutex::new(Vec::new())); + service + .serve_at( + ObjectPath::new(tinyvoice_bus::OBJECT_PATH)?, + MockVoice { + calls: Arc::clone(&calls), + }, + ) + .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 module = ModuleClient::new(FixtureAccess { + reporter: FailureReporter::new(Arc::new(IgnoreFailures)), + }) + .with_fixture(proxy); + Ok((service, TinyVoiceClient::new(module), calls)) +} + +#[tokio::test] +async fn typed_calls_and_session_handles_use_the_recording_broker() +-> std::result::Result<(), Box> { + let (_service, client, calls) = fixture().await?; + + assert_eq!(client.route("please pause").await?, tinyvoice_bus::VoiceIntent::Pause); + + let vad = VadSession::open(&client, tinyvoice_bus::VadConfig::default()).await?; + vad.close().await?; + drop(vad); + + let recorder = RecorderSession::open(&client).await?; + recorder.close().await?; + drop(recorder); + + let actual = calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + assert_eq!( + actual, + [ + (tinyvoice_bus::names::methods::ROUTE.to_owned(), json!(["please pause"])), + ( + 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_CLOSE.to_owned(), json!([41])), + (tinyvoice_bus::names::methods::SESSION_OPEN.to_owned(), json!([])), + (tinyvoice_bus::names::methods::SESSION_CLOSE.to_owned(), json!([73])), + ] + ); + Ok(()) +} From 144b0e15652c296d5fc2b20c54cebf40bb2de6a9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 02:51:03 +0530 Subject: [PATCH 04/67] test(client): expand coverage of wire calls, session handles, and error redaction The mock voice now records calls and can inject failures or canned replies, letting the tests assert exact member names and positional arguments for every stateless wrapper, capture, and hotkey call. New cases verify that VAD and recorder handles close only explicitly, that unexpected intents map to Unknown, and that module errors stay redacted while still reaching the failure sink. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_tests.rs | 456 +++++++++++++++++++++-- 1 file changed, 425 insertions(+), 31 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index 57788ca..fca3f58 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -6,8 +6,8 @@ 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, + AccessFuture, FailureReport, FailureReporter, FailureSink, ModuleAccess, ModuleCallError, + ModuleClient, ProxyFailure, ReleaseRecord, }; use tinybus::service::Interface; use tinybus::transport::memory::MemoryBus; @@ -28,10 +28,15 @@ static RELEASE: ReleaseRecord = ReleaseRecord { }; #[derive(Default)] -struct IgnoreFailures; +struct RecordingFailures(Mutex>); -impl FailureSink for IgnoreFailures { - fn capture(&self, _report: &FailureReport<'_>) {} +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 { @@ -67,15 +72,29 @@ impl ModuleAccess for FixtureAccess { } } +#[derive(Clone, Copy)] +enum FailureMode { + Unavailable, + Execution, +} + +#[derive(Default)] +struct MockState { + calls: Vec<(String, Value)>, + failure: Option, + replies: Vec<(String, Value)>, +} + +type State = Arc>; + struct MockVoice { - calls: Calls, + state: State, } #[async_trait] impl Interface for MockVoice { fn name(&self) -> InterfaceName { - InterfaceName::new(tinyvoice_bus::BUS_NAME) - .unwrap_or_else(|_| std::process::abort()) + InterfaceName::new(tinyvoice_bus::BUS_NAME).unwrap_or_else(|_| std::process::abort()) } fn members(&self) -> Vec { @@ -87,32 +106,111 @@ impl Interface for MockVoice { async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { let name = member.as_str(); - self.calls + let mut state = self + .state .lock() - .map_err(|_| tinybus::Error::failed("fixture call log is poisoned"))? - .push((name.to_owned(), args)); + .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: secret.to_owned(), + }, + 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 => 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_CLOSE - | tinyvoice_bus::names::methods::SESSION_CLOSE => Value::Null, + tinyvoice_bus::names::methods::VAD_PUSH => json!("[]"), + tinyvoice_bus::names::methods::VAD_IS_SPEAKING => json!(true), tinyvoice_bus::names::methods::SESSION_OPEN => json!(73), + tinyvoice_bus::names::methods::SESSION_ADVANCE => json!( + r#"{"accepted":true,"phase":"idle","generation":0,"action":"none"}"# + ), + tinyvoice_bus::names::methods::SESSION_STATUS => { + json!(r#"{"phase":"idle","generation":0}"#) + } + tinyvoice_bus::names::methods::RESERVE_CAPTURE => json!({ + "Ok": {"handle": "capture-1"} + }), + tinyvoice_bus::names::methods::CAPTURE_START => json!({ + "Ok": { + "handle": {"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 + | tinyvoice_bus::names::methods::VAD_RESET + | tinyvoice_bus::names::methods::VAD_CLOSE + | tinyvoice_bus::names::methods::SESSION_CLOSE => 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 => 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 + } + }), + tinyvoice_bus::names::methods::HOTKEY_FEED => json!({ + "Ok": { + "state": "running", + "generation": 1, + "active": false, + "continuity_lost": false + } + }), _ => Value::Null, }; Ok(reply) } } -async fn fixture() -> tinybus::Result<(Connection, TinyVoiceClient, Calls)> { +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 calls = Arc::new(Mutex::new(Vec::new())); + let state = Arc::new(Mutex::new(MockState::default())); service .serve_at( ObjectPath::new(tinyvoice_bus::OBJECT_PATH)?, MockVoice { - calls: Arc::clone(&calls), + state: Arc::clone(&state), }, ) .await?; @@ -123,44 +221,340 @@ async fn fixture() -> tinybus::Result<(Connection, TinyVoiceClient, Calls)> { tinyvoice_bus::OBJECT_PATH, tinyvoice_bus::BUS_NAME, )?; + let failures = Arc::new(RecordingFailures::default()); let module = ModuleClient::new(FixtureAccess { - reporter: FailureReporter::new(Arc::new(IgnoreFailures)), + reporter: FailureReporter::new(failures.clone()), }) .with_fixture(proxy); - Ok((service, TinyVoiceClient::new(module), calls)) + 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() } #[tokio::test] -async fn typed_calls_and_session_handles_use_the_recording_broker() +async fn stateless_wrappers_preserve_every_wire_member_and_positional_argument() -> std::result::Result<(), Box> { - let (_service, client, calls) = fixture().await?; + 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("please pause").await?, tinyvoice_bus::VoiceIntent::Pause); + 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]); - let vad = VadSession::open(&client, tinyvoice_bus::VadConfig::default()).await?; + 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, 0.5, 0.3, 0.01]) + ), + ( + 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!(["/////wIAAA==", 16_000, 1]) + ), + ] + ); + Ok(()) +} + +#[tokio::test] +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!(vad.push(20, &[0.2, 0.0]).await?.is_empty()); + assert!(vad.is_speaking().await?); + vad.reset().await?; + let vad_id = vad.id(); vad.close().await?; drop(vad); let recorder = RecorderSession::open(&client).await?; + assert_eq!( + 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!( + recorder.status().await?, + tinyvoice_bus::SessionStatus { + phase: tinyvoice_bus::SessionPhase::Idle, + generation: 0, + } + ); + let recorder_id = recorder.id(); recorder.close().await?; drop(recorder); - let actual = calls - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone(); + 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!( - actual, + calls, [ - (tinyvoice_bus::names::methods::ROUTE.to_owned(), json!(["please pause"])), ( 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}"#]), + 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, 0.0]]) + ), + ( + 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::VAD_CLOSE.to_owned(), json!([41])), + ( + 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!([])), - (tinyvoice_bus::names::methods::SESSION_CLOSE.to_owned(), json!([73])), ] ); + 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] +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::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::CapturePollRequest { + handle: stream.handle.clone(), + max_chunks: 2, + }; + assert!(client.poll_capture(poll.clone()).await?.expect("poll").chunks.is_empty()); + client.stop_capture(stream.handle.clone()).await?; + client.shutdown_capture().await?; + + 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!(client.read_hotkey(read.clone()).await?.expect("hotkey read").events.is_empty()); + 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_reply: Value = json!({"Err": "permission_denied"}); + state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .replies + .push((tinyvoice_bus::names::methods::HOTKEY_RESERVE.to_owned(), denied_reply)); + assert_eq!( + client.reserve_hotkey(request.clone()).await?, + Err(tinyvoice_bus::HotkeyError::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":{"handle":"capture-1"}}]) + ), + ( + tinyvoice_bus::names::methods::CAPTURE_POLL.to_owned(), + json!([{"handle":{"handle":"capture-1"},"max_chunks":2}]) + ), + ( + tinyvoice_bus::names::methods::CAPTURE_STOP.to_owned(), + json!([{"handle":"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"}}]) + ), + ] + ); + 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, ModuleCallError::Unavailable); + assert_eq!(format!("{unavailable:?}"), "Unavailable"); + + 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, ModuleCallError::ModuleFault); + assert_eq!(format!("{failure:?}"), "ModuleFault"); + assert!(!failure.to_string().contains("sensitive transcript marker")); + + let reports = failures + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(reports.len(), 2); + 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!(!format!("{reports:?}").contains("sensitive transcript marker")); Ok(()) } From 84dd0006d21413d4e79a5b1fee97d0d43be58805 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 02:51:14 +0530 Subject: [PATCH 05/67] test(tinyvoice-client): update mocks for flattened capture handles Adjust the mock voice interface and capture wrapper tests to use plain string capture handles instead of nested handle objects, and access the VAD id as a field rather than a method. Also return a null value for VAD and session close responses to match the updated protocol shapes. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_tests.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index fca3f58..751be6a 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -155,18 +155,18 @@ impl Interface for MockVoice { }), tinyvoice_bus::names::methods::CAPTURE_START => json!({ "Ok": { - "handle": {"handle": "capture-1"}, + "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 - | tinyvoice_bus::names::methods::VAD_RESET + tinyvoice_bus::names::methods::VAD_RESET | tinyvoice_bus::names::methods::VAD_CLOSE - | tinyvoice_bus::names::methods::SESSION_CLOSE => json!({"Ok": null}), + | tinyvoice_bus::names::methods::SESSION_CLOSE => Value::Null, + 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 @@ -323,7 +323,7 @@ async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly assert!(vad.push(20, &[0.2, 0.0]).await?.is_empty()); assert!(vad.is_speaking().await?); vad.reset().await?; - let vad_id = vad.id(); + let vad_id = vad.id; vad.close().await?; drop(vad); @@ -476,11 +476,11 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors ), ( tinyvoice_bus::names::methods::CAPTURE_START.to_owned(), - json!([{"permission":"granted","handle":{"handle":"capture-1"}}]) + json!([{"permission":"granted","handle":"capture-1"}]) ), ( tinyvoice_bus::names::methods::CAPTURE_POLL.to_owned(), - json!([{"handle":{"handle":"capture-1"},"max_chunks":2}]) + json!([{"handle":"capture-1","max_chunks":2}]) ), ( tinyvoice_bus::names::methods::CAPTURE_STOP.to_owned(), From 5e81b8857cce81d5a86725fd2390ad5797718d22 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 02:51:42 +0530 Subject: [PATCH 06/67] test(tinyvoice-client): cover incompatible contract errors and updated bus paths Add a mock failure mode that returns an unknown method so the client's incompatible-contract mapping and failure reporting are exercised, and update the capture test to use the relocated bus module paths. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_tests.rs | 26 ++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index 751be6a..8938519 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -75,6 +75,7 @@ impl ModuleAccess for FixtureAccess { #[derive(Clone, Copy)] enum FailureMode { Unavailable, + Incompatible, Execution, } @@ -118,7 +119,12 @@ impl Interface for MockVoice { FailureMode::Unavailable => tinybus::Error::ModuleUnavailable { module: MODULE_ID.to_owned(), state: "stopped".to_owned(), - detail: secret.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), }); @@ -413,13 +419,13 @@ async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly 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::MicrophonePermission::Granted; + 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::CapturePollRequest { + let poll = tinyvoice_bus::capture::CapturePollRequest { handle: stream.handle.clone(), max_chunks: 2, }; @@ -548,13 +554,25 @@ async fn unexpected_intents_and_module_errors_remain_closed_and_redacted() assert_eq!(format!("{failure:?}"), "ModuleFault"); 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, ModuleCallError::IncompatibleContract); + assert_eq!(format!("{incompatible:?}"), "IncompatibleContract"); + let reports = failures .0 .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!(reports.len(), 2); + assert_eq!(reports.len(), 3); 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!(!format!("{reports:?}").contains("sensitive transcript marker")); Ok(()) } From 08a8728bc4f6088d694f5942ebde4f10e287d67c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 02:51:57 +0530 Subject: [PATCH 07/67] chore(tinyvoice-client): document draft TinyBus constraint and reformat tests The README now explains how to construct the client from the host's single configured ModuleClient and notes that the manifest's TinyBus version range is a draft pending alignment with the 0.3.x runtime. Test code was reformatted to satisfy rustfmt, with no change in behaviour. Auto-committed-on: macbook --- crates/tinyvoice-client/Cargo.toml | 1 + crates/tinyvoice-client/README.md | 15 ++- crates/tinyvoice-client/src/lib_tests.rs | 143 ++++++++++++++++++----- 3 files changed, 122 insertions(+), 37 deletions(-) diff --git a/crates/tinyvoice-client/Cargo.toml b/crates/tinyvoice-client/Cargo.toml index 3067d85..00347ed 100644 --- a/crates/tinyvoice-client/Cargo.toml +++ b/crates/tinyvoice-client/Cargo.toml @@ -10,6 +10,7 @@ repository = "https://github.com/tinyhumansai/tinyvoice" [dependencies] serde = { version = "1", features = ["derive"] } tinyvoice-bus = { version = "0.1.4", path = "../tinyvoice-bus" } +# Draft constraint: align with the final TinyBus 0.3.x runtime before publishing. tinybus = { version = ">=0.2.1, <0.3", path = "../../vendor/tinybus/crates/tinybus", default-features = false } [dev-dependencies] diff --git a/crates/tinyvoice-client/README.md b/crates/tinyvoice-client/README.md index 7aac23b..558e791 100644 --- a/crates/tinyvoice-client/README.md +++ b/crates/tinyvoice-client/README.md @@ -5,11 +5,12 @@ 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. -The client wraps one configured `ModuleClient`. That keeps module loading, -compatibility checks, deadlines, and redacted failure reporting on the same -path the host already configures; the client must not open a second bus -connection or create a private resolver. Its methods preserve each contract -member's positional tuple and typed reply. +Construct it with `TinyVoiceClient::new(module_client)` using the host's one +configured `ModuleClient`. That keeps module loading, compatibility checks, +deadlines, and redacted failure reporting on the same path the host already +configures; the client must not open a second bus connection or create a +private resolver. Its methods preserve each contract member's positional +tuple and typed reply. `VadSession` and `RecorderSession` are explicit async leases. Closing them calls the corresponding module method; dropping a Rust handle does not imply remote @@ -20,3 +21,7 @@ The recording-broker tests use only TinyBus's in-memory transport. They record actual service invocations and verify the wire arguments and returned types; they do not load a published artifact, access a microphone, or contact a backend. + +The manifest's TinyBus version range is a draft against the published 0.2.1 +client API. Pin the final constraint to the TinyBus 0.3.x runtime when the +runtime SDK and vendored gitlink are aligned. diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index 8938519..2c81ec7 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -150,9 +150,9 @@ impl Interface for MockVoice { tinyvoice_bus::names::methods::VAD_PUSH => json!("[]"), tinyvoice_bus::names::methods::VAD_IS_SPEAKING => json!(true), tinyvoice_bus::names::methods::SESSION_OPEN => json!(73), - tinyvoice_bus::names::methods::SESSION_ADVANCE => json!( - r#"{"accepted":true,"phase":"idle","generation":0,"action":"none"}"# - ), + tinyvoice_bus::names::methods::SESSION_ADVANCE => { + json!(r#"{"accepted":true,"phase":"idle","generation":0,"action":"none"}"#) + } tinyvoice_bus::names::methods::SESSION_STATUS => { json!(r#"{"phase":"idle","generation":0}"#) } @@ -207,7 +207,8 @@ impl Interface for MockVoice { } } -async fn fixture() -> tinybus::Result<(Connection, TinyVoiceClient, State, Arc)> { +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?; @@ -237,7 +238,10 @@ async fn fixture() -> tinybus::Result<(Connection, TinyVoiceClient, State, Arc String { use base64::Engine as _; - let bytes: Vec = samples.iter().flat_map(|sample| sample.to_le_bytes()).collect(); + let bytes: Vec = samples + .iter() + .flat_map(|sample| sample.to_le_bytes()) + .collect(); base64::engine::general_purpose::STANDARD.encode(bytes) } @@ -259,9 +263,14 @@ async fn stateless_wrappers_preserve_every_wire_member_and_positional_argument() 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?, + 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() } @@ -270,9 +279,11 @@ async fn stateless_wrappers_preserve_every_wire_member_and_positional_argument() client.gate_recording(1.0, 0.5, gates).await?, tinyvoice_bus::GateVerdict::Pass ); - assert!(client - .is_hallucinated("okay", tinyvoice_bus::Mode::Dictation) - .await?); + 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]); @@ -282,7 +293,10 @@ async fn stateless_wrappers_preserve_every_wire_member_and_positional_argument() assert_eq!( recorded(&state), [ - (tinyvoice_bus::names::methods::ROUTE.to_owned(), json!(["pause"])), + ( + tinyvoice_bus::names::methods::ROUTE.to_owned(), + json!(["pause"]) + ), ( tinyvoice_bus::names::methods::SEQUENCE_UTTERANCE.to_owned(), json!(["hey tiny open slack", "Hey Tiny"]) @@ -335,9 +349,7 @@ async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly let recorder = RecorderSession::open(&client).await?; assert_eq!( - recorder - .advance(tinyvoice_bus::SessionInput::Run) - .await?, + recorder.advance(tinyvoice_bus::SessionInput::Run).await?, tinyvoice_bus::SessionAdvance { accepted: true, phase: tinyvoice_bus::SessionPhase::Idle, @@ -367,7 +379,9 @@ async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly [ ( 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}"#]) + 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(), @@ -377,9 +391,18 @@ async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly 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::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"}"#]) @@ -392,8 +415,16 @@ async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly 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!([])), + ( + 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!( @@ -420,7 +451,10 @@ 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 capture = client + .reserve_capture(permission) + .await? + .expect("capture reservation"); let stream = client .start_capture(capture.clone(), permission) .await? @@ -429,7 +463,14 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors handle: stream.handle.clone(), max_chunks: 2, }; - assert!(client.poll_capture(poll.clone()).await?.expect("poll").chunks.is_empty()); + assert!( + client + .poll_capture(poll.clone()) + .await? + .expect("poll") + .chunks + .is_empty() + ); client.stop_capture(stream.handle.clone()).await?; client.shutdown_capture().await?; @@ -442,12 +483,22 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors .reserve_hotkey(request.clone()) .await? .expect("hotkey reservation"); - let started = client.start_hotkey(handle.clone()).await?.expect("hotkey start"); + let started = client + .start_hotkey(handle.clone()) + .await? + .expect("hotkey start"); let read = tinyvoice_bus::HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None, }; - assert!(client.read_hotkey(read.clone()).await?.expect("hotkey read").events.is_empty()); + assert!( + client + .read_hotkey(read.clone()) + .await? + .expect("hotkey read") + .events + .is_empty() + ); let feed = tinyvoice_bus::HotkeyFeedRequest { handle: handle.clone(), generation: started.generation.expect("started generation"), @@ -457,8 +508,18 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors }], 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"); + 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_reply: Value = json!({"Err": "permission_denied"}); @@ -466,7 +527,10 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .replies - .push((tinyvoice_bus::names::methods::HOTKEY_RESERVE.to_owned(), denied_reply)); + .push(( + tinyvoice_bus::names::methods::HOTKEY_RESERVE.to_owned(), + denied_reply, + )); assert_eq!( client.reserve_hotkey(request.clone()).await?, Err(tinyvoice_bus::HotkeyError::PermissionDenied) @@ -492,7 +556,10 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors tinyvoice_bus::names::methods::CAPTURE_STOP.to_owned(), json!([{"handle":"capture-1"}]) ), - (tinyvoice_bus::names::methods::CAPTURE_SHUTDOWN.to_owned(), json!([])), + ( + 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"}}]) @@ -513,7 +580,10 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors 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_SHUTDOWN.to_owned(), + json!([]) + ), ( tinyvoice_bus::names::methods::HOTKEY_RESERVE.to_owned(), json!([{"request":{"key":"space","mode":"push","source":"host"}}]) @@ -535,7 +605,10 @@ async fn unexpected_intents_and_module_errors_remain_closed_and_redacted() tinyvoice_bus::names::methods::ROUTE.to_owned(), json!(r#"{"intent":"newer_unrecognized_intent"}"#), )); - assert_eq!(client.route("unrecognized command").await?, tinyvoice_bus::VoiceIntent::Unknown); + assert_eq!( + client.route("unrecognized command").await?, + tinyvoice_bus::VoiceIntent::Unknown + ); state .lock() @@ -567,8 +640,14 @@ async fn unexpected_intents_and_module_errors_remain_closed_and_redacted() .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); assert_eq!(reports.len(), 3); - 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[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()) From 05504efaf33b09738a773ed3598ccf3902a8338f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 02:52:14 +0530 Subject: [PATCH 08/67] test(tinyvoice-client): cover reserve_capture permission denial Extend the wrapper test so reserve_capture is exercised alongside reserve_hotkey, asserting that a denied reply surfaces as CaptureError::PermissionDenied and that the recorded call carries the expected permission payload. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_tests.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index 2c81ec7..dce5d5b 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -527,14 +527,24 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .replies - .push(( - tinyvoice_bus::names::methods::HOTKEY_RESERVE.to_owned(), - denied_reply, - )); + .extend([ + ( + tinyvoice_bus::names::methods::HOTKEY_RESERVE.to_owned(), + denied_reply.clone(), + ), + ( + tinyvoice_bus::names::methods::RESERVE_CAPTURE.to_owned(), + denied_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!( @@ -588,6 +598,10 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors 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(()) From bca7a4fb5397d39c8c5afb15eeef1ed49f2f3ec8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 02:52:31 +0530 Subject: [PATCH 09/67] test(tinyvoice-client): access recorder id field directly in vad test Updated the VAD and recorder routing test to read the recorder's id field instead of calling an accessor, matching the current handle API. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index dce5d5b..955a288 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -364,7 +364,7 @@ async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly generation: 0, } ); - let recorder_id = recorder.id(); + let recorder_id = recorder.id; recorder.close().await?; drop(recorder); From 4ab3f2fa31b75abd4e9022f8e3e7e48d82c65006 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:31:49 +0530 Subject: [PATCH 10/67] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to the latest upstream commit. Auto-committed-on: macbook --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index eb5cf72..b6cc32b 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit eb5cf727071a6eb12f9efa3db8ec59e54039f336 +Subproject commit b6cc32b309065d9f27cabe9c50eba137c423e2f6 From 94f45c35245ea4a3df0ecc0067f04415f8efba37 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:33:05 +0530 Subject: [PATCH 11/67] chore(deps): bump tinybus to 0.2.2 Update the tinybus and tinybus-module version constraints to the 0.2.2 release across the client and module crates, and refresh the lockfile accordingly. The client README now reflects the published 0.2.2 API instead of describing the range as a draft against 0.2.1. Auto-committed-on: macbook --- Cargo.lock | 149 +++++++++++++++++++++++++++++ crates/tinyvoice-client/Cargo.toml | 5 +- crates/tinyvoice-client/README.md | 6 +- crates/tinyvoice-module/Cargo.toml | 4 +- 4 files changed, 156 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f41156b..bbc688f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,17 @@ 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" @@ -48,6 +59,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 +220,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 +310,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 +801,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 +902,29 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tinybus" +version = "0.2.2" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.21", + "tinybus-macros", + "tokio", + "tracing", +] + +[[package]] +name = "tinybus-macros" +version = "0.2.2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tinyvoice" version = "0.3.2" @@ -856,12 +949,25 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "tinyvoice-client" +version = "0.1.0" +dependencies = [ + "async-trait", + "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 +1013,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/crates/tinyvoice-client/Cargo.toml b/crates/tinyvoice-client/Cargo.toml index 00347ed..c303f7a 100644 --- a/crates/tinyvoice-client/Cargo.toml +++ b/crates/tinyvoice-client/Cargo.toml @@ -10,14 +10,13 @@ repository = "https://github.com/tinyhumansai/tinyvoice" [dependencies] serde = { version = "1", features = ["derive"] } tinyvoice-bus = { version = "0.1.4", path = "../tinyvoice-bus" } -# Draft constraint: align with the final TinyBus 0.3.x runtime before publishing. -tinybus = { version = ">=0.2.1, <0.3", path = "../../vendor/tinybus/crates/tinybus", default-features = false } +tinybus = { version = ">=0.2.2, <0.3", path = "../../vendor/tinybus/crates/tinybus", default-features = false } [dev-dependencies] async-trait = "0.1" serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } -tinybus = { version = ">=0.2.1, <0.3", path = "../../vendor/tinybus/crates/tinybus", default-features = false, features = ["macros"] } +tinybus = { version = ">=0.2.2, <0.3", path = "../../vendor/tinybus/crates/tinybus", default-features = false, features = ["macros"] } [lints] workspace = true diff --git a/crates/tinyvoice-client/README.md b/crates/tinyvoice-client/README.md index 558e791..69a5d50 100644 --- a/crates/tinyvoice-client/README.md +++ b/crates/tinyvoice-client/README.md @@ -22,6 +22,6 @@ actual service invocations and verify the wire arguments and returned types; they do not load a published artifact, access a microphone, or contact a backend. -The manifest's TinyBus version range is a draft against the published 0.2.1 -client API. Pin the final constraint to the TinyBus 0.3.x runtime when the -runtime SDK and vendored gitlink are aligned. +The manifest's TinyBus version range targets the published 0.2.2 client API. +Pin the final constraint to the TinyBus 0.3.x runtime when the runtime SDK and +vendored gitlink are aligned. diff --git a/crates/tinyvoice-module/Cargo.toml b/crates/tinyvoice-module/Cargo.toml index 86a3e1d..fd5e936 100644 --- a/crates/tinyvoice-module/Cargo.toml +++ b/crates/tinyvoice-module/Cargo.toml @@ -40,9 +40,9 @@ tinyvoice-bus = { version = "0.1.4", path = "../tinyvoice-bus" } # 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 = { version = ">=0.2.2, <0.3", 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.2.2, <0.3" # 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"] } From 4fe47f1b0fd7e200e336ee04bab980bddd35e945 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:33:16 +0530 Subject: [PATCH 12/67] build(deps): add base64 dependency to tinyvoice-client Adds the base64 crate to the client so it can encode and decode binary payloads carried over the bus. Auto-committed-on: macbook --- Cargo.lock | 7 +++++++ crates/tinyvoice-client/Cargo.toml | 1 + 2 files changed, 8 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index bbc688f..5f58743 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,12 @@ 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" @@ -954,6 +960,7 @@ name = "tinyvoice-client" version = "0.1.0" dependencies = [ "async-trait", + "base64", "serde", "serde_json", "tinybus", diff --git a/crates/tinyvoice-client/Cargo.toml b/crates/tinyvoice-client/Cargo.toml index c303f7a..b0e287b 100644 --- a/crates/tinyvoice-client/Cargo.toml +++ b/crates/tinyvoice-client/Cargo.toml @@ -8,6 +8,7 @@ description = "Typed TinyBus host client for the TinyVoice module." repository = "https://github.com/tinyhumansai/tinyvoice" [dependencies] +base64 = "0.22" serde = { version = "1", features = ["derive"] } tinyvoice-bus = { version = "0.1.4", path = "../tinyvoice-bus" } tinybus = { version = ">=0.2.2, <0.3", path = "../../vendor/tinybus/crates/tinybus", default-features = false } From 8534e15cc8e8f97979f3aae1b8ab0d475299982f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:34:27 +0530 Subject: [PATCH 13/67] feat(tinyvoice-client): add typed host calls for the TinyVoice module The client now exposes typed wrappers over the TinyVoice TinyBus module for routing, gating, audio preparation, VAD, capture, hotkey, and recording sessions, with a VoiceCallError that keeps untrusted remote prose out of Debug, Display, and terminal reports unless a caller explicitly asks for it. serde_json moved from dev-dependencies to dependencies to support the wire conversions. Auto-committed-on: macbook --- crates/tinyvoice-client/Cargo.toml | 2 +- crates/tinyvoice-client/src/lib.rs | 725 ++++++++++++++++++++++- crates/tinyvoice-client/src/lib_tests.rs | 18 +- 3 files changed, 735 insertions(+), 10 deletions(-) diff --git a/crates/tinyvoice-client/Cargo.toml b/crates/tinyvoice-client/Cargo.toml index b0e287b..80d12bf 100644 --- a/crates/tinyvoice-client/Cargo.toml +++ b/crates/tinyvoice-client/Cargo.toml @@ -10,12 +10,12 @@ repository = "https://github.com/tinyhumansai/tinyvoice" [dependencies] base64 = "0.22" serde = { version = "1", features = ["derive"] } +serde_json = "1" tinyvoice-bus = { version = "0.1.4", path = "../tinyvoice-bus" } tinybus = { version = ">=0.2.2, <0.3", path = "../../vendor/tinybus/crates/tinybus", default-features = false } [dev-dependencies] async-trait = "0.1" -serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } tinybus = { version = ">=0.2.2, <0.3", path = "../../vendor/tinybus/crates/tinybus", default-features = false, features = ["macros"] } diff --git a/crates/tinyvoice-client/src/lib.rs b/crates/tinyvoice-client/src/lib.rs index 314384a..8474ad9 100644 --- a/crates/tinyvoice-client/src/lib.rs +++ b/crates/tinyvoice-client/src/lib.rs @@ -1,8 +1,727 @@ //! Typed host calls to the TinyVoice TinyBus module. //! -//! The client owns the method names and wire conversions. Hosts provide one -//! configured [`tinybus::module::ModuleClient`]; module loading, reporting, -//! deadlines, and transport remain TinyBus responsibilities. +//! The client owns the method names, positional payloads, and conversions at +//! the module boundary. Hosts provide one configured +//! [`ModuleClient`](tinybus::module::ModuleClient); loading, deadlines, and +//! terminal reporting remain TinyBus responsibilities. + +use std::fmt; + +use serde::{Serialize, de::DeserializeOwned}; +use tinybus::module::{ModuleCallError, ModuleCallFailure, ModuleClient}; + +pub use tinyvoice_bus as contract; +pub use tinyvoice_bus::{ + GateVerdict, IndexedVadEvent, RecordingGates, SessionAction, SessionAdvance, SessionInput, + SessionPhase, SessionStatus, UtteranceDisposition, VadConfig, VadEvent, VoiceIntent, +}; +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; + +/// 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, +} + +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 } + } + + /// 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 config = encode_json(&config, "VAD config")?; + let id = self + .call(tinyvoice_bus::names::methods::VAD_OPEN, (config,)) + .await?; + Ok(VadSession { + client: self.clone(), + 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 + } + + /// 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 + } + + /// Stop capture and wait for native resource cleanup. + /// + /// # 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 + } + + /// Shut down 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 + } + + /// 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, + { + self.module + .call_detailed(MODULE_ID, method, args) + .await + .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 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 id = client.call(tinyvoice_bus::names::methods::SESSION_OPEN, ()).await?; + Ok(Self { + client: client.clone(), + 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 + } +} + +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"] diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index 955a288..7ad1fdd 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -340,6 +340,7 @@ async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly 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!(vad.push(20, &[0.2, 0.0]).await?.is_empty()); assert!(vad.is_speaking().await?); vad.reset().await?; @@ -348,6 +349,7 @@ async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly drop(vad); let recorder = RecorderSession::open(&client).await?; + assert!(!format!("{recorder:?}").contains("73")); assert_eq!( recorder.advance(tinyvoice_bus::SessionInput::Run).await?, tinyvoice_bus::SessionAdvance { @@ -629,16 +631,20 @@ async fn unexpected_intents_and_module_errors_remain_closed_and_redacted() .unwrap_or_else(std::sync::PoisonError::into_inner) .failure = Some(FailureMode::Unavailable); let unavailable = client.route("secret transcript").await.unwrap_err(); - assert_eq!(unavailable, ModuleCallError::Unavailable); - assert_eq!(format!("{unavailable:?}"), "Unavailable"); + 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, ModuleCallError::ModuleFault); - assert_eq!(format!("{failure:?}"), "ModuleFault"); + 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 @@ -646,8 +652,8 @@ async fn unexpected_intents_and_module_errors_remain_closed_and_redacted() .unwrap_or_else(std::sync::PoisonError::into_inner) .failure = Some(FailureMode::Incompatible); let incompatible = client.route("secret transcript").await.unwrap_err(); - assert_eq!(incompatible, ModuleCallError::IncompatibleContract); - assert_eq!(format!("{incompatible:?}"), "IncompatibleContract"); + assert_eq!(incompatible.kind(), ModuleCallError::IncompatibleContract); + assert!(!format!("{incompatible:?}").contains("sensitive transcript marker")); let reports = failures .0 From db414c77811a32562fbec14a73559a29f399a300 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:34:56 +0530 Subject: [PATCH 14/67] test(tinyvoice-client): align mock wire values with f32 encoding Update the mock interface responses so reserve capture returns a bare handle string and the gate recording and encode wav expectations use the exact f32-derived values and base64 payload the client now produces. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_tests.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index 7ad1fdd..4c42550 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -156,9 +156,7 @@ impl Interface for MockVoice { tinyvoice_bus::names::methods::SESSION_STATUS => { json!(r#"{"phase":"idle","generation":0}"#) } - tinyvoice_bus::names::methods::RESERVE_CAPTURE => json!({ - "Ok": {"handle": "capture-1"} - }), + tinyvoice_bus::names::methods::RESERVE_CAPTURE => json!({"Ok": "capture-1"}), tinyvoice_bus::names::methods::CAPTURE_START => json!({ "Ok": { "handle": "capture-1", @@ -303,7 +301,7 @@ async fn stateless_wrappers_preserve_every_wire_member_and_positional_argument() ), ( tinyvoice_bus::names::methods::GATE_RECORDING.to_owned(), - json!([1.0, 0.5, 0.3, 0.01]) + json!([1.0, 0.5, 0.30000001192092896_f64, 0.009999999776482582_f64]) ), ( tinyvoice_bus::names::methods::IS_HALLUCINATED.to_owned(), @@ -327,7 +325,7 @@ async fn stateless_wrappers_preserve_every_wire_member_and_positional_argument() ), ( tinyvoice_bus::names::methods::ENCODE_WAV_PCM16.to_owned(), - json!(["/////wIAAA==", 16_000, 1]) + json!(["//8CAA==", 16_000, 1]) ), ] ); From aadd41ab47d4ee41b09dca4b54371c7ec1128430 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:35:30 +0530 Subject: [PATCH 15/67] test(tinyvoice-client): add context to session handle assertions Wrap VAD and recorder session calls in a helper that annotates errors with the failing operation, and give the hotkey and capture denial replies distinct shapes so each wrapper is exercised against its own error format. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_tests.rs | 28 +++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index 4c42550..64654f9 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -251,6 +251,10 @@ fn recorded(state: &State) -> Vec<(String, Value)> { .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> { @@ -339,17 +343,20 @@ async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly let vad_config = tinyvoice_bus::VadConfig::default(); let vad = VadSession::open(&client, vad_config).await?; assert!(!format!("{vad:?}").contains("41")); - assert!(vad.push(20, &[0.2, 0.0]).await?.is_empty()); - assert!(vad.is_speaking().await?); - vad.reset().await?; + assert!(context("VAD push", vad.push(20, &[0.2, 0.0]).await)?.is_empty()); + assert!(context("VAD speaking", vad.is_speaking().await)?); + context("VAD reset", vad.reset().await)?; let vad_id = vad.id; - vad.close().await?; + context("VAD close", vad.close().await)?; drop(vad); let recorder = RecorderSession::open(&client).await?; assert!(!format!("{recorder:?}").contains("73")); assert_eq!( - recorder.advance(tinyvoice_bus::SessionInput::Run).await?, + context( + "recorder advance", + recorder.advance(tinyvoice_bus::SessionInput::Run).await, + )?, tinyvoice_bus::SessionAdvance { accepted: true, phase: tinyvoice_bus::SessionPhase::Idle, @@ -358,14 +365,14 @@ async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly } ); assert_eq!( - recorder.status().await?, + context("recorder status", recorder.status().await)?, tinyvoice_bus::SessionStatus { phase: tinyvoice_bus::SessionPhase::Idle, generation: 0, } ); let recorder_id = recorder.id; - recorder.close().await?; + context("recorder close", recorder.close().await)?; drop(recorder); let abandoned_vad = VadSession::open(&client, vad_config).await?; @@ -522,7 +529,8 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors .expect("hotkey stop"); client.shutdown_hotkeys().await?.expect("hotkey shutdown"); - let denied_reply: Value = json!({"Err": "permission_denied"}); + 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) @@ -530,11 +538,11 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors .extend([ ( tinyvoice_bus::names::methods::HOTKEY_RESERVE.to_owned(), - denied_reply.clone(), + denied_hotkey_reply, ), ( tinyvoice_bus::names::methods::RESERVE_CAPTURE.to_owned(), - denied_reply, + denied_capture_reply, ), ]); assert_eq!( From 426829607aea6bf791b228923de2727d7e0c1a77 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:35:48 +0530 Subject: [PATCH 16/67] test(tinyvoice-client): update mock responses to match typed wire formats Adjust the mock voice interface so its canned session advance and capture stop responses use the current typed payload shapes, keeping the wrapper tests aligned with the real protocol. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index 64654f9..71fe2ec 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -151,7 +151,7 @@ impl Interface for MockVoice { tinyvoice_bus::names::methods::VAD_IS_SPEAKING => json!(true), tinyvoice_bus::names::methods::SESSION_OPEN => json!(73), tinyvoice_bus::names::methods::SESSION_ADVANCE => { - json!(r#"{"accepted":true,"phase":"idle","generation":0,"action":"none"}"#) + json!(r#"{"accepted":true,"phase":"idle","generation":0,"action":{"action":"none"}}"#) } tinyvoice_bus::names::methods::SESSION_STATUS => { json!(r#"{"phase":"idle","generation":0}"#) @@ -572,7 +572,7 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors ), ( tinyvoice_bus::names::methods::CAPTURE_STOP.to_owned(), - json!([{"handle":"capture-1"}]) + json!(["capture-1"]) ), ( tinyvoice_bus::names::methods::CAPTURE_SHUTDOWN.to_owned(), From a79da19cb6d057dfa0913a731bd543f855b04161 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:35:59 +0530 Subject: [PATCH 17/67] test(tinyvoice-client): assert capture stop and shutdown succeed The capture wrapper test now unwraps the results of stop_capture and shutdown_capture with expect so a failure surfaces as a clear assertion instead of being silently propagated. An unused Calls type alias was also dropped. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_tests.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index 71fe2ec..c14c8e0 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -16,7 +16,6 @@ use tinybus::{Connection, InterfaceName, MemberName, ObjectPath, Proxy}; use super::*; const MODULE_ID: &str = "tinyvoice"; -type Calls = Arc>>; static RELEASE: ReleaseRecord = ReleaseRecord { id: MODULE_ID, @@ -478,8 +477,14 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors .chunks .is_empty() ); - client.stop_capture(stream.handle.clone()).await?; - client.shutdown_capture().await?; + 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(), From e542e5bb2e8efbe04820afc01b651da1c9d31796 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:36:09 +0530 Subject: [PATCH 18/67] test(tinyvoice-client): use f32 literals in wire argument expectations Replace the hand-expanded f64 constants in the stateless wrapper and VAD push expectations with f32 literals so the test values match the wire types directly and stay readable. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index c14c8e0..01eb78f 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -304,7 +304,7 @@ async fn stateless_wrappers_preserve_every_wire_member_and_positional_argument() ), ( tinyvoice_bus::names::methods::GATE_RECORDING.to_owned(), - json!([1.0, 0.5, 0.30000001192092896_f64, 0.009999999776482582_f64]) + json!([1.0_f32, 0.5_f32, 0.3_f32, 0.01_f32]) ), ( tinyvoice_bus::names::methods::IS_HALLUCINATED.to_owned(), @@ -391,7 +391,7 @@ async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly ), ( tinyvoice_bus::names::methods::VAD_PUSH.to_owned(), - json!([vad_id, 20, [0.2, 0.0]]) + json!([vad_id, 20, [0.2_f32, 0.0_f32]]) ), ( tinyvoice_bus::names::methods::VAD_IS_SPEAKING.to_owned(), From a72d573f0476b0d1c88e833fcc46370a35f9a112 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:36:41 +0530 Subject: [PATCH 19/67] chore(tinyvoice-client): drop unused tinybus macros dev-dependency The client's dev-dependencies no longer enable TinyBus's macros feature, so the tinybus-macros package drops out of the lockfile. The README now documents that the client preserves the configured ModuleClient deadline and reporting sink, and that raw remote values from the error accessors require a caller-owned redaction policy. Auto-committed-on: macbook --- Cargo.lock | 10 ---------- crates/tinyvoice-client/Cargo.toml | 2 +- crates/tinyvoice-client/README.md | 6 ++++++ 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5f58743..cf4377a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -917,20 +917,10 @@ dependencies = [ "serde_json", "sha2", "thiserror 2.0.21", - "tinybus-macros", "tokio", "tracing", ] -[[package]] -name = "tinybus-macros" -version = "0.2.2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tinyvoice" version = "0.3.2" diff --git a/crates/tinyvoice-client/Cargo.toml b/crates/tinyvoice-client/Cargo.toml index 80d12bf..c7d0696 100644 --- a/crates/tinyvoice-client/Cargo.toml +++ b/crates/tinyvoice-client/Cargo.toml @@ -17,7 +17,7 @@ tinybus = { version = ">=0.2.2, <0.3", path = "../../vendor/tinybus/crates/tinyb [dev-dependencies] async-trait = "0.1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } -tinybus = { version = ">=0.2.2, <0.3", path = "../../vendor/tinybus/crates/tinybus", default-features = false, features = ["macros"] } +tinybus = { version = ">=0.2.2, <0.3", path = "../../vendor/tinybus/crates/tinybus", default-features = false } [lints] workspace = true diff --git a/crates/tinyvoice-client/README.md b/crates/tinyvoice-client/README.md index 69a5d50..28e8df7 100644 --- a/crates/tinyvoice-client/README.md +++ b/crates/tinyvoice-client/README.md @@ -17,6 +17,12 @@ the corresponding module method; dropping a Rust handle does not imply remote cleanup. OpenHuman keeps host policy such as volume clamping and conversion from persisted server configuration into `VadConfig` in its own adapter. +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 the wire arguments and returned types; they do not load a published artifact, access a microphone, or contact a From 7d26d7de63f4efd2e78273fd2c349f42cbec21ab Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:36:57 +0530 Subject: [PATCH 20/67] refactor(tinyvoice-client): reorder re-exports and apply rustfmt Moved the tinyvoice_bus re-export block below the submodule re-exports and reformatted several call chains and closures to satisfy rustfmt. No behaviour changed. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib.rs | 31 +++++++++++++----------- crates/tinyvoice-client/src/lib_tests.rs | 9 +++---- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/crates/tinyvoice-client/src/lib.rs b/crates/tinyvoice-client/src/lib.rs index 8474ad9..98be6df 100644 --- a/crates/tinyvoice-client/src/lib.rs +++ b/crates/tinyvoice-client/src/lib.rs @@ -11,10 +11,6 @@ use serde::{Serialize, de::DeserializeOwned}; use tinybus::module::{ModuleCallError, ModuleCallFailure, ModuleClient}; pub use tinyvoice_bus as contract; -pub use tinyvoice_bus::{ - GateVerdict, IndexedVadEvent, RecordingGates, SessionAction, SessionAdvance, SessionInput, - SessionPhase, SessionStatus, UtteranceDisposition, VadConfig, VadEvent, VoiceIntent, -}; pub use tinyvoice_bus::capture::{ CaptureBatch, CaptureError, CaptureFormat, CaptureHandle, CapturePollRequest, CaptureResult, CaptureStream, MicrophonePermission, RawChunk, RecordingFinishRequest, RecordingStartRequest, @@ -25,6 +21,10 @@ pub use tinyvoice_bus::hotkey::{ 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"; @@ -294,7 +294,10 @@ impl TinyVoiceClient { channels: u16, ) -> Result> { use base64::Engine as _; - let bytes: Vec = samples.iter().flat_map(|sample| sample.to_le_bytes()).collect(); + 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( @@ -382,10 +385,7 @@ impl TinyVoiceClient { /// /// Returns a sanitized TinyBus failure; a capture refusal remains the /// typed inner [`CaptureError`]. - pub async fn stop_capture( - &self, - handle: CaptureHandle, - ) -> Result> { + pub async fn stop_capture(&self, handle: CaptureHandle) -> Result> { self.call(tinyvoice_bus::names::methods::CAPTURE_STOP, (handle,)) .await } @@ -508,16 +508,17 @@ impl TinyVoiceClient { 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}")) - }) + 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}"))) + .map_err(|error| { + self.malformed_reply(format!("module returned invalid audio: {error}")) + }) } fn decode_samples(&self, encoded: &str) -> Result> { @@ -647,7 +648,9 @@ impl RecorderSession { /// /// Returns a sanitized TinyBus failure. pub async fn open(client: &TinyVoiceClient) -> Result { - let id = client.call(tinyvoice_bus::names::methods::SESSION_OPEN, ()).await?; + let id = client + .call(tinyvoice_bus::names::methods::SESSION_OPEN, ()) + .await?; Ok(Self { client: client.clone(), id, diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index 01eb78f..6e60ed0 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -150,7 +150,9 @@ impl Interface for MockVoice { tinyvoice_bus::names::methods::VAD_IS_SPEAKING => json!(true), 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"}}"#) + json!( + r#"{"accepted":true,"phase":"idle","generation":0,"action":{"action":"none"}}"# + ) } tinyvoice_bus::names::methods::SESSION_STATUS => { json!(r#"{"phase":"idle","generation":0}"#) @@ -481,10 +483,7 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors .stop_capture(stream.handle.clone()) .await? .expect("capture stop"); - client - .shutdown_capture() - .await? - .expect("capture shutdown"); + client.shutdown_capture().await?.expect("capture shutdown"); let request = tinyvoice_bus::HotkeyRequest { key: "space".to_owned(), From 5e87669c0eb3cec85e7a8096d2d730302097eb7d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:37:12 +0530 Subject: [PATCH 21/67] docs(tinyvoice-client): wrap TinyVoice and TinyBus in doc backticks Rustdoc comments now render the product and bus names as code spans, matching the crate's existing documentation style. No behaviour changed. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib.rs | 74 ++++++++++++------------ crates/tinyvoice-client/src/lib_tests.rs | 2 +- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/crates/tinyvoice-client/src/lib.rs b/crates/tinyvoice-client/src/lib.rs index 98be6df..e283f48 100644 --- a/crates/tinyvoice-client/src/lib.rs +++ b/crates/tinyvoice-client/src/lib.rs @@ -1,9 +1,9 @@ -//! Typed host calls to the TinyVoice TinyBus module. +//! 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`](tinybus::module::ModuleClient); loading, deadlines, and -//! terminal reporting remain TinyBus responsibilities. +//! terminal reporting remain `TinyBus` responsibilities. use std::fmt; @@ -29,13 +29,13 @@ pub use tinyvoice_bus::{ /// Registry id of the loadable module these calls use. pub const MODULE_ID: &str = "tinyvoice"; -/// Result of a typed TinyVoice operation. +/// Result of a typed `TinyVoice` operation. pub type Result = std::result::Result; -/// A terminal TinyBus failure with its untrusted remote prose kept private by +/// A terminal `TinyBus` failure with its untrusted remote prose kept private by /// default. /// -/// `Debug`, `Display`, and TinyBus terminal reports contain only the closed +/// `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. @@ -107,7 +107,7 @@ impl fmt::Display for VoiceCallError { impl std::error::Error for VoiceCallError {} -/// Typed TinyVoice calls over one host-configured TinyBus module client. +/// Typed `TinyVoice` calls over one host-configured `TinyBus` module client. #[derive(Clone)] pub struct TinyVoiceClient { module: ModuleClient, @@ -123,7 +123,7 @@ impl fmt::Debug for TinyVoiceClient { } impl TinyVoiceClient { - /// Wrap the host's configured TinyBus client. + /// 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, @@ -140,7 +140,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns the sanitized TinyBus failure category. Its raw remote details + /// 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( @@ -155,7 +155,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure or a sanitized malformed-reply + /// Returns a sanitized `TinyBus` failure or a sanitized malformed-reply /// error. pub async fn sequence_utterance( &self, @@ -174,7 +174,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure or a sanitized malformed-reply + /// Returns a sanitized `TinyBus` failure or a sanitized malformed-reply /// error. pub async fn gate_recording( &self, @@ -199,7 +199,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure. + /// Returns a sanitized `TinyBus` failure. pub async fn is_hallucinated(&self, text: &str, mode: HallucinationMode) -> Result { self.call( tinyvoice_bus::names::methods::IS_HALLUCINATED, @@ -212,7 +212,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure or malformed audio-reply error. + /// Returns a sanitized `TinyBus` failure or malformed audio-reply error. pub async fn prepare_capture( &self, samples: &[f32], @@ -238,7 +238,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure or malformed audio-reply error. + /// 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( @@ -253,7 +253,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure or malformed sample-reply error. + /// Returns a sanitized `TinyBus` failure or malformed sample-reply error. pub async fn prepare_frames( &self, samples: &[f32], @@ -273,7 +273,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure. + /// 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, @@ -286,7 +286,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure or malformed audio-reply error. + /// Returns a sanitized `TinyBus` failure or malformed audio-reply error. pub async fn encode_wav_pcm16( &self, samples: &[i16], @@ -312,7 +312,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure. + /// Returns a sanitized `TinyBus` failure. pub async fn open_vad(&self, config: VadConfig) -> Result { let config = encode_json(&config, "VAD config")?; let id = self @@ -328,7 +328,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure; a capture refusal remains the + /// Returns a sanitized `TinyBus` failure; a capture refusal remains the /// typed inner [`CaptureError`]. pub async fn reserve_capture( &self, @@ -348,7 +348,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure; a capture refusal remains the + /// Returns a sanitized `TinyBus` failure; a capture refusal remains the /// typed inner [`CaptureError`]. pub async fn start_capture( &self, @@ -369,7 +369,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure; a capture refusal remains the + /// Returns a sanitized `TinyBus` failure; a capture refusal remains the /// typed inner [`CaptureError`]. pub async fn poll_capture( &self, @@ -383,7 +383,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure; a capture refusal remains the + /// 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,)) @@ -394,7 +394,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure; a capture refusal remains the + /// 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, ()) @@ -405,7 +405,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure; a listener refusal remains the + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the /// typed inner [`HotkeyError`]. pub async fn reserve_hotkey( &self, @@ -422,7 +422,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure; a listener refusal remains the + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the /// typed inner [`HotkeyError`]. pub async fn start_hotkey( &self, @@ -439,7 +439,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure; a listener refusal remains the + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the /// typed inner [`HotkeyError`]. pub async fn read_hotkey( &self, @@ -453,7 +453,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure; a listener refusal remains the + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the /// typed inner [`HotkeyError`]. pub async fn feed_hotkey( &self, @@ -467,7 +467,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure; a listener refusal remains the + /// Returns a sanitized `TinyBus` failure; a listener refusal remains the /// typed inner [`HotkeyError`]. pub async fn stop_hotkey( &self, @@ -484,7 +484,7 @@ impl TinyVoiceClient { /// /// # Errors /// - /// Returns a sanitized TinyBus failure; a listener refusal remains the + /// 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, ()) @@ -567,7 +567,7 @@ impl VadSession { /// /// # Errors /// - /// Returns a sanitized TinyBus failure. + /// Returns a sanitized `TinyBus` failure. pub async fn open(client: &TinyVoiceClient, config: VadConfig) -> Result { client.open_vad(config).await } @@ -576,7 +576,7 @@ impl VadSession { /// /// # Errors /// - /// Returns a sanitized TinyBus failure or malformed-reply error. + /// Returns a sanitized `TinyBus` failure or malformed-reply error. pub async fn push(&self, frame_ms: u32, energies: &[f32]) -> Result> { self.client .call_json( @@ -591,7 +591,7 @@ impl VadSession { /// /// # Errors /// - /// Returns a sanitized TinyBus failure. + /// Returns a sanitized `TinyBus` failure. pub async fn is_speaking(&self) -> Result { self.client .call(tinyvoice_bus::names::methods::VAD_IS_SPEAKING, (self.id,)) @@ -602,7 +602,7 @@ impl VadSession { /// /// # Errors /// - /// Returns a sanitized TinyBus failure. + /// Returns a sanitized `TinyBus` failure. pub async fn reset(&self) -> Result<()> { self.client .call(tinyvoice_bus::names::methods::VAD_RESET, (self.id,)) @@ -613,7 +613,7 @@ impl VadSession { /// /// # Errors /// - /// Returns a sanitized TinyBus failure. + /// Returns a sanitized `TinyBus` failure. pub async fn close(&self) -> Result<()> { self.client .call(tinyvoice_bus::names::methods::VAD_CLOSE, (self.id,)) @@ -646,7 +646,7 @@ impl RecorderSession { /// /// # Errors /// - /// Returns a sanitized TinyBus failure. + /// Returns a sanitized `TinyBus` failure. pub async fn open(client: &TinyVoiceClient) -> Result { let id = client .call(tinyvoice_bus::names::methods::SESSION_OPEN, ()) @@ -661,7 +661,7 @@ impl RecorderSession { /// /// # Errors /// - /// Returns a sanitized TinyBus failure or malformed-reply error. + /// 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 @@ -677,7 +677,7 @@ impl RecorderSession { /// /// # Errors /// - /// Returns a sanitized TinyBus failure or malformed-reply error. + /// Returns a sanitized `TinyBus` failure or malformed-reply error. pub async fn status(&self) -> Result { self.client .call_json( @@ -692,7 +692,7 @@ impl RecorderSession { /// /// # Errors /// - /// Returns a sanitized TinyBus failure. + /// Returns a sanitized `TinyBus` failure. pub async fn close(&self) -> Result<()> { self.client .call(tinyvoice_bus::names::methods::SESSION_CLOSE, (self.id,)) diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index 6e60ed0..0144cd9 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -1,4 +1,4 @@ -//! Product-facing wire tests over a real in-memory TinyBus broker. +//! Product-facing wire tests over a real in-memory `TinyBus` broker. use std::sync::{Arc, Mutex}; From 8d7e3791a8d99d01e36a61ea4e112bb0b652e1d3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:37:29 +0530 Subject: [PATCH 22/67] test(tinyvoice-client): allow panicking assertions and long tests in wire tests Adds crate-level clippy allows for expect_used and unwrap_used since these behaviour tests intentionally panic on failure, and marks the two long test functions with too_many_lines. The mock interface arms are also consolidated so VAD_IS_SPEAKING and HOTKEY_FEED share their existing replies instead of duplicating them. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_tests.rs | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index 0144cd9..703df47 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -1,5 +1,8 @@ //! 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; @@ -139,7 +142,8 @@ impl Interface for MockVoice { json!(r#"{"disposition":"command","command":"open slack"}"#) } tinyvoice_bus::names::methods::GATE_RECORDING => json!("\"pass\""), - tinyvoice_bus::names::methods::IS_HALLUCINATED => json!(true), + 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=="), @@ -147,7 +151,6 @@ impl Interface for MockVoice { 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::VAD_IS_SPEAKING => json!(true), tinyvoice_bus::names::methods::SESSION_OPEN => json!(73), tinyvoice_bus::names::methods::SESSION_ADVANCE => { json!( @@ -167,15 +170,13 @@ impl Interface for MockVoice { tinyvoice_bus::names::methods::CAPTURE_POLL => { json!({"Ok": {"chunks": [], "closed": false}}) } - tinyvoice_bus::names::methods::VAD_RESET - | tinyvoice_bus::names::methods::VAD_CLOSE - | tinyvoice_bus::names::methods::SESSION_CLOSE => Value::Null, 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 => json!({ + | tinyvoice_bus::names::methods::HOTKEY_SHUTDOWN + | tinyvoice_bus::names::methods::HOTKEY_FEED => json!({ "Ok": { "state": "running", "generation": 1, @@ -192,14 +193,6 @@ impl Interface for MockVoice { "active": false } }), - tinyvoice_bus::names::methods::HOTKEY_FEED => json!({ - "Ok": { - "state": "running", - "generation": 1, - "active": false, - "continuity_lost": false - } - }), _ => Value::Null, }; Ok(reply) @@ -338,6 +331,7 @@ async fn stateless_wrappers_preserve_every_wire_member_and_positional_argument() } #[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?; @@ -455,6 +449,7 @@ async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly } #[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?; From aca3c4cfa777cef5daa0dd5ba65b43d4c6307dc7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:37:53 +0530 Subject: [PATCH 23/67] test(client): cover malformed route replies and proxy startup failures Extend the redaction test to assert that a malformed route reply surfaces as a module fault without leaking the transcript, and add a case confirming proxy startup failures keep the transport classification. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_tests.rs | 51 +++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index 703df47..d8e2246 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -660,11 +660,32 @@ async fn unexpected_intents_and_module_errors_remain_closed_and_redacted() 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(), 3); + assert_eq!(reports.len(), 4); assert_eq!( reports[0], (MODULE_ID.to_owned(), "module_unavailable".to_owned()) @@ -677,6 +698,34 @@ async fn unexpected_intents_and_module_errors_remain_closed_and_redacted() 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(()) +} From 3ea1bd8e6af6be7a5c20ed56de58430011b0e79c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:38:08 +0530 Subject: [PATCH 24/67] docs(tinyvoice-client): simplify module client doc link Replace the explicit path link to `ModuleClient` with an intra-doc link so the crate documentation resolves the type without hardcoding the `tinybus` module path. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinyvoice-client/src/lib.rs b/crates/tinyvoice-client/src/lib.rs index e283f48..eab2460 100644 --- a/crates/tinyvoice-client/src/lib.rs +++ b/crates/tinyvoice-client/src/lib.rs @@ -2,7 +2,7 @@ //! //! The client owns the method names, positional payloads, and conversions at //! the module boundary. Hosts provide one configured -//! [`ModuleClient`](tinybus::module::ModuleClient); loading, deadlines, and +//! [`ModuleClient`]; loading, deadlines, and //! terminal reporting remain `TinyBus` responsibilities. use std::fmt; From 356a5a1b7e2407902aee8a639bd3def13c0b7d65 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:38:58 +0530 Subject: [PATCH 25/67] chore(deps): update tinybus to 0.2.2 and refresh lockfile Bumps tinybus and tinybus-macros to 0.2.2, which swaps the zip crate for rawzip and adds icu_casemap and unicode-normalization for case folding and text normalization. The lockfile also picks up the resulting transitive dependency changes. Auto-committed-on: macbook --- crates/tinyvoice-module/Cargo.lock | 113 ++++++++++++++--------------- 1 file changed, 53 insertions(+), 60 deletions(-) diff --git a/crates/tinyvoice-module/Cargo.lock b/crates/tinyvoice-module/Cargo.lock index 82aaf2b..ae59fa9 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.2.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.2.2" dependencies = [ "proc-macro2", "quote", @@ -2278,7 +2288,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.1.5" +version = "0.2.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", ] @@ -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", -] From 57590a1bfd8c496fbb72e8b7104feacce6ba8c36 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:40:46 +0530 Subject: [PATCH 26/67] chore(tinyvoice-client): tidy dependency features and doc comments Dependency comments now explain why each crate is needed, the duplicate tinybus dev-dependency is dropped, and the unused tokio time feature is removed. Two doc comments were reworded to say the client requests capture shutdown rather than waiting for cleanup. Auto-committed-on: macbook --- crates/tinyvoice-client/Cargo.toml | 12 +++++++++--- crates/tinyvoice-client/src/lib.rs | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/tinyvoice-client/Cargo.toml b/crates/tinyvoice-client/Cargo.toml index c7d0696..3478e15 100644 --- a/crates/tinyvoice-client/Cargo.toml +++ b/crates/tinyvoice-client/Cargo.toml @@ -8,16 +8,22 @@ 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" -serde = { version = "1", features = ["derive"] } +# 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" } +# One host-configured module client owns calls, deadlines, and reporting. tinybus = { version = ">=0.2.2, <0.3", 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" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } -tinybus = { version = ">=0.2.2, <0.3", path = "../../vendor/tinybus/crates/tinybus", default-features = false } +# Provides the async test executor and `#[tokio::test]` macro. +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } [lints] workspace = true diff --git a/crates/tinyvoice-client/src/lib.rs b/crates/tinyvoice-client/src/lib.rs index eab2460..419f8ba 100644 --- a/crates/tinyvoice-client/src/lib.rs +++ b/crates/tinyvoice-client/src/lib.rs @@ -379,7 +379,7 @@ impl TinyVoiceClient { .await } - /// Stop capture and wait for native resource cleanup. + /// Request that the module stop the capture lease. /// /// # Errors /// @@ -390,7 +390,7 @@ impl TinyVoiceClient { .await } - /// Shut down every module-owned capture lease. + /// Request shutdown of every module-owned capture lease. /// /// # Errors /// From c8032b31059fb6998d0204e800e8cf1f9815c8bd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 14:06:43 +0530 Subject: [PATCH 27/67] test(client): add ownership test module Register a new test module for ownership checks in the tinyvoice client crate so the accompanying test file is compiled and run. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib.rs | 4 + .../src/lib_ownership_tests.rs | 595 ++++++++++++++++++ 2 files changed, 599 insertions(+) create mode 100644 crates/tinyvoice-client/src/lib_ownership_tests.rs diff --git a/crates/tinyvoice-client/src/lib.rs b/crates/tinyvoice-client/src/lib.rs index 419f8ba..fa98177 100644 --- a/crates/tinyvoice-client/src/lib.rs +++ b/crates/tinyvoice-client/src/lib.rs @@ -729,3 +729,7 @@ fn hallucination_mode_wire(mode: HallucinationMode) -> &'static str { #[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..52254af --- /dev/null +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -0,0 +1,595 @@ +//! Alias-handover regression tests for owned TinyVoice session projections. + +use super::*; + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::collections::HashMap; +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, 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; + +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, +} + +type SharedPeerState = Arc>; + +struct OwnershipVoice { + peer: Peer, + state: SharedPeerState, +} + +#[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(); + let mut state = self + .state + .lock() + .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; + state.calls.push((method.to_owned(), args.clone())); + let values = args + .as_array() + .ok_or_else(|| tinybus::Error::failed("expected positional method arguments"))?; + + let result = match method { + tinyvoice_bus::names::methods::ROUTE => 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; + 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}]"# + } + }; + json!(events) + } + tinyvoice_bus::names::methods::VAD_IS_SPEAKING => { + let id = positional_id(values)?; + ensure_vad_open(&state, id)?; + json!(self.peer == Peer::B) + } + tinyvoice_bus::names::methods::VAD_RESET => { + let id = positional_id(values)?; + ensure_vad_open(&state, id)?; + Value::Null + } + tinyvoice_bus::names::methods::VAD_CLOSE => { + let id = positional_id(values)?; + ensure_vad_open(&state, id)?; + state.vad_open = false; + Value::Null + } + tinyvoice_bus::names::methods::SESSION_OPEN => { + state.recorders.insert( + RECORDER_ID, + SessionStatus { + phase: SessionPhase::Stopped, + generation: 0, + }, + ); + 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"))?; + 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"))?; + 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"))?; + Value::Null + } + _ => return Err(tinybus::Error::failed("unexpected ownership fixture method")), + }; + + Ok(result) + } +} + +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 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 { + 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(), + }, + ) + .await?; + service_b + .serve_at( + object_path, + OwnershipVoice { + peer: Peer::B, + state: state_b.clone(), + }, + ) + .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(alias_proxy); + let direct_a = make_client(direct_a_proxy); + Ok(AliasHandoffFixture { + client, + direct_a, + caller, + service_a, + service_b, + state_a, + state_b, + }) +} + +fn make_client(proxy: Proxy) -> TinyVoiceClient { + let failures = Arc::new(IgnoreFailures); + let module = ModuleClient::new(FixtureAccess { + reporter: FailureReporter::new(failures), + }) + .with_fixture(proxy); + TinyVoiceClient::new(module) +} + +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::VAD_PUSH, + tinyvoice_bus::names::methods::VAD_IS_SPEAKING, + tinyvoice_bus::names::methods::VAD_RESET, + tinyvoice_bus::names::methods::VAD_CLOSE, + tinyvoice_bus::names::methods::ROUTE, + ] + .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] +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" + ); +} From 069a097f5e936433ec30538b71b28fbede0be6d5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 14:06:54 +0530 Subject: [PATCH 28/67] test(tinyvoice-client): reformat ownership fixture tests Reformat the alias-handover regression tests to satisfy rustfmt, moving the inner attribute above the imports and reflowing long expressions and match arms. No test behaviour or coverage changed. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 44 ++++++++----------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 52254af..9f434d5 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -1,9 +1,9 @@ //! Alias-handover regression tests for owned TinyVoice session projections. -use super::*; - #![allow(clippy::expect_used, clippy::unwrap_used)] +use super::*; + use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -95,8 +95,7 @@ struct OwnershipVoice { #[async_trait] impl Interface for OwnershipVoice { fn name(&self) -> InterfaceName { - InterfaceName::new(tinyvoice_bus::BUS_NAME) - .unwrap_or_else(|_| std::process::abort()) + InterfaceName::new(tinyvoice_bus::BUS_NAME).unwrap_or_else(|_| std::process::abort()) } fn members(&self) -> Vec { @@ -198,7 +197,11 @@ impl Interface for OwnershipVoice { .ok_or_else(|| tinybus::Error::failed("unknown recorder id"))?; Value::Null } - _ => return Err(tinybus::Error::failed("unexpected ownership fixture method")), + _ => { + return Err(tinybus::Error::failed( + "unexpected ownership fixture method", + )); + } }; Ok(result) @@ -220,10 +223,7 @@ fn ensure_vad_open(state: &PeerState, id: u64) -> tinybus::Result<()> { } } -fn advance_fixture_session( - status: &mut SessionStatus, - input: SessionInput, -) -> SessionAdvance { +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; @@ -239,16 +239,14 @@ fn advance_fixture_session( }, ) } - SessionInput::SetupReady { stop_buffered: false } - if status.phase == SessionPhase::Recording => - { - ( - true, - SessionAction::RecordingLive { - generation: status.generation, - }, - ) - } + SessionInput::SetupReady { + stop_buffered: false, + } if status.phase == SessionPhase::Recording => ( + true, + SessionAction::RecordingLive { + generation: status.generation, + }, + ), _ => (false, SessionAction::None), }; @@ -339,12 +337,8 @@ fn make_client(proxy: Proxy) -> TinyVoiceClient { 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?; + 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( From ff43cbba1714d73af84673c9e4b33d9e0486b62b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 14:42:05 +0530 Subject: [PATCH 29/67] test(tinyvoice-client): reorder expected method list in ownership test The expected method list in the VAD ownership test now places ROUTE before the VAD methods, matching the order the fixture actually reports. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_ownership_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 9f434d5..6bc6f1c 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -440,11 +440,11 @@ async fn vad_session_stays_with_its_creator_across_a_live_public_alias_handover( 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, - tinyvoice_bus::names::methods::ROUTE, ] .map(str::to_owned) ); From 8dedec1c9232a9da9d6ba28db2eb80fb909461c4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:55:29 +0530 Subject: [PATCH 30/67] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to commit cba2de0 to pick up its latest upstream changes. Auto-committed-on: macbook --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index b6cc32b..cba2de0 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit b6cc32b309065d9f27cabe9c50eba137c423e2f6 +Subproject commit cba2de0e573f99fe4c7e45ba14e2d6b5a2a7757f From 7d1e99d8dc3dcc6fe600ac0bc405d1fcdb123d11 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:56:10 +0530 Subject: [PATCH 31/67] feat(client): pin voice sessions to a bound module client Upgrade TinyBus to 0.3.2 and add a pinned client path so stateful voice leases such as VAD sessions stay bound to the module instance that opened them. Calls fall back to the shared module client when no pin is held. Auto-committed-on: macbook --- crates/tinyvoice-client/Cargo.toml | 4 ++-- crates/tinyvoice-client/src/lib.rs | 35 +++++++++++++++++++++++------- crates/tinyvoice-module/Cargo.toml | 6 ++--- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/crates/tinyvoice-client/Cargo.toml b/crates/tinyvoice-client/Cargo.toml index 3478e15..ecf0036 100644 --- a/crates/tinyvoice-client/Cargo.toml +++ b/crates/tinyvoice-client/Cargo.toml @@ -16,8 +16,8 @@ serde = "1" serde_json = "1" # Shared TinyVoice member names, request types, and typed replies. tinyvoice-bus = { version = "0.1.4", path = "../tinyvoice-bus" } -# One host-configured module client owns calls, deadlines, and reporting. -tinybus = { version = ">=0.2.2, <0.3", path = "../../vendor/tinybus/crates/tinybus", default-features = false } +# 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. diff --git a/crates/tinyvoice-client/src/lib.rs b/crates/tinyvoice-client/src/lib.rs index fa98177..af942bd 100644 --- a/crates/tinyvoice-client/src/lib.rs +++ b/crates/tinyvoice-client/src/lib.rs @@ -8,7 +8,7 @@ use std::fmt; use serde::{Serialize, de::DeserializeOwned}; -use tinybus::module::{ModuleCallError, ModuleCallFailure, ModuleClient}; +use tinybus::module::{ModuleCallError, ModuleCallFailure, ModuleClient, PinnedModuleClient}; pub use tinyvoice_bus as contract; pub use tinyvoice_bus::capture::{ @@ -111,6 +111,7 @@ impl std::error::Error for VoiceCallError {} #[derive(Clone)] pub struct TinyVoiceClient { module: ModuleClient, + pinned: Option, } impl fmt::Debug for TinyVoiceClient { @@ -130,7 +131,22 @@ impl TinyVoiceClient { /// and configured deadline. #[must_use] pub fn new(module: ModuleClient) -> Self { - Self { module } + 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. @@ -314,12 +330,13 @@ impl TinyVoiceClient { /// /// 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 = self + let id = client .call(tinyvoice_bus::names::methods::VAD_OPEN, (config,)) .await?; Ok(VadSession { - client: self.clone(), + client, id, }) } @@ -496,10 +513,12 @@ impl TinyVoiceClient { A: Serialize + Send, R: DeserializeOwned, { - self.module - .call_detailed(MODULE_ID, method, args) - .await - .map_err(VoiceCallError::from_failure) + 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 diff --git a/crates/tinyvoice-module/Cargo.toml b/crates/tinyvoice-module/Cargo.toml index fd5e936..12751b9 100644 --- a/crates/tinyvoice-module/Cargo.toml +++ b/crates/tinyvoice-module/Cargo.toml @@ -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.2.2, <0.3", 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.2.2, <0.3" +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"] } From 1427e092a9124c0cbcceafd820ae3e18b6a0c298 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:57:00 +0530 Subject: [PATCH 32/67] feat(client): add peer-pinned owned capture and hotkey sessions Adds start_capture_owned and reserve_hotkey_owned, which bind to a single module peer and return OwnedCaptureStream and OwnedHotkeySession wrappers that carry the private handle through poll, feed, read, and explicit close. RecorderSession::open now also binds once so its calls stay on one peer. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib.rs | 239 ++++++++++++++++++++++++++++- 1 file changed, 238 insertions(+), 1 deletion(-) diff --git a/crates/tinyvoice-client/src/lib.rs b/crates/tinyvoice-client/src/lib.rs index af942bd..806ac09 100644 --- a/crates/tinyvoice-client/src/lib.rs +++ b/crates/tinyvoice-client/src/lib.rs @@ -382,6 +382,53 @@ impl TinyVoiceClient { .await } + /// Reserve and start a continuous stream on one pinned module peer. + /// + /// The returned stream keeps the peer selected for this creation call for + /// all polls and explicit cleanup. 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 start_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)), + }; + let stream: CaptureResult = client + .call( + tinyvoice_bus::names::methods::CAPTURE_START, + (RecordingStartRequest { + permission, + handle: Some(handle), + },), + ) + .await?; + match stream { + Ok(stream) => Ok(Ok(OwnedCaptureStream { + client, + handle: stream.handle, + format: stream.format, + })), + Err(error) => Ok(Err(error)), + } + } + /// Read a bounded batch from a continuous capture lease. /// /// # Errors @@ -435,6 +482,30 @@ impl TinyVoiceClient { .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 = 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 @@ -640,6 +711,69 @@ impl VadSession { } } +/// 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. /// @@ -667,11 +801,12 @@ impl RecorderSession { /// /// 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: client.clone(), + client, id, }) } @@ -719,6 +854,108 @@ impl RecorderSession { } } +/// 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( From a7f46b62c6516740f9a6c35172959129294170fa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:58:58 +0530 Subject: [PATCH 33/67] test(tinyvoice-client): cover capture and hotkey ownership across alias handover Add fixture support for the capture and hotkey methods and new tests asserting that owned capture streams and hotkey sessions stay pinned to their creating peer when the public alias moves, and that all operations on a detached peer fail closed without reaching the replacement service. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib.rs | 10 +- .../src/lib_ownership_tests.rs | 621 ++++++++++++++++++ 2 files changed, 623 insertions(+), 8 deletions(-) diff --git a/crates/tinyvoice-client/src/lib.rs b/crates/tinyvoice-client/src/lib.rs index 806ac09..08f6868 100644 --- a/crates/tinyvoice-client/src/lib.rs +++ b/crates/tinyvoice-client/src/lib.rs @@ -335,10 +335,7 @@ impl TinyVoiceClient { let id = client .call(tinyvoice_bus::names::methods::VAD_OPEN, (config,)) .await?; - Ok(VadSession { - client, - id, - }) + Ok(VadSession { client, id }) } /// Reserve an opaque capture lease before native microphone startup. @@ -805,10 +802,7 @@ impl RecorderSession { let id = client .call(tinyvoice_bus::names::methods::SESSION_OPEN, ()) .await?; - Ok(Self { - client, - id, - }) + Ok(Self { client, id }) } /// Apply an input to the recording state machine. diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 6bc6f1c..2ec1fe3 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -22,6 +22,8 @@ use tinyvoice_bus::{SessionAction, SessionAdvance, SessionInput, SessionPhase, S 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, @@ -83,6 +85,10 @@ struct PeerState { calls: Vec<(String, Value)>, vad_open: bool, recorders: HashMap, + capture_reserved: bool, + capture_open: bool, + hotkey_open: bool, + hotkey_active: bool, } type SharedPeerState = Arc>; @@ -197,6 +203,106 @@ impl Interface for OwnershipVoice { .ok_or_else(|| tinybus::Error::failed("unknown recorder id"))?; Value::Null } + tinyvoice_bus::names::methods::RESERVE_CAPTURE => { + state.capture_reserved = true; + json!({"Ok": CAPTURE_ID}) + } + tinyvoice_bus::names::methods::CAPTURE_START => { + let request = 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"))?; + if !state.capture_reserved || handle != CAPTURE_ID { + return Err(tinybus::Error::failed("unknown capture reservation")); + } + state.capture_open = true; + json!({ + "Ok": { + "handle": CAPTURE_ID, + "format": {"source_rate": 48_000, "channels": 1} + } + }) + } + 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"))?; + ensure_capture_open(&state, request.get("handle").and_then(Value::as_str))?; + let sample = if self.peer == Peer::A { 0.25 } else { 0.75 }; + json!({ + "Ok": { + "chunks": [{"samples": [sample]}], + "closed": false + } + }) + } + tinyvoice_bus::names::methods::CAPTURE_STOP => { + ensure_capture_open(&state, values.first().and_then(Value::as_str))?; + state.capture_open = false; + json!({"Ok": null}) + } + tinyvoice_bus::names::methods::HOTKEY_RESERVE => { + state.hotkey_open = true; + json!({"Ok": HOTKEY_ID}) + } + tinyvoice_bus::names::methods::HOTKEY_START => { + ensure_hotkey_open(&state, values.first().and_then(Value::as_object))?; + json!({ + "Ok": { + "state": "running", + "generation": 1, + "active": false, + "continuity_lost": false + } + }) + } + tinyvoice_bus::names::methods::HOTKEY_FEED => { + ensure_hotkey_open(&state, values.first().and_then(Value::as_object))?; + state.hotkey_active = true; + json!({ + "Ok": { + "state": "running", + "generation": 1, + "active": true, + "continuity_lost": false + } + }) + } + tinyvoice_bus::names::methods::HOTKEY_READ => { + ensure_hotkey_open(&state, values.first().and_then(Value::as_object))?; + let event = if self.peer == Peer::A { + "pressed" + } else { + "released" + }; + let active = self.peer == Peer::B && state.hotkey_active; + 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_open = false; + json!({ + "Ok": { + "state": "stopped", + "generation": 1, + "active": false, + "continuity_lost": false + } + }) + } _ => { return Err(tinybus::Error::failed( "unexpected ownership fixture method", @@ -223,6 +329,30 @@ fn ensure_vad_open(state: &PeerState, id: u64) -> tinybus::Result<()> { } } +fn ensure_capture_open(state: &PeerState, handle: Option<&str>) -> tinybus::Result<()> { + if handle == Some(CAPTURE_ID) && state.capture_open { + Ok(()) + } else { + Err(tinybus::Error::failed("unknown capture stream")) + } +} + +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) + && state.hotkey_open + { + 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 => { @@ -587,3 +717,494 @@ async fn recorder_session_stays_with_its_creator_across_a_live_public_alias_hand "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 = fixture + .client + .start_capture_owned(MicrophonePermission::Granted) + .await + .expect("start capture on A") + .expect("A capture start succeeds"); + 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 = fixture + .client + .start_capture_owned(MicrophonePermission::Granted) + .await + .expect("start colliding capture on B") + .expect("B capture start succeeds"); + assert_eq!(a.handle, b.handle, "capture handles collide across peers"); + let b_batch = b.poll(1).await; + assert_eq!( + b_batch, + Ok(Ok(CaptureBatch { + chunks: vec![RawChunk { + samples: vec![0.75] + }], + closed: false, + })), + "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_eq!( + (a_batch, a_close, b_still_open), + ( + Ok(Ok(CaptureBatch { + chunks: vec![RawChunk { + samples: vec![0.25] + }], + closed: false, + })), + Ok(Ok(())), + Ok(Ok(CaptureBatch { + chunks: vec![RawChunk { + samples: vec![0.75] + }], + closed: false, + })), + ), + "A polling/close stay on A, and closing A leaves B 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 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] +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_capture = fixture + .client + .start_capture_owned(MicrophonePermission::Granted) + .await + .expect("start A capture") + .expect("A capture starts"); + 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_capture = fixture + .client + .start_capture_owned(MicrophonePermission::Granted) + .await + .expect("start colliding B capture") + .expect("B capture starts"); + 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 starts"); + b_hotkey + .feed( + 1, + vec![SequencedHostFact { + sequence: 1, + fact: HostKeyFact::Down, + }], + false, + ) + .await + .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" + ); +} From 40b37958f3483c95981a7019f960a3416ce2f101 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:05:40 +0530 Subject: [PATCH 34/67] test(tinyvoice-client): add capture start gate to ownership tests The handoff fixture now accepts an optional capture start gate and client timeout so tests can pause capture start and exercise timeout behaviour. Tokio's time and test-util features were enabled to support this. Auto-committed-on: macbook --- crates/tinyvoice-client/Cargo.toml | 2 +- .../src/lib_ownership_tests.rs | 40 +++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/tinyvoice-client/Cargo.toml b/crates/tinyvoice-client/Cargo.toml index ecf0036..72901dd 100644 --- a/crates/tinyvoice-client/Cargo.toml +++ b/crates/tinyvoice-client/Cargo.toml @@ -23,7 +23,7 @@ tinybus = { version = ">=0.3.2, <0.4", path = "../../vendor/tinybus/crates/tinyb # 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"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "test-util"] } [lints] workspace = true diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 2ec1fe3..a9b9745 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -6,6 +6,7 @@ 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}; @@ -93,9 +94,17 @@ struct PeerState { type SharedPeerState = Arc>; +#[derive(Default)] +struct CaptureStartGate { + entered: tokio::sync::Notify, + release: tokio::sync::Notify, + completed: tokio::sync::Notify, +} + struct OwnershipVoice { peer: Peer, state: SharedPeerState, + capture_start_gate: Option>, } #[async_trait] @@ -113,6 +122,12 @@ impl Interface for OwnershipVoice { async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { let method = member.as_str(); + if method == tinyvoice_bus::names::methods::CAPTURE_START { + if let Some(gate) = &self.capture_start_gate { + gate.entered.notify_one(); + gate.release.notified().await; + } + } let mut state = self .state .lock() @@ -220,6 +235,9 @@ impl Interface for OwnershipVoice { return Err(tinybus::Error::failed("unknown capture reservation")); } state.capture_open = true; + if let Some(gate) = &self.capture_start_gate { + gate.completed.notify_one(); + } json!({ "Ok": { "handle": CAPTURE_ID, @@ -399,6 +417,13 @@ struct AliasHandoffFixture { } 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()); @@ -414,6 +439,7 @@ async fn handoff_fixture() -> tinybus::Result { OwnershipVoice { peer: Peer::A, state: state_a.clone(), + capture_start_gate: capture_start_gate.clone(), }, ) .await?; @@ -423,6 +449,7 @@ async fn handoff_fixture() -> tinybus::Result { OwnershipVoice { peer: Peer::B, state: state_b.clone(), + capture_start_gate: None, }, ) .await?; @@ -443,8 +470,8 @@ async fn handoff_fixture() -> tinybus::Result { tinyvoice_bus::BUS_NAME, )?; - let client = make_client(alias_proxy); - let direct_a = make_client(direct_a_proxy); + 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, @@ -457,11 +484,18 @@ async fn handoff_fixture() -> tinybus::Result { } fn make_client(proxy: Proxy) -> TinyVoiceClient { + make_client_with_timeout(proxy, None) +} + +fn make_client_with_timeout(proxy: Proxy, timeout: Option) -> TinyVoiceClient { let failures = Arc::new(IgnoreFailures); - let module = ModuleClient::new(FixtureAccess { + 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) } From a737a56027240dc209b6cdd75f3c339300afbbb7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:06:11 +0530 Subject: [PATCH 35/67] test(tinyvoice-client): cover late capture publication after start timeout Add a test asserting that when start_capture_owned hits its deadline, the in-flight service operation can still publish the stream and the late handle remains pollable and stoppable. The unused make_client helper was dropped since the new test builds its client through the fixture. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 69 +++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index a9b9745..fdfb719 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -483,10 +483,6 @@ async fn handoff_fixture_with_capture_start_gate( }) } -fn make_client(proxy: Proxy) -> TinyVoiceClient { - make_client_with_timeout(proxy, None) -} - fn make_client_with_timeout(proxy: Proxy, timeout: Option) -> TinyVoiceClient { let failures = Arc::new(IgnoreFailures); let mut module = ModuleClient::new(FixtureAccess { @@ -858,6 +854,71 @@ async fn owned_capture_stream_stays_with_its_creator_across_alias_handover() { ); } +#[tokio::test(start_paused = true)] +async fn timed_out_owned_capture_start_can_publish_after_its_caller_loses_the_handle() { + 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 client = fixture.client.clone(); + let (result_tx, result_rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let result = client + .start_capture_owned(MicrophonePermission::Granted) + .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. + gate.entered.notified().await; + tokio::time::advance(Duration::from_secs(1)).await; + let timed_out = result_rx + .await + .expect("the client task reports its deadline result"); + assert!(timed_out.is_err(), "the start RPC must hit its configured deadline"); + + // A timeout only stops the caller's wait. The in-flight service operation + // can still publish the stream after the caller lost the private handle. + let completed = gate.completed.notified(); + gate.release.notify_one(); + completed.await; + let late_poll = fixture + .client + .poll_capture(CapturePollRequest { + handle: CaptureHandle(CAPTURE_ID.to_owned()), + max_chunks: 1, + }) + .await; + let cleanup = fixture + .client + .stop_capture(CaptureHandle(CAPTURE_ID.to_owned())) + .await; + + assert_eq!( + late_poll, + Ok(Ok(CaptureBatch { + chunks: vec![RawChunk { samples: vec![0.25] }], + closed: false, + })), + "the service published a live stream after start_capture_owned returned an error" + ); + assert_eq!(cleanup, Ok(Ok(())), "the fixture cleans up the 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_POLL, + tinyvoice_bus::names::methods::CAPTURE_STOP, + ] + .map(str::to_owned) + ); +} + #[tokio::test] async fn owned_hotkey_session_stays_with_its_creator_across_alias_handover() { let fixture = handoff_fixture() From cc8515fe0dfdeef326c1310c2aae4f6eb3451950 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:06:36 +0530 Subject: [PATCH 36/67] docs(tinyvoice-client): document peer-pinned owned sessions The README now describes how stateful owners bind once at creation and retain a private peer-pinned client, covering the new owned capture and hotkey leases and their explicit close semantics. It also updates the TinyBus version notes to the 0.3.2 line and clarifies that the flat capture and hotkey methods remain compatibility calls. Auto-committed-on: macbook --- crates/tinyvoice-client/README.md | 43 ++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/crates/tinyvoice-client/README.md b/crates/tinyvoice-client/README.md index 28e8df7..9cf1e8e 100644 --- a/crates/tinyvoice-client/README.md +++ b/crates/tinyvoice-client/README.md @@ -6,16 +6,29 @@ 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`. That keeps module loading, compatibility checks, -deadlines, and redacted failure reporting on the same path the host already -configures; the client must not open a second bus connection or create a -private resolver. Its methods preserve each contract member's positional -tuple and typed reply. +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` and `RecorderSession` are explicit async leases. Closing them calls -the corresponding module method; dropping a Rust handle does not imply remote -cleanup. OpenHuman keeps host policy such as volume clamping and conversion -from persisted server configuration into `VadConfig` in its own adapter. +`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. + +`start_capture_owned` binds before reserving and starting the native stream, +then returns an `OwnedCaptureStream` for polling and explicit close. A timeout +or cancellation during `CAPTURE_START` occurs before that owner is returned; +callers that need the reservation handle before native startup can keep using +the flat `reserve_capture`, `start_capture`, and `stop_capture` methods. Those +legacy methods preserve the caller's explicit handle, but 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 @@ -24,10 +37,10 @@ failure category; its `Debug` and `Display` omit remote prose. The explicit 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 the wire arguments and returned types; -they do not load a published artifact, access a microphone, or contact a -backend. +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 manifest's TinyBus version range targets the published 0.2.2 client API. -Pin the final constraint to the TinyBus 0.3.x runtime when the runtime SDK and -vendored gitlink are aligned. +The client and module manifests target the published TinyBus 0.3.2 line, and +the vendored gitlink pins that source. The original Cargo lockfiles still need +refreshing under the authorized Cargo validation slot. From b3240c9ea0017c88484cbc2dae3e523b678b2230 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:07:29 +0530 Subject: [PATCH 37/67] ci(release): add reviewed-epic release mode to module release workflow The release workflow now accepts a release_mode input that selects either the ordinary main path or a reviewed-epic admission path, with reviewed_pr required only in the latter. This lets tagged releases be validated against a reviewed PR's merge commit while keeping the existing main flow unchanged. Auto-committed-on: macbook --- .github/workflows/release.yml | 53 +++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7c83ea..3819431 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@7b921fd + with: + tag: ${{ needs.resolve.outputs.tag }} + version: ${{ needs.resolve.outputs.version }} + reviewed-pr: ${{ fromJSON(needs.resolve.outputs.reviewed_pr || '0') }} From 3964f147b36a261156cfd375e033cfad20e171b7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:08:20 +0530 Subject: [PATCH 38/67] ci(release): pin reusable release workflow to full commit sha The release workflow now references the shared module-reviewed-epic-release workflow by its full commit SHA instead of a short one, and the tinyvoice-module crate version is bumped to 0.3.3 for the next release. Auto-committed-on: macbook --- .github/workflows/release.yml | 2 +- crates/tinyvoice-module/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3819431..9798e3f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,7 +86,7 @@ jobs: pull-requests: read checks: read actions: read - uses: tinyhumansai/.github/.github/workflows/module-reviewed-epic-release.yml@7b921fd + uses: tinyhumansai/.github/.github/workflows/module-reviewed-epic-release.yml@1e28317c435a9c714002199e65d18a1067fa5777 with: tag: ${{ needs.resolve.outputs.tag }} version: ${{ needs.resolve.outputs.version }} diff --git a/crates/tinyvoice-module/Cargo.toml b/crates/tinyvoice-module/Cargo.toml index 12751b9..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" From 65285b9a8c78e73d7a00cb16ae2e416fea3a34dc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:08:48 +0530 Subject: [PATCH 39/67] test(client): cover canceled owned capture start publishing late Add a test asserting that canceling a start_capture_owned call drops the caller's handle without stopping the remote startup, so the service can still publish a live stream afterward. The existing timeout test and a few assertions were also reformatted to satisfy rustfmt. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 68 ++++++++++++++++--- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index fdfb719..8efc89b 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -857,12 +857,10 @@ async fn owned_capture_stream_stays_with_its_creator_across_alias_handover() { #[tokio::test(start_paused = true)] async fn timed_out_owned_capture_start_can_publish_after_its_caller_loses_the_handle() { 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 fixture = + handoff_fixture_with_capture_start_gate(Some(gate.clone()), Some(Duration::from_secs(1))) + .await + .expect("start gated capture service"); let client = fixture.client.clone(); let (result_tx, result_rx) = tokio::sync::oneshot::channel(); tokio::spawn(async move { @@ -879,7 +877,10 @@ async fn timed_out_owned_capture_start_can_publish_after_its_caller_loses_the_ha let timed_out = result_rx .await .expect("the client task reports its deadline result"); - assert!(timed_out.is_err(), "the start RPC must hit its configured deadline"); + assert!( + timed_out.is_err(), + "the start RPC must hit its configured deadline" + ); // A timeout only stops the caller's wait. The in-flight service operation // can still publish the stream after the caller lost the private handle. @@ -901,7 +902,9 @@ async fn timed_out_owned_capture_start_can_publish_after_its_caller_loses_the_ha assert_eq!( late_poll, Ok(Ok(CaptureBatch { - chunks: vec![RawChunk { samples: vec![0.25] }], + chunks: vec![RawChunk { + samples: vec![0.25] + }], closed: false, })), "the service published a live stream after start_capture_owned returned an error" @@ -919,6 +922,55 @@ async fn timed_out_owned_capture_start_can_publish_after_its_caller_loses_the_ha ); } +#[tokio::test] +async fn canceled_owned_capture_start_can_publish_after_its_caller_loses_the_handle() { + 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 client = fixture.client.clone(); + let start_task = tokio::spawn(async move { + client + .start_capture_owned(MicrophonePermission::Granted) + .await + }); + + gate.entered.notified().await; + start_task.abort(); + assert!( + start_task + .await + .expect_err("the caller task is canceled") + .is_cancelled() + ); + + let completed = gate.completed.notified(); + gate.release.notify_one(); + completed.await; + let late_poll = fixture + .client + .poll_capture(CapturePollRequest { + handle: CaptureHandle(CAPTURE_ID.to_owned()), + max_chunks: 1, + }) + .await; + let cleanup = fixture + .client + .stop_capture(CaptureHandle(CAPTURE_ID.to_owned())) + .await; + assert_eq!( + late_poll, + Ok(Ok(CaptureBatch { + chunks: vec![RawChunk { + samples: vec![0.25] + }], + closed: false, + })), + "canceling the call dropped its handle but did not stop remote startup" + ); + assert_eq!(cleanup, Ok(Ok(())), "the fixture cleans up the late stream"); +} + #[tokio::test] async fn owned_hotkey_session_stays_with_its_creator_across_alias_handover() { let fixture = handoff_fixture() From 5d8f4290792cc4a425cfea2d5696a1e0ccebd43f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:11:55 +0530 Subject: [PATCH 40/67] test(client): assert timed-out and canceled starts leave no live stream The ownership tests for timed-out and canceled capture starts now assert that no unowned live stream is left behind, instead of asserting that a late publication succeeds. Each await on the capture-start gate and the caller result is wrapped in a timeout so a stalled handoff fails the test rather than hanging it. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 67 +++++++++---------- 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 8efc89b..46fe0ba 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -855,7 +855,7 @@ async fn owned_capture_stream_stays_with_its_creator_across_alias_handover() { } #[tokio::test(start_paused = true)] -async fn timed_out_owned_capture_start_can_publish_after_its_caller_loses_the_handle() { +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))) @@ -872,10 +872,13 @@ async fn timed_out_owned_capture_start_can_publish_after_its_caller_loses_the_ha // The reservation call has completed and CAPTURE_START has reached the // remote service, which is deliberately held before native publication. - gate.entered.notified().await; + 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 = result_rx + 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(), @@ -884,9 +887,10 @@ async fn timed_out_owned_capture_start_can_publish_after_its_caller_loses_the_ha // A timeout only stops the caller's wait. The in-flight service operation // can still publish the stream after the caller lost the private handle. - let completed = gate.completed.notified(); gate.release.notify_one(); - completed.await; + tokio::time::timeout(Duration::from_secs(5), gate.completed.notified()) + .await + .expect("the delayed service finishes publishing the stream"); let late_poll = fixture .client .poll_capture(CapturePollRequest { @@ -894,22 +898,17 @@ async fn timed_out_owned_capture_start_can_publish_after_its_caller_loses_the_ha max_chunks: 1, }) .await; + let late_stream_is_live = matches!(late_poll, Ok(Ok(_))); let cleanup = fixture .client .stop_capture(CaptureHandle(CAPTURE_ID.to_owned())) .await; - assert_eq!( - late_poll, - Ok(Ok(CaptureBatch { - chunks: vec![RawChunk { - samples: vec![0.25] - }], - closed: false, - })), - "the service published a live stream after start_capture_owned returned an error" + assert!(cleanup.is_ok(), "the fixture cleans up any late stream"); + assert!( + !late_stream_is_live, + "a timed-out owned start must not leave an unowned live stream" ); - assert_eq!(cleanup, Ok(Ok(())), "the fixture cleans up the late stream"); assert_eq!( methods(&fixture.state_a), [ @@ -923,7 +922,7 @@ async fn timed_out_owned_capture_start_can_publish_after_its_caller_loses_the_ha } #[tokio::test] -async fn canceled_owned_capture_start_can_publish_after_its_caller_loses_the_handle() { +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 @@ -935,18 +934,20 @@ async fn canceled_owned_capture_start_can_publish_after_its_caller_loses_the_han .await }); - gate.entered.notified().await; + tokio::time::timeout(Duration::from_secs(5), gate.entered.notified()) + .await + .expect("CAPTURE_START reaches the delayed service"); start_task.abort(); - assert!( - start_task - .await - .expect_err("the caller task is canceled") - .is_cancelled() - ); + 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 completed = gate.completed.notified(); gate.release.notify_one(); - completed.await; + tokio::time::timeout(Duration::from_secs(5), gate.completed.notified()) + .await + .expect("the delayed service finishes publishing the stream"); let late_poll = fixture .client .poll_capture(CapturePollRequest { @@ -954,21 +955,17 @@ async fn canceled_owned_capture_start_can_publish_after_its_caller_loses_the_han max_chunks: 1, }) .await; + let late_stream_is_live = matches!(late_poll, Ok(Ok(_))); let cleanup = fixture .client .stop_capture(CaptureHandle(CAPTURE_ID.to_owned())) .await; - assert_eq!( - late_poll, - Ok(Ok(CaptureBatch { - chunks: vec![RawChunk { - samples: vec![0.25] - }], - closed: false, - })), - "canceling the call dropped its handle but did not stop remote startup" + assert!(aborted, "the caller's start future was canceled"); + assert!(cleanup.is_ok(), "the fixture cleans up any late stream"); + assert!( + !late_stream_is_live, + "a canceled owned start must not leave an unowned live stream" ); - assert_eq!(cleanup, Ok(Ok(())), "the fixture cleans up the late stream"); } #[tokio::test] From 744c54b8cecb3ecf9a0ec66e4e3662c8edafd773 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:32:31 +0530 Subject: [PATCH 41/67] chore(deps): bump tinybus from 0.2.2 to 0.3.2 Update the locked tinybus dependency to 0.3.2 to pick up the latest release. Auto-committed-on: macbook --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index cf4377a..550dbdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -910,7 +910,7 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.2.2" +version = "0.3.2" dependencies = [ "async-trait", "serde", From 00a5cad8228d92d3c0fddcdfe7aa910e70681183 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:32:36 +0530 Subject: [PATCH 42/67] chore(deps): bump tinybus to 0.3.2 and tinyvoice-module to 0.3.3 Updated the lockfile to pull in tinybus, tinybus-macros, and tinybus-module at 0.3.2, and bumped tinyvoice-module to 0.3.3 to match. Auto-committed-on: macbook --- crates/tinyvoice-module/Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tinyvoice-module/Cargo.lock b/crates/tinyvoice-module/Cargo.lock index ae59fa9..616fe1b 100644 --- a/crates/tinyvoice-module/Cargo.lock +++ b/crates/tinyvoice-module/Cargo.lock @@ -2256,7 +2256,7 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.2.2" +version = "0.3.2" dependencies = [ "async-trait", "flate2", @@ -2279,7 +2279,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.2.2" +version = "0.3.2" dependencies = [ "proc-macro2", "quote", @@ -2288,7 +2288,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.2.2" +version = "0.3.2" dependencies = [ "async-trait", "serde", @@ -2506,7 +2506,7 @@ dependencies = [ [[package]] name = "tinyvoice-module" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "async-trait", From 751775fcdd0fe9ea8f49df91a79d1555ada3c62f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:33:01 +0530 Subject: [PATCH 43/67] test(tinyvoice-client): split ownership assertions for clearer failures Reworked the capture stream ownership test to assert each peer's poll and close result separately instead of comparing one large tuple, so a failure points at the specific step that broke. Also annotated the hotkey reserve handle's result type to make the expected error type explicit. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib.rs | 2 +- .../src/lib_ownership_tests.rs | 46 +++++++++---------- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/crates/tinyvoice-client/src/lib.rs b/crates/tinyvoice-client/src/lib.rs index 08f6868..e8f3efb 100644 --- a/crates/tinyvoice-client/src/lib.rs +++ b/crates/tinyvoice-client/src/lib.rs @@ -494,7 +494,7 @@ impl TinyVoiceClient { request: HotkeyRequest, ) -> Result> { let client = self.bind().await?; - let handle = client + let handle: std::result::Result = client .call( tinyvoice_bus::names::methods::HOTKEY_RESERVE, (HotkeyReserveRequest { request },), diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 46fe0ba..268a8bf 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -796,38 +796,34 @@ async fn owned_capture_stream_stays_with_its_creator_across_alias_handover() { .expect("B capture start succeeds"); assert_eq!(a.handle, b.handle, "capture handles collide across peers"); let b_batch = b.poll(1).await; - assert_eq!( - b_batch, - Ok(Ok(CaptureBatch { - chunks: vec![RawChunk { - samples: vec![0.75] - }], - closed: false, - })), + 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_eq!( - (a_batch, a_close, b_still_open), - ( - Ok(Ok(CaptureBatch { - chunks: vec![RawChunk { - samples: vec![0.25] - }], - closed: false, - })), - Ok(Ok(())), - Ok(Ok(CaptureBatch { - chunks: vec![RawChunk { - samples: vec![0.75] - }], - closed: false, - })), + 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] }] ), - "A polling/close stay on A, and closing A leaves B live" + "closing A leaves B's stream live" ); assert_eq!( methods(&fixture.state_a), From 2b48d5a28dece8dc7dcfed2ed45151b446a6de19 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:33:13 +0530 Subject: [PATCH 44/67] test(tinyvoice-client): handle nested results in ownership test The ownership test now unwraps the RPC result before the inner result for hotkey start and feed, matching the client's nested return types so the assertions exercise the intended behaviour. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_ownership_tests.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 268a8bf..7d01cce 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -1213,7 +1213,11 @@ async fn owned_resources_fail_closed_after_their_peer_detaches() { .advance(SessionInput::Run) .await .expect("B recorder responds"); - b_hotkey.start().await.expect("B hotkey starts"); + b_hotkey + .start() + .await + .expect("B hotkey start RPC succeeds") + .expect("B hotkey starts"); b_hotkey .feed( 1, @@ -1224,6 +1228,7 @@ async fn owned_resources_fail_closed_after_their_peer_detaches() { 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(), From 8e7f9ea7cca52c7d1ccc02449f30d161afdfa75b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:35:04 +0530 Subject: [PATCH 45/67] feat(tinyvoice-client): split owned capture start into reserve and start `start_capture_owned` is replaced by `reserve_capture_owned`, which returns an `OwnedCaptureReservation` bound to the pinned peer before any fallible native startup runs. The reservation exposes `start` and `close` so a timed-out or canceled start can still be stopped through the same peer. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib.rs | 109 +++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 23 deletions(-) diff --git a/crates/tinyvoice-client/src/lib.rs b/crates/tinyvoice-client/src/lib.rs index e8f3efb..ad41006 100644 --- a/crates/tinyvoice-client/src/lib.rs +++ b/crates/tinyvoice-client/src/lib.rs @@ -379,20 +379,21 @@ impl TinyVoiceClient { .await } - /// Reserve and start a continuous stream on one pinned module peer. + /// Reserve a continuous capture on one pinned module peer. /// - /// The returned stream keeps the peer selected for this creation call for - /// all polls and explicit cleanup. Host microphone permission remains an - /// input supplied by the caller. + /// 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 start_capture_owned( + pub async fn reserve_capture_owned( &self, permission: MicrophonePermission, - ) -> Result> { + ) -> Result> { let client = self.bind().await?; let reservation = client .call( @@ -407,23 +408,11 @@ impl TinyVoiceClient { Ok(handle) => handle, Err(error) => return Ok(Err(error)), }; - let stream: CaptureResult = client - .call( - tinyvoice_bus::names::methods::CAPTURE_START, - (RecordingStartRequest { - permission, - handle: Some(handle), - },), - ) - .await?; - match stream { - Ok(stream) => Ok(Ok(OwnedCaptureStream { - client, - handle: stream.handle, - format: stream.format, - })), - Err(error) => Ok(Err(error)), - } + Ok(Ok(OwnedCaptureReservation { + client, + handle, + permission, + })) } /// Read a bounded batch from a continuous capture lease. @@ -708,6 +697,80 @@ impl VadSession { } } +/// 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)), + } + } + + /// Cancel pending startup or stop a published stream on its pinned peer. + /// + /// If startup has not begun, the module may report [`CaptureError::UnknownHandle`]; + /// reservations expire in the module according to its existing contract. + /// + /// # 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 From 51fe3c6246e3e71b2c2b33bdf84239e9f2770542 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:35:41 +0530 Subject: [PATCH 46/67] test(tinyvoice-client): cover capture start cancellation in ownership fixture The ownership fixture now models a capture start that is still pending when a stop arrives, so tests can exercise cancellation of an in-flight start. Capture poll and stop also return unknown_handle for handles that were never opened instead of relying on a shared assertion helper. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 73 ++++++++++++++----- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 7d01cce..80cfbd1 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -87,6 +87,8 @@ struct PeerState { vad_open: bool, recorders: HashMap, capture_reserved: bool, + capture_pending: bool, + capture_canceled: bool, capture_open: bool, hotkey_open: bool, hotkey_active: bool, @@ -99,6 +101,7 @@ struct CaptureStartGate { entered: tokio::sync::Notify, release: tokio::sync::Notify, completed: tokio::sync::Notify, + stop_entered: tokio::sync::Notify, } struct OwnershipVoice { @@ -122,12 +125,6 @@ impl Interface for OwnershipVoice { async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { let method = member.as_str(); - if method == tinyvoice_bus::names::methods::CAPTURE_START { - if let Some(gate) = &self.capture_start_gate { - gate.entered.notify_one(); - gate.release.notified().await; - } - } let mut state = self .state .lock() @@ -137,6 +134,26 @@ impl Interface for OwnershipVoice { .as_array() .ok_or_else(|| tinybus::Error::failed("expected positional method arguments"))?; + if method == tinyvoice_bus::names::methods::CAPTURE_START { + if let Some(gate) = &self.capture_start_gate { + state.capture_pending = true; + drop(state); + gate.entered.notify_one(); + gate.release.notified().await; + state = self + .state + .lock() + .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; + if state.capture_canceled { + state.capture_pending = false; + state.capture_canceled = false; + state.capture_reserved = false; + gate.completed.notify_one(); + return Ok(json!({"Err": {"code": "cancelled"}})); + } + } + } + let result = match method { tinyvoice_bus::names::methods::ROUTE => match self.peer { Peer::A => json!(r#"{"intent":"pause"}"#), @@ -234,6 +251,8 @@ impl Interface for OwnershipVoice { if !state.capture_reserved || handle != CAPTURE_ID { return Err(tinybus::Error::failed("unknown capture reservation")); } + state.capture_pending = false; + state.capture_reserved = false; state.capture_open = true; if let Some(gate) = &self.capture_start_gate { gate.completed.notify_one(); @@ -250,19 +269,39 @@ impl Interface for OwnershipVoice { .first() .and_then(Value::as_object) .ok_or_else(|| tinybus::Error::failed("missing capture poll request"))?; - ensure_capture_open(&state, request.get("handle").and_then(Value::as_str))?; - let sample = if self.peer == Peer::A { 0.25 } else { 0.75 }; - json!({ - "Ok": { - "chunks": [{"samples": [sample]}], - "closed": false - } - }) + if request.get("handle").and_then(Value::as_str) != Some(CAPTURE_ID) + || !state.capture_open + { + json!({"Err": {"code": "unknown_handle"}}) + } else { + let sample = if self.peer == Peer::A { 0.25 } else { 0.75 }; + json!({ + "Ok": { + "chunks": [{"samples": [sample]}], + "closed": false + } + }) + } } tinyvoice_bus::names::methods::CAPTURE_STOP => { - ensure_capture_open(&state, values.first().and_then(Value::as_str))?; - state.capture_open = false; - json!({"Ok": null}) + let gate = self.capture_start_gate.clone(); + if state.capture_pending { + state.capture_canceled = true; + drop(state); + if let Some(gate) = gate { + gate.stop_entered.notify_one(); + gate.completed.notified().await; + } + return Ok(json!({"Ok": null})); + } + if values.first().and_then(Value::as_str) == Some(CAPTURE_ID) + && state.capture_open + { + state.capture_open = false; + json!({"Ok": null}) + } else { + json!({"Err": {"code": "unknown_handle"}}) + } } tinyvoice_bus::names::methods::HOTKEY_RESERVE => { state.hotkey_open = true; From 561784f3bd57f2d0b037bea311b1e615088dcd2f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:36:07 +0530 Subject: [PATCH 47/67] test(tinyvoice-client): reuse owned capture setup across ownership tests Extracted a shared helper that reserves and starts an owned capture stream, and replaced the repeated inline setup in the ownership tests with it. Also dropped an unused capture-open helper. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 52 +++++++------------ 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 80cfbd1..eb761f8 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -386,14 +386,6 @@ fn ensure_vad_open(state: &PeerState, id: u64) -> tinybus::Result<()> { } } -fn ensure_capture_open(state: &PeerState, handle: Option<&str>) -> tinybus::Result<()> { - if handle == Some(CAPTURE_ID) && state.capture_open { - Ok(()) - } else { - Err(tinybus::Error::failed("unknown capture stream")) - } -} - fn ensure_hotkey_open( state: &PeerState, request: Option<&serde_json::Map>, @@ -534,6 +526,22 @@ fn make_client_with_timeout(proxy: Proxy, timeout: Option) -> TinyVoic 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?; @@ -792,12 +800,7 @@ 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 = fixture - .client - .start_capture_owned(MicrophonePermission::Granted) - .await - .expect("start capture on A") - .expect("A capture start succeeds"); + let (_a_reservation, a) = reserve_and_start_owned_capture(&fixture.client).await; assert_eq!( a.format(), CaptureFormat { @@ -827,12 +830,7 @@ async fn owned_capture_stream_stays_with_its_creator_across_alias_handover() { VoiceIntent::Resume ); - let b = fixture - .client - .start_capture_owned(MicrophonePermission::Granted) - .await - .expect("start colliding capture on B") - .expect("B capture start succeeds"); + 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!( @@ -1190,12 +1188,7 @@ async fn owned_resources_fail_closed_after_their_peer_detaches() { let a_recorder = RecorderSession::open(&fixture.client) .await .expect("open A recorder"); - let a_capture = fixture - .client - .start_capture_owned(MicrophonePermission::Granted) - .await - .expect("start A capture") - .expect("A capture starts"); + let (_a_reservation, a_capture) = reserve_and_start_owned_capture(&fixture.client).await; let a_hotkey = fixture .client .reserve_hotkey_owned(HotkeyRequest { @@ -1227,12 +1220,7 @@ async fn owned_resources_fail_closed_after_their_peer_detaches() { let b_recorder = RecorderSession::open(&fixture.client) .await .expect("open colliding B recorder"); - let b_capture = fixture - .client - .start_capture_owned(MicrophonePermission::Granted) - .await - .expect("start colliding B capture") - .expect("B capture starts"); + let (_b_reservation, b_capture) = reserve_and_start_owned_capture(&fixture.client).await; let b_hotkey = fixture .client .reserve_hotkey_owned(HotkeyRequest { From df3efaead3250a18879fc30e29a8ca20d46b1247 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:36:33 +0530 Subject: [PATCH 48/67] test(tinyvoice-client): cover explicit close after timed-out owned start Rework the ownership tests so the reservation is taken up front and the retained owner is closed explicitly after a timed-out or canceled start, asserting the close waits for startup cleanup and leaves no late stream pollable. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 69 ++++++++++--------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index eb761f8..055410b 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -894,12 +894,16 @@ async fn timed_out_capture_start_does_not_leave_an_unowned_stream() { handoff_fixture_with_capture_start_gate(Some(gate.clone()), Some(Duration::from_secs(1))) .await .expect("start gated capture service"); - let client = fixture.client.clone(); + 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 = client - .start_capture_owned(MicrophonePermission::Granted) - .await; + let result = start_owner.start().await; let _ = result_tx.send(result); }); @@ -918,12 +922,17 @@ async fn timed_out_capture_start_does_not_leave_an_unowned_stream() { "the start RPC must hit its configured deadline" ); - // A timeout only stops the caller's wait. The in-flight service operation - // can still publish the stream after the caller lost the private handle. + 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(); - tokio::time::timeout(Duration::from_secs(5), gate.completed.notified()) + let close_result = tokio::time::timeout(Duration::from_secs(5), close_task) .await - .expect("the delayed service finishes publishing the stream"); + .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 { @@ -931,16 +940,9 @@ async fn timed_out_capture_start_does_not_leave_an_unowned_stream() { max_chunks: 1, }) .await; - let late_stream_is_live = matches!(late_poll, Ok(Ok(_))); - let cleanup = fixture - .client - .stop_capture(CaptureHandle(CAPTURE_ID.to_owned())) - .await; - - assert!(cleanup.is_ok(), "the fixture cleans up any late stream"); assert!( - !late_stream_is_live, - "a timed-out owned start must not leave an unowned live stream" + matches!(late_poll, Ok(Err(CaptureError::UnknownHandle))), + "closing the retained owner after timeout leaves no late stream" ); assert_eq!( methods(&fixture.state_a), @@ -960,11 +962,15 @@ async fn canceled_capture_start_does_not_leave_an_unowned_stream() { let fixture = handoff_fixture_with_capture_start_gate(Some(gate.clone()), None) .await .expect("start gated capture service"); - let client = fixture.client.clone(); + 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 { - client - .start_capture_owned(MicrophonePermission::Granted) - .await + start_owner.start().await }); tokio::time::timeout(Duration::from_secs(5), gate.entered.notified()) @@ -977,10 +983,17 @@ async fn canceled_capture_start_does_not_leave_an_unowned_stream() { .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(); - tokio::time::timeout(Duration::from_secs(5), gate.completed.notified()) + let close_result = tokio::time::timeout(Duration::from_secs(5), close_task) .await - .expect("the delayed service finishes publishing the stream"); + .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 { @@ -988,16 +1001,10 @@ async fn canceled_capture_start_does_not_leave_an_unowned_stream() { max_chunks: 1, }) .await; - let late_stream_is_live = matches!(late_poll, Ok(Ok(_))); - let cleanup = fixture - .client - .stop_capture(CaptureHandle(CAPTURE_ID.to_owned())) - .await; assert!(aborted, "the caller's start future was canceled"); - assert!(cleanup.is_ok(), "the fixture cleans up any late stream"); assert!( - !late_stream_is_live, - "a canceled owned start must not leave an unowned live stream" + matches!(late_poll, Ok(Err(CaptureError::UnknownHandle))), + "closing the retained owner after cancellation leaves no late stream" ); } From fe48b132db3837cc2c147b6e574ef64aa10b24d6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:37:07 +0530 Subject: [PATCH 49/67] docs(tinyvoice-client): document owned reservation flow The README now describes `reserve_capture_owned` returning a pinned reservation before `CAPTURE_START`, with explicit close targeting the creating peer and the existing pending-start cancellation path. The ownership test's expected call order was corrected to match the actual stop-then-poll sequence. Auto-committed-on: macbook --- crates/tinyvoice-client/README.md | 17 ++++++++++------- .../tinyvoice-client/src/lib_ownership_tests.rs | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/tinyvoice-client/README.md b/crates/tinyvoice-client/README.md index 9cf1e8e..b594923 100644 --- a/crates/tinyvoice-client/README.md +++ b/crates/tinyvoice-client/README.md @@ -22,13 +22,16 @@ 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. -`start_capture_owned` binds before reserving and starting the native stream, -then returns an `OwnedCaptureStream` for polling and explicit close. A timeout -or cancellation during `CAPTURE_START` occurs before that owner is returned; -callers that need the reservation handle before native startup can keep using -the flat `reserve_capture`, `start_capture`, and `stop_capture` methods. Those -legacy methods preserve the caller's explicit handle, but use whichever peer -the supplied ordinary client addresses at each call. +`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 still follows the module's existing expiry behavior because the bus +contract has no separate reservation-release method. 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 diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 055410b..ea9e7b6 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -949,8 +949,8 @@ async fn timed_out_capture_start_does_not_leave_an_unowned_stream() { [ tinyvoice_bus::names::methods::RESERVE_CAPTURE, tinyvoice_bus::names::methods::CAPTURE_START, - tinyvoice_bus::names::methods::CAPTURE_POLL, tinyvoice_bus::names::methods::CAPTURE_STOP, + tinyvoice_bus::names::methods::CAPTURE_POLL, ] .map(str::to_owned) ); From 184d7354458fdc540dacf0cac35f1dc08173c5a7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:37:20 +0530 Subject: [PATCH 50/67] test(tinyvoice-client): reformat ownership tests Reformatted two expressions in the ownership tests to satisfy rustfmt, collapsing a multi-line condition and a spawned future onto single lines. No test behaviour changed. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_ownership_tests.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index ea9e7b6..44b52f7 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -294,8 +294,7 @@ impl Interface for OwnershipVoice { } return Ok(json!({"Ok": null})); } - if values.first().and_then(Value::as_str) == Some(CAPTURE_ID) - && state.capture_open + if values.first().and_then(Value::as_str) == Some(CAPTURE_ID) && state.capture_open { state.capture_open = false; json!({"Ok": null}) @@ -969,9 +968,7 @@ async fn canceled_capture_start_does_not_leave_an_unowned_stream() { .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 - }); + let start_task = tokio::spawn(async move { start_owner.start().await }); tokio::time::timeout(Duration::from_secs(5), gate.entered.notified()) .await From 7dda000264b724c080f3971b62d9e01a1f4763f2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:37:50 +0530 Subject: [PATCH 51/67] test(tinyvoice-client): rework capture start fixture to validate reservations The ownership fixture now checks the capture reservation handle before blocking on the gate and returns the capture handle from the gated path instead of falling through to the generic method dispatch. This keeps the fixture's state transitions consistent with the real client's reservation flow. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 57 +++++++++++++------ 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 44b52f7..0e1697b 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -125,22 +125,32 @@ impl Interface for OwnershipVoice { async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { let method = member.as_str(); - let mut state = self - .state - .lock() - .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; - state.calls.push((method.to_owned(), args.clone())); - let values = args - .as_array() - .ok_or_else(|| tinybus::Error::failed("expected positional method arguments"))?; - if method == tinyvoice_bus::names::methods::CAPTURE_START { - if let Some(gate) = &self.capture_start_gate { - state.capture_pending = true; - drop(state); + if let Some(gate) = self.capture_start_gate.clone() { + 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"))? + .to_owned(); + { + let mut state = self + .state + .lock() + .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; + state.calls.push((method.to_owned(), args)); + if !state.capture_reserved || handle != CAPTURE_ID { + return Err(tinybus::Error::failed("unknown capture reservation")); + } + state.capture_pending = true; + } gate.entered.notify_one(); gate.release.notified().await; - state = self + let mut state = self .state .lock() .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; @@ -151,8 +161,26 @@ impl Interface for OwnershipVoice { gate.completed.notify_one(); return Ok(json!({"Err": {"code": "cancelled"}})); } + state.capture_pending = false; + state.capture_reserved = false; + state.capture_open = true; + gate.completed.notify_one(); + return Ok(json!({ + "Ok": { + "handle": CAPTURE_ID, + "format": {"source_rate": 48_000, "channels": 1} + } + })); } } + let mut state = self + .state + .lock() + .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; + state.calls.push((method.to_owned(), args.clone())); + let values = args + .as_array() + .ok_or_else(|| tinybus::Error::failed("expected positional method arguments"))?; let result = match method { tinyvoice_bus::names::methods::ROUTE => match self.peer { @@ -254,9 +282,6 @@ impl Interface for OwnershipVoice { state.capture_pending = false; state.capture_reserved = false; state.capture_open = true; - if let Some(gate) = &self.capture_start_gate { - gate.completed.notify_one(); - } json!({ "Ok": { "handle": CAPTURE_ID, From edd8a3532d40ae13bb66d09b53ec791865fc4943 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:37:57 +0530 Subject: [PATCH 52/67] test(tinyvoice-client): reformat ownership fixture lock error handling Rewrapped the poisoned-state error mapping in the ownership test fixture to satisfy formatting, with no change to behaviour. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_ownership_tests.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 0e1697b..7b6104a 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -138,10 +138,9 @@ impl Interface for OwnershipVoice { .ok_or_else(|| tinybus::Error::failed("missing capture reservation"))? .to_owned(); { - let mut state = self - .state - .lock() - .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; + let mut state = self.state.lock().map_err(|_| { + tinybus::Error::failed("ownership fixture state is poisoned") + })?; state.calls.push((method.to_owned(), args)); if !state.capture_reserved || handle != CAPTURE_ID { return Err(tinybus::Error::failed("unknown capture reservation")); From f6a5cdd4d74d4d36bd1ea8fdb2a932a8709f0b46 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:38:20 +0530 Subject: [PATCH 53/67] test(tinyvoice-client): cover capture stop in ownership fixture The ownership test fixture now handles the capture stop method, recording the call and resolving pending, stopped, or unknown handles so tests can exercise stop behaviour against the fake interface. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 7b6104a..0c7a20d 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -125,6 +125,48 @@ impl Interface for OwnershipVoice { async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { let method = member.as_str(); + if method == tinyvoice_bus::names::methods::CAPTURE_STOP { + enum StopAction { + Pending, + Stopped, + Unknown, + } + + let action = { + let mut state = self + .state + .lock() + .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; + state.calls.push((method.to_owned(), args.clone())); + if state.capture_pending { + state.capture_canceled = true; + StopAction::Pending + } else if args + .as_array() + .and_then(|values| values.first()) + .and_then(Value::as_str) + == Some(CAPTURE_ID) + && state.capture_open + { + state.capture_open = false; + StopAction::Stopped + } else { + StopAction::Unknown + } + }; + return match action { + StopAction::Pending => { + if let Some(gate) = &self.capture_start_gate { + gate.stop_entered.notify_one(); + gate.completed.notified().await; + } + Ok(json!({"Ok": null})) + } + StopAction::Stopped => Ok(json!({"Ok": null})), + StopAction::Unknown => Ok(json!({"Err": {"code": "unknown_handle"}})), + }; + } + if method == tinyvoice_bus::names::methods::CAPTURE_START { if let Some(gate) = self.capture_start_gate.clone() { let request = args From a216b7920a0ebe895041df30e427771e2a7ce5bc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:38:34 +0530 Subject: [PATCH 54/67] test(tinyvoice-client): drop capture stop handling from ownership test double The fake interface used by the ownership tests no longer handles the capture stop method, since the tests that exercised that path are gone and the stub was only kept around for them. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 0c7a20d..2b06dca 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -349,25 +349,6 @@ impl Interface for OwnershipVoice { }) } } - tinyvoice_bus::names::methods::CAPTURE_STOP => { - let gate = self.capture_start_gate.clone(); - if state.capture_pending { - state.capture_canceled = true; - drop(state); - if let Some(gate) = gate { - gate.stop_entered.notify_one(); - gate.completed.notified().await; - } - return Ok(json!({"Ok": null})); - } - if values.first().and_then(Value::as_str) == Some(CAPTURE_ID) && state.capture_open - { - state.capture_open = false; - json!({"Ok": null}) - } else { - json!({"Err": {"code": "unknown_handle"}}) - } - } tinyvoice_bus::names::methods::HOTKEY_RESERVE => { state.hotkey_open = true; json!({"Ok": HOTKEY_ID}) From 70e1f0ae123ee5c257812bd399ac556e771ecd99 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:39:45 +0530 Subject: [PATCH 55/67] test(tinyvoice-client): model capture and hotkey fixture state as enums Replace the loose boolean flags in the ownership test fixture with CaptureFixtureState and HotkeyFixtureState enums so impossible combinations can no longer be represented. This makes the fixture's lifecycle states explicit and keeps the tests aligned with the real state machine. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 2b06dca..cd1cb1e 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -86,12 +86,25 @@ struct PeerState { calls: Vec<(String, Value)>, vad_open: bool, recorders: HashMap, - capture_reserved: bool, - capture_pending: bool, - capture_canceled: bool, - capture_open: bool, - hotkey_open: bool, - hotkey_active: bool, + 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>; From b8931b04bea96e5aabfffd15ef2472aff3f324a7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:41:30 +0530 Subject: [PATCH 56/67] chore: files changed crates/tinyvoice-client/src/lib_ownership_tests.rs Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 62 ++++++++++++------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index cd1cb1e..7d782ef 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -151,17 +151,19 @@ impl Interface for OwnershipVoice { .lock() .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; state.calls.push((method.to_owned(), args.clone())); - if state.capture_pending { - state.capture_canceled = true; + if matches!(&state.capture, CaptureFixtureState::Starting { .. }) { + if let CaptureFixtureState::Starting { canceled } = &mut state.capture { + *canceled = true; + } StopAction::Pending } else if args .as_array() .and_then(|values| values.first()) .and_then(Value::as_str) == Some(CAPTURE_ID) - && state.capture_open + && matches!(&state.capture, CaptureFixtureState::Live) { - state.capture_open = false; + state.capture = CaptureFixtureState::Empty; StopAction::Stopped } else { StopAction::Unknown @@ -197,10 +199,12 @@ impl Interface for OwnershipVoice { tinybus::Error::failed("ownership fixture state is poisoned") })?; state.calls.push((method.to_owned(), args)); - if !state.capture_reserved || handle != CAPTURE_ID { + if !matches!(&state.capture, CaptureFixtureState::Reserved) + || handle != CAPTURE_ID + { return Err(tinybus::Error::failed("unknown capture reservation")); } - state.capture_pending = true; + state.capture = CaptureFixtureState::Starting { canceled: false }; } gate.entered.notify_one(); gate.release.notified().await; @@ -208,16 +212,22 @@ impl Interface for OwnershipVoice { .state .lock() .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; - if state.capture_canceled { - state.capture_pending = false; - state.capture_canceled = false; - state.capture_reserved = false; + if matches!( + &state.capture, + CaptureFixtureState::Starting { canceled: true } + ) { + state.capture = CaptureFixtureState::Empty; gate.completed.notify_one(); return Ok(json!({"Err": {"code": "cancelled"}})); } - state.capture_pending = false; - state.capture_reserved = false; - state.capture_open = true; + if !matches!( + &state.capture, + CaptureFixtureState::Starting { canceled: false } + ) && !matches!(&state.capture, CaptureFixtureState::Reserved) + { + return Err(tinybus::Error::failed("capture startup lost its reservation")); + } + state.capture = CaptureFixtureState::Live; gate.completed.notify_one(); return Ok(json!({ "Ok": { @@ -318,7 +328,7 @@ impl Interface for OwnershipVoice { Value::Null } tinyvoice_bus::names::methods::RESERVE_CAPTURE => { - state.capture_reserved = true; + state.capture = CaptureFixtureState::Reserved; json!({"Ok": CAPTURE_ID}) } tinyvoice_bus::names::methods::CAPTURE_START => { @@ -330,12 +340,12 @@ impl Interface for OwnershipVoice { .get("handle") .and_then(Value::as_str) .ok_or_else(|| tinybus::Error::failed("missing capture reservation"))?; - if !state.capture_reserved || handle != CAPTURE_ID { + if !matches!(&state.capture, CaptureFixtureState::Reserved) + || handle != CAPTURE_ID + { return Err(tinybus::Error::failed("unknown capture reservation")); } - state.capture_pending = false; - state.capture_reserved = false; - state.capture_open = true; + state.capture = CaptureFixtureState::Live; json!({ "Ok": { "handle": CAPTURE_ID, @@ -349,7 +359,7 @@ impl Interface for OwnershipVoice { .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) - || !state.capture_open + || !matches!(&state.capture, CaptureFixtureState::Live) { json!({"Err": {"code": "unknown_handle"}}) } else { @@ -363,11 +373,12 @@ impl Interface for OwnershipVoice { } } tinyvoice_bus::names::methods::HOTKEY_RESERVE => { - state.hotkey_open = true; + state.hotkey = HotkeyFixtureState::Reserved; 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 }; json!({ "Ok": { "state": "running", @@ -379,7 +390,7 @@ impl Interface for OwnershipVoice { } tinyvoice_bus::names::methods::HOTKEY_FEED => { ensure_hotkey_open(&state, values.first().and_then(Value::as_object))?; - state.hotkey_active = true; + state.hotkey = HotkeyFixtureState::Running { active: true }; json!({ "Ok": { "state": "running", @@ -396,7 +407,10 @@ impl Interface for OwnershipVoice { } else { "released" }; - let active = self.peer == Peer::B && state.hotkey_active; + let active = matches!( + &state.hotkey, + HotkeyFixtureState::Running { active: true } + ); json!({ "Ok": { "generation": 1, @@ -409,7 +423,7 @@ impl Interface for OwnershipVoice { } tinyvoice_bus::names::methods::HOTKEY_STOP => { ensure_hotkey_open(&state, values.first().and_then(Value::as_object))?; - state.hotkey_open = false; + state.hotkey = HotkeyFixtureState::Empty; json!({ "Ok": { "state": "stopped", @@ -453,7 +467,7 @@ fn ensure_hotkey_open( .and_then(|request| request.get("handle")) .and_then(Value::as_str) == Some(HOTKEY_ID) - && state.hotkey_open + && !matches!(&state.hotkey, HotkeyFixtureState::Empty) { Ok(()) } else { From ec58f2114ee4c4cc8d3f53b5b153ead1c719f0d9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:41:44 +0530 Subject: [PATCH 57/67] test(tinyvoice-client): reformat ownership test fixtures Reformatted the ownership test fixtures to satisfy rustfmt, wrapping enum variants and error strings and collapsing a few expressions that now fit on one line. No test behaviour changed. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 7d782ef..010c33d 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -95,7 +95,9 @@ enum CaptureFixtureState { #[default] Empty, Reserved, - Starting { canceled: bool }, + Starting { + canceled: bool, + }, Live, } @@ -104,7 +106,9 @@ enum HotkeyFixtureState { #[default] Empty, Reserved, - Running { active: bool }, + Running { + active: bool, + }, } type SharedPeerState = Arc>; @@ -225,7 +229,9 @@ impl Interface for OwnershipVoice { CaptureFixtureState::Starting { canceled: false } ) && !matches!(&state.capture, CaptureFixtureState::Reserved) { - return Err(tinybus::Error::failed("capture startup lost its reservation")); + return Err(tinybus::Error::failed( + "capture startup lost its reservation", + )); } state.capture = CaptureFixtureState::Live; gate.completed.notify_one(); @@ -340,8 +346,7 @@ impl Interface for OwnershipVoice { .get("handle") .and_then(Value::as_str) .ok_or_else(|| tinybus::Error::failed("missing capture reservation"))?; - if !matches!(&state.capture, CaptureFixtureState::Reserved) - || handle != CAPTURE_ID + if !matches!(&state.capture, CaptureFixtureState::Reserved) || handle != CAPTURE_ID { return Err(tinybus::Error::failed("unknown capture reservation")); } @@ -407,10 +412,7 @@ impl Interface for OwnershipVoice { } else { "released" }; - let active = matches!( - &state.hotkey, - HotkeyFixtureState::Running { active: true } - ); + let active = matches!(&state.hotkey, HotkeyFixtureState::Running { active: true }); json!({ "Ok": { "generation": 1, From f118a805a0f83a41a2e7888cbfc924b0c5ab0d01 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:42:10 +0530 Subject: [PATCH 58/67] test(tinyvoice-client): scope hotkey active state to peer b The ownership test fixture now reports the hotkey as active only when the requesting peer is B, matching the behaviour the test is meant to exercise. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_ownership_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 010c33d..a5a674c 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -412,7 +412,8 @@ impl Interface for OwnershipVoice { } else { "released" }; - let active = matches!(&state.hotkey, HotkeyFixtureState::Running { active: true }); + let active = self.peer == Peer::B + && matches!(&state.hotkey, HotkeyFixtureState::Running { active: true }); json!({ "Ok": { "generation": 1, From d070de1c9cc7bed485f781a4dee3d9e9bcf235bb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:42:57 +0530 Subject: [PATCH 59/67] refactor(tinyvoice-client): split ownership fixture dispatch into per-domain handlers The ownership test fixture's single large call implementation is now decomposed into separate VAD, recorder, capture, and hotkey handlers dispatched by method name, with capture start and stop kept async. This keeps each fixture domain's state transitions isolated and makes the dispatch table easier to extend. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 411 +++++++++--------- 1 file changed, 214 insertions(+), 197 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index a5a674c..41c84f8 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -127,139 +127,24 @@ struct OwnershipVoice { capture_start_gate: Option>, } -#[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(); - if method == tinyvoice_bus::names::methods::CAPTURE_STOP { - enum StopAction { - Pending, - Stopped, - Unknown, - } - - let action = { - let mut state = self - .state - .lock() - .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; - state.calls.push((method.to_owned(), args.clone())); - if matches!(&state.capture, CaptureFixtureState::Starting { .. }) { - if let CaptureFixtureState::Starting { canceled } = &mut state.capture { - *canceled = true; - } - StopAction::Pending - } else if args - .as_array() - .and_then(|values| values.first()) - .and_then(Value::as_str) - == Some(CAPTURE_ID) - && matches!(&state.capture, CaptureFixtureState::Live) - { - state.capture = CaptureFixtureState::Empty; - StopAction::Stopped - } else { - StopAction::Unknown - } - }; - return match action { - StopAction::Pending => { - if let Some(gate) = &self.capture_start_gate { - gate.stop_entered.notify_one(); - gate.completed.notified().await; - } - Ok(json!({"Ok": null})) - } - StopAction::Stopped => Ok(json!({"Ok": null})), - StopAction::Unknown => Ok(json!({"Err": {"code": "unknown_handle"}})), - }; - } - - if method == tinyvoice_bus::names::methods::CAPTURE_START { - if let Some(gate) = self.capture_start_gate.clone() { - 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"))? - .to_owned(); - { - let mut state = self.state.lock().map_err(|_| { - tinybus::Error::failed("ownership fixture state is poisoned") - })?; - state.calls.push((method.to_owned(), args)); - if !matches!(&state.capture, CaptureFixtureState::Reserved) - || handle != CAPTURE_ID - { - return Err(tinybus::Error::failed("unknown capture reservation")); - } - state.capture = CaptureFixtureState::Starting { canceled: false }; - } - 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 } - ) && !matches!(&state.capture, CaptureFixtureState::Reserved) - { - return Err(tinybus::Error::failed( - "capture startup lost its reservation", - )); - } - state.capture = CaptureFixtureState::Live; - gate.completed.notify_one(); - return Ok(json!({ - "Ok": { - "handle": CAPTURE_ID, - "format": {"source_rate": 48_000, "channels": 1} - } - })); - } - } +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())); - let values = args - .as_array() - .ok_or_else(|| tinybus::Error::failed("expected positional method arguments"))?; - - let result = match method { - tinyvoice_bus::names::methods::ROUTE => match self.peer { + state.calls.push((method.to_owned(), args)); + 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; - json!(VAD_ID) + Ok(json!(VAD_ID)) } tinyvoice_bus::names::methods::VAD_PUSH => { let id = positional_id(values)?; @@ -270,24 +155,38 @@ impl Interface for OwnershipVoice { r#"[{"frame":0,"kind":"speech_end","voiced_ms":20,"emit":true,"forced":false}]"# } }; - json!(events) + Ok(json!(events)) } tinyvoice_bus::names::methods::VAD_IS_SPEAKING => { let id = positional_id(values)?; ensure_vad_open(&state, id)?; - json!(self.peer == Peer::B) + Ok(json!(self.peer == Peer::B)) } tinyvoice_bus::names::methods::VAD_RESET => { let id = positional_id(values)?; ensure_vad_open(&state, id)?; - Value::Null + Ok(Value::Null) } tinyvoice_bus::names::methods::VAD_CLOSE => { let id = positional_id(values)?; ensure_vad_open(&state, id)?; state.vad_open = false; - Value::Null + 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)); + match method { tinyvoice_bus::names::methods::SESSION_OPEN => { state.recorders.insert( RECORDER_ID, @@ -296,7 +195,7 @@ impl Interface for OwnershipVoice { generation: 0, }, ); - json!(RECORDER_ID) + Ok(json!(RECORDER_ID)) } tinyvoice_bus::names::methods::SESSION_ADVANCE => { let id = positional_id(values)?; @@ -313,7 +212,7 @@ impl Interface for OwnershipVoice { let reply = advance_fixture_session(status, input); let encoded = serde_json::to_string(&reply) .map_err(|_| tinybus::Error::failed("could not encode recorder reply"))?; - json!(encoded) + Ok(json!(encoded)) } tinyvoice_bus::names::methods::SESSION_STATUS => { let id = positional_id(values)?; @@ -323,7 +222,7 @@ impl Interface for OwnershipVoice { .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"))?; - json!(encoded) + Ok(json!(encoded)) } tinyvoice_bus::names::methods::SESSION_CLOSE => { let id = positional_id(values)?; @@ -331,32 +230,25 @@ impl Interface for OwnershipVoice { .recorders .remove(&id) .ok_or_else(|| tinybus::Error::failed("unknown recorder id"))?; - Value::Null + 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)); + match method { tinyvoice_bus::names::methods::RESERVE_CAPTURE => { state.capture = CaptureFixtureState::Reserved; - json!({"Ok": CAPTURE_ID}) - } - tinyvoice_bus::names::methods::CAPTURE_START => { - let request = 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"))?; - if !matches!(&state.capture, CaptureFixtureState::Reserved) || handle != CAPTURE_ID - { - return Err(tinybus::Error::failed("unknown capture reservation")); - } - state.capture = CaptureFixtureState::Live; - json!({ - "Ok": { - "handle": CAPTURE_ID, - "format": {"source_rate": 48_000, "channels": 1} - } - }) + Ok(json!({"Ok": CAPTURE_ID})) } tinyvoice_bus::names::methods::CAPTURE_POLL => { let request = values @@ -366,55 +258,151 @@ impl Interface for OwnershipVoice { if request.get("handle").and_then(Value::as_str) != Some(CAPTURE_ID) || !matches!(&state.capture, CaptureFixtureState::Live) { - json!({"Err": {"code": "unknown_handle"}}) + Ok(json!({"Err": {"code": "unknown_handle"}})) } else { let sample = if self.peer == Peer::A { 0.25 } else { 0.75 }; - json!({ + 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, + )); + if !matches!(&state.capture, CaptureFixtureState::Reserved) || handle != CAPTURE_ID { + return Err(tinybus::Error::failed("unknown capture reservation")); + } + 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, + )); + 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) { + state.capture = CaptureFixtureState::Empty; + false + } else { + return Ok(json!({"Err": {"code": "unknown_handle"}})); } + }; + if pending { + if 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)); + match method { tinyvoice_bus::names::methods::HOTKEY_RESERVE => { state.hotkey = HotkeyFixtureState::Reserved; - json!({"Ok": HOTKEY_ID}) + 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 }; - json!({ - "Ok": { - "state": "running", - "generation": 1, - "active": false, - "continuity_lost": 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 }; - json!({ - "Ok": { - "state": "running", - "generation": 1, - "active": true, - "continuity_lost": false - } - }) + Ok(hotkey_status_json("running", true)) } tinyvoice_bus::names::methods::HOTKEY_READ => { ensure_hotkey_open(&state, values.first().and_then(Value::as_object))?; - let event = if self.peer == Peer::A { - "pressed" - } else { - "released" - }; let active = self.peer == Peer::B && matches!(&state.hotkey, HotkeyFixtureState::Running { active: true }); - json!({ + let event = if self.peer == Peer::A { "pressed" } else { "released" }; + Ok(json!({ "Ok": { "generation": 1, "batch": 1, @@ -422,28 +410,57 @@ impl Interface for OwnershipVoice { "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; - json!({ - "Ok": { - "state": "stopped", - "generation": 1, - "active": false, - "continuity_lost": false - } - }) + Ok(hotkey_status_json("stopped", false)) } - _ => { - return Err(tinybus::Error::failed( - "unexpected ownership fixture method", - )); - } - }; + _ => Err(tinybus::Error::failed("unexpected hotkey fixture method")), + } + } +} + +#[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() + } - Ok(result) + 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")), + } } } From efc9d98bf121d17df7af3c42cc0b79ec50b09b90 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:43:30 +0530 Subject: [PATCH 60/67] test(tinyvoice-client): add hotkey status fixture helper and satisfy clippy Extracted a shared helper for building hotkey status responses in the ownership fixture and applied rustfmt plus clippy allowances so the alias-handover regression tests pass linting without changing their behaviour. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 41c84f8..50296ab 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -1,4 +1,4 @@ -//! Alias-handover regression tests for owned TinyVoice session projections. +//! Alias-handover regression tests for owned `TinyVoice` session projections. #![allow(clippy::expect_used, clippy::unwrap_used)] @@ -321,7 +321,9 @@ impl OwnershipVoice { &state.capture, CaptureFixtureState::Starting { canceled: false } ) { - return Err(tinybus::Error::failed("capture startup lost its reservation")); + return Err(tinybus::Error::failed( + "capture startup lost its reservation", + )); } state.capture = CaptureFixtureState::Live; gate.completed.notify_one(); @@ -345,10 +347,9 @@ impl OwnershipVoice { .state .lock() .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; - state.calls.push(( - tinyvoice_bus::names::methods::CAPTURE_STOP.to_owned(), - args, - )); + state + .calls + .push((tinyvoice_bus::names::methods::CAPTURE_STOP.to_owned(), args)); if handle != Some(CAPTURE_ID) { return Ok(json!({"Err": {"code": "unknown_handle"}})); } @@ -401,7 +402,11 @@ impl OwnershipVoice { 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" }; + let event = if self.peer == Peer::A { + "pressed" + } else { + "released" + }; Ok(json!({ "Ok": { "generation": 1, @@ -422,6 +427,17 @@ impl OwnershipVoice { } } +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 { @@ -459,7 +475,9 @@ impl Interface for OwnershipVoice { | 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")), + _ => Err(tinybus::Error::failed( + "unexpected ownership fixture method", + )), } } } @@ -763,6 +781,7 @@ async fn vad_session_stays_with_its_creator_across_a_live_public_alias_handover( } #[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 @@ -1100,6 +1119,7 @@ async fn canceled_capture_start_does_not_leave_an_unowned_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 @@ -1274,6 +1294,7 @@ async fn owned_hotkey_session_stays_with_its_creator_across_alias_handover() { } #[tokio::test] +#[allow(clippy::too_many_lines)] async fn owned_resources_fail_closed_after_their_peer_detaches() { let fixture = handoff_fixture() .await From 985ea6811c65e5ae0e42c36a2592c6680b8f7b3e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:43:45 +0530 Subject: [PATCH 61/67] test(tinyvoice-client): clone recorded call args in ownership fixture The ownership test fixture now clones the argument value before pushing it onto the recorded call list, so the original args remain available for the match arms that follow. This keeps the fixture compiling and preserves the recorded calls without moving the value out from under later use. Auto-committed-on: macbook --- .../tinyvoice-client/src/lib_ownership_tests.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 50296ab..bf39a04 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -136,7 +136,7 @@ impl OwnershipVoice { .state .lock() .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; - state.calls.push((method.to_owned(), args)); + 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"}"#), @@ -185,7 +185,7 @@ impl OwnershipVoice { .state .lock() .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; - state.calls.push((method.to_owned(), args)); + state.calls.push((method.to_owned(), args.clone())); match method { tinyvoice_bus::names::methods::SESSION_OPEN => { state.recorders.insert( @@ -244,7 +244,7 @@ impl OwnershipVoice { .state .lock() .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; - state.calls.push((method.to_owned(), args)); + state.calls.push((method.to_owned(), args.clone())); match method { tinyvoice_bus::names::methods::RESERVE_CAPTURE => { state.capture = CaptureFixtureState::Reserved; @@ -291,7 +291,7 @@ impl OwnershipVoice { .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; state.calls.push(( tinyvoice_bus::names::methods::CAPTURE_START.to_owned(), - args, + args.clone(), )); if !matches!(&state.capture, CaptureFixtureState::Reserved) || handle != CAPTURE_ID { return Err(tinybus::Error::failed("unknown capture reservation")); @@ -347,9 +347,10 @@ impl OwnershipVoice { .state .lock() .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; - state - .calls - .push((tinyvoice_bus::names::methods::CAPTURE_STOP.to_owned(), args)); + 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"}})); } @@ -382,7 +383,7 @@ impl OwnershipVoice { .state .lock() .map_err(|_| tinybus::Error::failed("ownership fixture state is poisoned"))?; - state.calls.push((method.to_owned(), args)); + state.calls.push((method.to_owned(), args.clone())); match method { tinyvoice_bus::names::methods::HOTKEY_RESERVE => { state.hotkey = HotkeyFixtureState::Reserved; From 01aa72be30fa08421285898c44eab8d37e2c9e55 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:44:30 +0530 Subject: [PATCH 62/67] test(tinyvoice-client): borrow method args in ownership fixture The ownership test fixture now passes method arguments by reference instead of by value, and the nested pending/gate check in the capture stop handler is collapsed into a single let-chain. This avoids unnecessary clones in the fixture and keeps the test code aligned with current idioms. Auto-committed-on: macbook --- .../src/lib_ownership_tests.rs | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index bf39a04..3cd30b1 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -128,7 +128,7 @@ struct OwnershipVoice { } impl OwnershipVoice { - fn call_vad(&self, method: &str, args: Value) -> tinybus::Result { + 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"))?; @@ -177,7 +177,7 @@ impl OwnershipVoice { } } - fn call_recorder(&self, method: &str, args: Value) -> tinybus::Result { + 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"))?; @@ -236,7 +236,7 @@ impl OwnershipVoice { } } - fn call_capture_request(&self, method: &str, args: Value) -> tinybus::Result { + 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"))?; @@ -366,16 +366,14 @@ impl OwnershipVoice { return Ok(json!({"Err": {"code": "unknown_handle"}})); } }; - if pending { - if let Some(gate) = gate { - gate.stop_entered.notify_one(); - gate.completed.notified().await; - } + 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 { + 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"))?; @@ -460,14 +458,14 @@ impl Interface for OwnershipVoice { | 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::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::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) + 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, @@ -475,7 +473,7 @@ impl Interface for OwnershipVoice { | 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), + | tinyvoice_bus::names::methods::HOTKEY_STOP => self.call_hotkey(method, &args), _ => Err(tinybus::Error::failed( "unexpected ownership fixture method", )), From 62258a773def17d5119e0b518f44924ebc7bff51 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 17:13:21 +0530 Subject: [PATCH 63/67] fix(tinyvoice-client): release unstarted capture reservations on close Closing an owned capture reservation before start now releases the handle on the pinned peer instead of leaving it to expire, and the docs and ownership tests were updated to describe and cover that behaviour. Auto-committed-on: macbook --- crates/tinyvoice-client/README.md | 5 ++- crates/tinyvoice-client/src/lib.rs | 7 +-- .../src/lib_ownership_tests.rs | 44 ++++++++++++++++++- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/crates/tinyvoice-client/README.md b/crates/tinyvoice-client/README.md index b594923..7736732 100644 --- a/crates/tinyvoice-client/README.md +++ b/crates/tinyvoice-client/README.md @@ -27,8 +27,9 @@ 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 still follows the module's existing expiry behavior because the bus -contract has no separate reservation-release method. The flat +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. diff --git a/crates/tinyvoice-client/src/lib.rs b/crates/tinyvoice-client/src/lib.rs index ad41006..6c46dae 100644 --- a/crates/tinyvoice-client/src/lib.rs +++ b/crates/tinyvoice-client/src/lib.rs @@ -752,10 +752,11 @@ impl OwnedCaptureReservation { } } - /// Cancel pending startup or stop a published stream on its pinned peer. + /// Release this known reservation, cancel pending startup, or stop its stream. /// - /// If startup has not begun, the module may report [`CaptureError::UnknownHandle`]; - /// reservations expire in the module according to its existing contract. + /// 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 /// diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 3cd30b1..2490726 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -294,7 +294,7 @@ impl OwnershipVoice { args.clone(), )); if !matches!(&state.capture, CaptureFixtureState::Reserved) || handle != CAPTURE_ID { - return Err(tinybus::Error::failed("unknown capture reservation")); + return Ok(json!({"Err": {"code": "unknown_handle"}})); } state.capture = if gate.is_some() { CaptureFixtureState::Starting { canceled: false } @@ -362,6 +362,9 @@ impl OwnershipVoice { } else if matches!(&state.capture, CaptureFixtureState::Live) { state.capture = CaptureFixtureState::Empty; false + } else if matches!(&state.capture, CaptureFixtureState::Reserved) { + state.capture = CaptureFixtureState::Empty; + false } else { return Ok(json!({"Err": {"code": "unknown_handle"}})); } @@ -998,6 +1001,45 @@ async fn owned_capture_stream_stays_with_its_creator_across_alias_handover() { ); } +#[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()); From 63bedf3b60afc5d9674ab5a5f2e056fb7a46d522 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 17:13:29 +0530 Subject: [PATCH 64/67] docs(tinyvoice-client): update dependency status in readme The readme now states that the vendored gitlink and both Cargo lockfiles resolve to the published TinyBus 0.3.2 source, replacing the note that the lockfiles still needed refreshing. Auto-committed-on: macbook --- crates/tinyvoice-client/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tinyvoice-client/README.md b/crates/tinyvoice-client/README.md index 7736732..5d365c0 100644 --- a/crates/tinyvoice-client/README.md +++ b/crates/tinyvoice-client/README.md @@ -46,5 +46,4 @@ 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 pins that source. The original Cargo lockfiles still need -refreshing under the authorized Cargo validation slot. +the vendored gitlink and both Cargo lockfiles resolve to that source. From bf799a9be604c734782df5f0cac68c25da6d9354 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 17:33:33 +0530 Subject: [PATCH 65/67] test(tinyvoice-client): merge duplicate capture reset branches in ownership fixture Collapsed the separate Live and Reserved arms into a single match guard since both reset the capture state to Empty and return false, removing the duplicated logic without changing behaviour. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_ownership_tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_ownership_tests.rs b/crates/tinyvoice-client/src/lib_ownership_tests.rs index 2490726..fac5d22 100644 --- a/crates/tinyvoice-client/src/lib_ownership_tests.rs +++ b/crates/tinyvoice-client/src/lib_ownership_tests.rs @@ -359,10 +359,10 @@ impl OwnershipVoice { *canceled = true; } true - } else if matches!(&state.capture, CaptureFixtureState::Live) { - state.capture = CaptureFixtureState::Empty; - false - } else if matches!(&state.capture, CaptureFixtureState::Reserved) { + } else if matches!( + &state.capture, + CaptureFixtureState::Live | CaptureFixtureState::Reserved + ) { state.capture = CaptureFixtureState::Empty; false } else { From 16b438e5d5ee47ead4d951a74271bd35f0a0b1d7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 17:35:21 +0530 Subject: [PATCH 66/67] test(service): use from_secs in prepare test The prepare test now expresses the tool timeout as one second via Duration::from_secs instead of an equivalent millisecond value, and a redundant clippy expectation for unused async trait impls was dropped since the lint no longer fires at this boundary. Auto-committed-on: macbook --- crates/tinyvoice-module/src/service/live/prepare_tests.rs | 2 +- crates/tinyvoice-module/src/service/mod.rs | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) 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..71be2de 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -198,10 +198,6 @@ fn to_json(value: &T) -> TinyBusResult { clippy::unused_async, reason = "TinyBus interface methods require async signatures" )] -#[expect( - clippy::unused_async_trait_impl, - reason = "TinyBus interface methods must retain async signatures at this dynamic module boundary" -)] #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.Voice")] impl VoiceService { /// Fix explicit host scheduling bounds for this module instance. From ed9d31a4e6ad96b07cf8ddb0acf6c93e70ee94e1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 17:48:01 +0530 Subject: [PATCH 67/67] test(client): assert empty collections by value Replace the emptiness checks in the VAD and capture/hotkey wrapper tests with equality assertions against explicitly typed empty vectors, so the expected element type is pinned down. Also allow the newer unused_async_trait_impl lint on the VoiceService interface, which is unknown to the crate's Rust 1.88 MSRV. Auto-committed-on: macbook --- crates/tinyvoice-client/src/lib_tests.rs | 17 ++++++++++------- crates/tinyvoice-module/src/service/mod.rs | 6 ++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/tinyvoice-client/src/lib_tests.rs b/crates/tinyvoice-client/src/lib_tests.rs index d8e2246..09ddbe2 100644 --- a/crates/tinyvoice-client/src/lib_tests.rs +++ b/crates/tinyvoice-client/src/lib_tests.rs @@ -338,7 +338,10 @@ async fn vad_and_recorder_handles_route_each_operation_and_close_only_explicitly let vad_config = tinyvoice_bus::VadConfig::default(); let vad = VadSession::open(&client, vad_config).await?; assert!(!format!("{vad:?}").contains("41")); - assert!(context("VAD push", vad.push(20, &[0.2, 0.0]).await)?.is_empty()); + 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; @@ -466,13 +469,13 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors handle: stream.handle.clone(), max_chunks: 2, }; - assert!( + assert_eq!( client .poll_capture(poll.clone()) .await? .expect("poll") - .chunks - .is_empty() + .chunks, + Vec::::new() ); client .stop_capture(stream.handle.clone()) @@ -497,13 +500,13 @@ async fn capture_and_hotkey_wrappers_preserve_typed_requests_and_business_errors handle: handle.clone(), acknowledged_batch: None, }; - assert!( + assert_eq!( client .read_hotkey(read.clone()) .await? .expect("hotkey read") - .events - .is_empty() + .events, + Vec::::new() ); let feed = tinyvoice_bus::HotkeyFeedRequest { handle: handle.clone(), diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index 71be2de..8be0af6 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -198,6 +198,12 @@ 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" +)] #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.Voice")] impl VoiceService { /// Fix explicit host scheduling bounds for this module instance.