Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e96e9de
feat(bus): own native recording and bounded audio outputs
senamakel Oct 10, 2026
39ead47
feat(capture): own bounded continuous streams and shutdown
senamakel Oct 10, 2026
570735a
fix: retain native capture ownership through cancellation and shutdown
senamakel Oct 10, 2026
ee09645
Add hotkey lifecycle bus contract
senamakel Oct 10, 2026
7ac8f6c
Own native hotkey lifecycle in module
senamakel Oct 10, 2026
5276937
Document module hotkey lifecycle
senamakel Oct 10, 2026
2f8e150
Preserve hotkey batch ack across reset
senamakel Oct 10, 2026
78c43a5
Share hotkey vocabulary with the bus
senamakel Oct 10, 2026
c22a867
Recover ended Windows hotkey owners
senamakel Oct 10, 2026
b007cc6
docs(tinyvoice): document module-owned capture and hotkey lifecycles
senamakel Oct 10, 2026
ba3cee8
test(tinyvoice): validate formatted hotkey lifecycle coverage
senamakel Oct 10, 2026
3fc932b
fix(module): satisfy stable clippy for module workspace
senamakel Oct 10, 2026
b2e43a6
fix(tinyvoice): harden hotkey replay and startup races
senamakel Oct 10, 2026
649756c
fix(tinyvoice): wait for native hotkey startup retries
senamakel Oct 10, 2026
294e348
test(tinyvoice): clarify tap repeat semantics
senamakel Oct 10, 2026
82d92da
fix(tinyvoice): close capture and native hotkey gaps
senamakel Oct 10, 2026
1ed9aca
fix(windows): register hook from its owning module
senamakel Oct 10, 2026
20b930c
fix(tinyvoice): verify platform hotkey lifecycle gates
senamakel Oct 10, 2026
887dc4a
fix(windows): reap failed hook cleanup off the drop path
senamakel Oct 10, 2026
77bd00b
fix: preserve hotkey feed continuity and shutdown cleanup
senamakel Oct 11, 2026
d64ed33
fix: clean up lost XRecord contexts and map navigation keys
senamakel Oct 11, 2026
5b98a8b
docs: list TinyVoice hotkey bus methods
senamakel Oct 11, 2026
5c5bfca
fix: reset hotkey state at every sequence discontinuity
senamakel Oct 11, 2026
40fad03
fix: retain uncertain XRecord cleanup for retry
senamakel Oct 11, 2026
02f6a27
style: format hotkey regression tests
senamakel Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,25 @@ jobs:
path: coverage.json
if-no-files-found: ignore

windows-hotkeys:
name: Windows hotkey ownership
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
submodules: true

- uses: dtolnay/rust-toolchain@stable

- uses: Swatinem/rust-cache@v2

- name: Test module hotkey lifecycle on Windows
run: cargo test --locked --manifest-path crates/tinyvoice-module/Cargo.toml --all-features

- name: Test native Windows hook owner
run: cargo test --locked --manifest-path crates/tinyvoice-hotkey-win/Cargo.toml

docs:
name: Docs
runs-on: ubuntu-latest
Expand Down
26 changes: 24 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,10 @@ jobs:
actual_target="$(rustc -vV | sed -n 's/^host: //p')"
[[ "$actual_target" == "$EXPECTED_TARGET" ]]

- name: Install native capture development libraries
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install --yes libasound2-dev

- name: Build installable module
run: cargo build --manifest-path crates/tinyvoice-module/Cargo.toml --locked --release --package tinyvoice-module

Expand All @@ -289,7 +293,11 @@ jobs:
package_root="dist/${package_name}"
mkdir -p "$package_root"
install -m 755 "$module" "$package_root/"
install -m 644 LICENSE README.md docs/specs/tinybus-module.md "$package_root/"
install -m 644 LICENSE README.md docs/specs/tinybus-module.md \
docs/specs/module-capture.md docs/specs/hotkey-module-lifecycle.md \
"$package_root/"
test -s "$package_root/module-capture.md"
test -s "$package_root/hotkey-module-lifecycle.md"
module_name="$(basename "$module")"
module_hash="$(sha256sum "$package_root/$module_name" | awk '{print $1}')"
printf '"%s" = "%s"\n' "$module_name" "$module_hash" \
Expand All @@ -311,7 +319,21 @@ jobs:
$packageName = "tinyvoice-module-$env:VERSION-$env:BUNDLE_ID"
$packageRoot = "dist/$packageName"
New-Item -ItemType Directory -Force $packageRoot | Out-Null
Copy-Item -LiteralPath $module, 'LICENSE', 'README.md' -Destination $packageRoot
$packageFiles = @(
$module
'LICENSE'
'README.md'
'docs/specs/tinybus-module.md'
'docs/specs/module-capture.md'
'docs/specs/hotkey-module-lifecycle.md'
)
Copy-Item -LiteralPath $packageFiles -Destination $packageRoot
if (-not (Test-Path -LiteralPath "$packageRoot/module-capture.md" -PathType Leaf)) {
throw 'The package is missing module-capture.md.'
}
if (-not (Test-Path -LiteralPath "$packageRoot/hotkey-module-lifecycle.md" -PathType Leaf)) {
throw 'The package is missing hotkey-module-lifecycle.md.'
}
$hash = (Get-FileHash -LiteralPath $module -Algorithm SHA256).Hash.ToLowerInvariant()
$moduleName = Split-Path -Leaf $module
"`"$moduleName`" = `"$hash`"`n" |
Expand Down
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ which decides where the *next* piece of code goes:
> **A crate owns what is identical for every host; the host owns what depends
> on its own runtime, config, or threat model.**

`tinyvoice` is therefore synchronous, I/O-free and runtime-free. Before adding
anything here, check it against that rule. Device capture, STT/TTS transport,
hotkeys, credentials, and config shapes all fail it and belong to the host.
`tinyvoice` keeps its pure primitives synchronous and runtime-free. The module
owns native capture and its stoppable hotkey lifecycle. The library retains an
optional reusable native listener API for standalone hosts; hosts that need
module-managed lifetimes use the minimal bus contract. Credentials, permission
decisions (through the computer module), product configuration and STT/TTS
orchestration stay with the host.

## Project Structure

Expand Down
27 changes: 26 additions & 1 deletion MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This package contains the native `tinyvoice` module for TinyBus module ABI v1.
Install only the archive matching the host operating system and architecture.

The module claims `ai.tinyhumans.tinyvoice.Voice`, serves the object at
`/ai/tinyhumans/tinyvoice/Voice`, and provides seven methods.
`/ai/tinyhumans/tinyvoice/Voice`, and provides 32 methods under contract 1.4.

## Methods

Expand All @@ -25,6 +25,23 @@ The module claims `ai.tinyhumans.tinyvoice.Voice`, serves the object at
| `EncodeWav` | `samples` (`f32` mono), `sample_rate` | base64 WAV |
| `EncodeWavPcm16` | `samples` (`i16`), `sample_rate`, `channels` | base64 WAV, samples unchanged |
| `PrepareCapture` | `samples`, `source_rate`, `channels`, `gate_threshold` | base64 WAV |
| `ListInputDevices` | none | structured result of device names |
| `CaptureShutdown` | none | await native shutdown, terminal for this instance |
| `ReserveCapture` | permission request | known reservation handle |
| `RecordingStart` | permission and reserved handle | opaque recording handle |
| `RecordingFinish` | handle, gate threshold in one request | bounded WAV output handle and length |
| `RecordingCancel` | handle | structured result of unit |
| `ReadAudioOutput` | handle, offset, length in one request | base64 WAV slice |
| `ReleaseAudioOutput` | handle | structured result of unit |
| `CaptureStart` | permission and reserved handle | opaque stream handle and native format |
| `CapturePoll` | handle, max_chunks in one request | ordered raw chunks and closed flag |
| `CaptureStop` | handle | structured result after native thread shutdown |
| `HotkeyReserve` | bounded `HotkeyRequest` | opaque `HotkeyHandle` ([lifecycle](docs/specs/hotkey-module-lifecycle.md)) |
| `HotkeyStart` | reserved handle | status and host-feed generation ([lifecycle](docs/specs/hotkey-module-lifecycle.md)) |
| `HotkeyRead` | handle and optional acknowledged batch | bounded replayable event batch ([lifecycle](docs/specs/hotkey-module-lifecycle.md)) |
| `HotkeyFeed` | handle, generation, sequence, key facts, and overflow flag | feed status ([lifecycle](docs/specs/hotkey-module-lifecycle.md)) |
| `HotkeyStop` | handle | status after listener cleanup ([lifecycle](docs/specs/hotkey-module-lifecycle.md)) |
| `HotkeyShutdown` | none | terminal cleanup status ([lifecycle](docs/specs/hotkey-module-lifecycle.md)) |

Notes on the contract:

Expand Down Expand Up @@ -52,6 +69,14 @@ Notes on the contract:
**not** an error, so teardown cannot itself fail.
- `VadPush` frame indices are relative to *that call*, not a running total.

Native capture operations return `CaptureResult` values rather than transport
errors. They require an explicit microphone grant from the host; denial never
opens a device. One active recording or stream owns the microphone slot.
Continuous polling returns at most two chunks from an eight-chunk queue, each
containing at most 32,768 interleaved f32 samples. Call `CaptureStop` even after
the queue closes to release the lease and observe terminal device errors. See
[the capture specification](docs/specs/module-capture.md) for all resource bounds.

## Installing

The archive contains one `.so`, `.dylib`, or `.dll` plus `modules.toml`. Keep
Expand Down
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ permission check.

| Here | With the host |
| --- | --- |
| WAV framing, RMS, resampling, downmix, silence gate | Device capture (`cpal`) — a stream is `!Send` and needs the host's thread and permission model |
| WAV framing, RMS, resampling, downmix, silence gate; module-owned capture threads | Microphone permission decisions through the computer module |
| VAD segmentation | The capture loop that drives it |
| Wake-word gate, intent routing | What to *do* with an intent |
| Hallucination detection | The STT transport, credentials, and retry policy |
Expand Down Expand Up @@ -64,8 +64,8 @@ Run it: `cargo run -p tinyvoice --example basic`.
`ActivationMode`, `HotkeyEvent`). Off by default so the library, and the
loadable module built from it, carry no OS input hook.
- `capture` (off by default): `tinyvoice::capture`, microphone input on `cpal`:
`start_recording` for a one-shot recording and `spawn_capture_thread` for a
continuous chunk stream, both returning the device's raw samples for
`start_recording` for a one-shot recording and `start_capture_stream` for a
continuous chunk stream with a stoppable thread handle, both returning the device's raw samples for
`tinyvoice::audio` to process. Microphone permission is a `PermissionCheck`
the host passes in. Off by default because it links the platform audio stack.

Expand All @@ -85,10 +85,10 @@ session methods (`VadOpen` / `VadPush` / `VadReset` / `VadClose`) exist for it.
An earlier version of this README claimed otherwise, on an assumption rather
than a measurement.

The one thing that should stay on the host's side is whatever runs **inside the
audio callback**: `cpal` delivers on a realtime thread where blocking is a
dropout. Forward raw interleaved samples out of the callback and call
`PrepareFrames` from a worker — less work in the callback, not more.
The compiled module owns native capture threads and callbacks. `cpal` delivers
on a realtime thread where blocking causes dropouts, so callbacks forward raw
interleaved samples into a bounded queue. Hosts poll batches and call
`PrepareFrames` from a worker.

## Layout

Expand Down Expand Up @@ -122,3 +122,12 @@ CI additionally requires 90% line coverage in every source file and a clean
## License

GPL-3.0-only. See [`LICENSE`](LICENSE).

## Module-owned recording

Contract 1.3 adds device enumeration, reserved startup/cancellation, one-shot recording and continuous capture through opaque
handles. The host supplies a microphone permission grant obtained through
TinyComputer; the compiled module owns the device, audio preparation, bounded
WAV reads, bounded raw chunk polling, cancellation and release. See [capture contract](docs/specs/module-capture.md).
Module-owned hotkeys are specified in [hotkey lifecycle](docs/specs/hotkey-module-lifecycle.md); the optional
`tinyvoice::hotkey` listener remains available to library hosts.
117 changes: 117 additions & 0 deletions crates/tinyvoice-bus/src/capture/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
//! Native capture lifecycle vocabulary; no devices or audio processing live here.
use serde::{Deserialize, Serialize};
/// Permission decision obtained by the host through the computer module.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MicrophonePermission {
/// Capture may proceed.
Granted,
/// Capture is refused, including unknown or unsupported permission states.
#[default]
Denied,
}
/// Opaque module-owned recording or audio output handle.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CaptureHandle(pub String);
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
/// Reserve or start capture using an explicit permission decision.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecordingStartRequest {
Comment thread
senamakel marked this conversation as resolved.
/// Permission granted through the computer module.
pub permission: MicrophonePermission,
Comment thread
senamakel marked this conversation as resolved.
/// Module-generated reservation obtained before starting native setup.
#[serde(default)]
pub handle: Option<CaptureHandle>,
}
/// Finish capture and run the module's existing preparation pipeline.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordingFinishRequest {
/// Recording lease.
pub handle: CaptureHandle,
/// Existing silence-gate threshold; zero disables gating.
pub gate_threshold: f32,
}
/// Prepared WAV held inside the module for bounded reads.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AudioOutput {
/// Caller-known recording lease, released with `ReleaseAudioOutput`.
pub handle: CaptureHandle,
/// WAV bytes available.
pub length: usize,
}
/// Read a bounded slice of a held audio output.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadAudioRequest {
/// Output lease.
pub handle: CaptureHandle,
/// Byte offset.
pub offset: usize,
/// Maximum bytes to read.
pub length: usize,
}
/// Capture failure for product presentation, never a telemetry payload.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "code", content = "detail", rename_all = "snake_case")]
pub enum CaptureError {
/// Permission was not explicitly granted.
PermissionDenied,
/// Startup was canceled before its resource was delivered.
Cancelled,
/// Capture shutdown has closed this module instance.
Closed,
/// A recording or finishing operation already owns the device slot.
Busy,
/// The recording or output handle is unknown or released.
UnknownHandle,
/// Output storage or read bounds were exceeded.
LimitExceeded,
/// Invalid processing parameters.
InvalidParameters,
/// Device or processing failure; contains product-facing native detail.
Device(String),
}
/// Typed terminal result; provider detail must not enter telemetry.
pub type CaptureResult<T> = Result<T, CaptureError>;
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.

#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;

/// Native device format, reported once on stream startup.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CaptureFormat {
/// Native sample rate.
pub source_rate: u32,
/// Interleaved channel count.
pub channels: u16,
}
/// Bounded native callback buffer, processed through module audio operations.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RawChunk {
Comment thread
senamakel marked this conversation as resolved.
/// Interleaved native f32 samples.
pub samples: Vec<f32>,
}
/// Opaque continuous capture lease and its native format.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureStream {
/// Lease consumed by `CaptureStop`.
pub handle: CaptureHandle,
/// Native device format.
pub format: CaptureFormat,
}
/// Bounded batch read request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapturePollRequest {
Comment thread
senamakel marked this conversation as resolved.
/// Continuous capture lease.
pub handle: CaptureHandle,
/// At most two callback buffers per call.
pub max_chunks: usize,
Comment thread
senamakel marked this conversation as resolved.
}
/// Ordered bounded chunks, with terminal channel status.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CaptureBatch {
/// Retained native chunks in capture order.
pub chunks: Vec<RawChunk>,
/// Native capture ended; stop releases the lease and reports its result.
pub closed: bool,
}
55 changes: 55 additions & 0 deletions crates/tinyvoice-bus/src/capture/mod_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! Capture vocabulary preserves explicit permission and opaque lease shapes.
use super::*;
#[test]
fn permission_defaults_to_denial_and_handle_is_opaque() -> Result<(), serde_json::Error> {
assert_eq!(
MicrophonePermission::default(),
MicrophonePermission::Denied
);
assert_eq!(
serde_json::to_value(MicrophonePermission::Granted)?,
serde_json::json!("granted")
);
let handle = CaptureHandle("opaque".into());
assert_eq!(serde_json::to_value(&handle)?, serde_json::json!("opaque"));
assert_eq!(
serde_json::from_value::<CaptureHandle>(serde_json::json!("opaque"))?,
handle
);
Ok(())
}
#[test]
fn native_faults_are_structured_product_results() -> Result<(), serde_json::Error> {
let fault = CaptureError::Device("fixture".into());
let wire = serde_json::to_value(&fault)?;
assert_eq!(
wire,
serde_json::json!({"code":"device","detail":"fixture"})
);
assert_eq!(serde_json::from_value::<CaptureError>(wire)?, fault);
Ok(())
}

#[test]
fn continuous_format_and_batches_preserve_native_samples() -> Result<(), serde_json::Error> {
let stream = CaptureStream {
handle: CaptureHandle("lease".into()),
format: CaptureFormat {
source_rate: 48_000,
channels: 2,
},
};
let wire = serde_json::to_value(&stream)?;
let decoded: CaptureStream = serde_json::from_value(wire)?;
assert_eq!(decoded.format, stream.format);
let batch = CaptureBatch {
chunks: vec![RawChunk {
samples: vec![0.25, -0.25],
}],
closed: false,
};
let decoded: CaptureBatch = serde_json::from_value(serde_json::to_value(&batch)?)?;
assert_eq!(decoded.chunks, batch.chunks);
assert!(!decoded.closed);
Ok(())
}
Loading
Loading