Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
bun = "1.3.13"
596 changes: 596 additions & 0 deletions docs/latency-investigation.md

Large diffs are not rendered by default.

111 changes: 110 additions & 1 deletion src-tauri/src/audio_toolkit/audio/recorder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,12 @@ pub fn is_no_input_device_error(error_message: &str) -> bool {

#[cfg(test)]
mod tests {
use super::{is_microphone_access_denied, is_no_input_device_error};
use super::{is_microphone_access_denied, is_no_input_device_error, AudioRecorder};
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use std::time::Duration;

#[test]
fn detects_access_is_denied() {
Expand Down Expand Up @@ -390,6 +395,110 @@ mod tests {
assert!(!is_no_input_device_error("permission denied"));
assert!(!is_no_input_device_error("device not found"));
}

// ---------------------------------------------------------------
// Lifecycle tests exercising AudioRecorder::open / start / stop / close
// against the real default input device. They skip gracefully when no
// device is available (e.g. headless CI) so the unit suite still passes.
// On macOS the first run may prompt for microphone permission.
// ---------------------------------------------------------------

/// Try to open the default input device. Returns `None` and the test
/// should early-return when no input device is attached; panics on any
/// other error so real regressions still surface.
fn open_default_or_skip(recorder: &mut AudioRecorder) -> Option<()> {
match recorder.open(None) {
Ok(()) => Some(()),
Err(err) => {
let msg = err.to_string();
if is_no_input_device_error(&msg) {
eprintln!("skipping recorder lifecycle test: {msg}");
None
} else {
panic!("AudioRecorder::open failed unexpectedly: {msg}");
}
}
}
}

#[test]
fn open_default_device_then_close_is_clean() {
let mut recorder = AudioRecorder::new().expect("new should succeed");
if open_default_or_skip(&mut recorder).is_none() {
return;
}
recorder.close().expect("close should succeed after open");
}

#[test]
fn open_is_idempotent_on_already_open_recorder() {
let mut recorder = AudioRecorder::new().expect("new should succeed");
if open_default_or_skip(&mut recorder).is_none() {
return;
}
// Second open on an already-open recorder must be a no-op, not an
// error and not a second worker thread.
recorder
.open(None)
.expect("second open on an already-open recorder should be a no-op");
recorder.close().expect("close should succeed");
}

#[test]
fn start_then_stop_returns_captured_samples() {
let mut recorder = AudioRecorder::new().expect("new should succeed");
if open_default_or_skip(&mut recorder).is_none() {
return;
}
recorder.start().expect("start should succeed");
// Give the cpal callback a chance to fire at least once. Any real
// device should produce samples within this window; we only assert
// the call returns Ok so the test stays stable on silent mics.
std::thread::sleep(Duration::from_millis(150));
let samples = recorder.stop().expect("stop should return samples");
// Sanity: stop returns an owned Vec<f32>. We don't assert length —
// VAD may suppress silence, but the call must succeed cleanly.
let _: Vec<f32> = samples;
recorder.close().expect("close should succeed");
}

#[test]
fn close_allows_reopen() {
// The on-demand microphone mode relies on repeated open/close
// cycles. This test pins that lifecycle so we notice if a refactor
// leaks state between sessions.
let mut recorder = AudioRecorder::new().expect("new should succeed");
if open_default_or_skip(&mut recorder).is_none() {
return;
}
recorder.close().expect("first close should succeed");
recorder
.open(None)
.expect("reopen after close should succeed");
recorder.close().expect("second close should succeed");
}

#[test]
fn level_callback_fires_while_recording() {
let hits = Arc::new(AtomicUsize::new(0));
let hits_cb = Arc::clone(&hits);
let mut recorder = AudioRecorder::new()
.expect("new should succeed")
.with_level_callback(move |_buckets| {
hits_cb.fetch_add(1, Ordering::Relaxed);
});
if open_default_or_skip(&mut recorder).is_none() {
return;
}
recorder.start().expect("start should succeed");
std::thread::sleep(Duration::from_millis(400));
let _ = recorder.stop().expect("stop should succeed");
recorder.close().expect("close should succeed");
assert!(
hits.load(Ordering::Relaxed) > 0,
"level callback should fire at least once while recording",
);
}
}

fn run_consumer(
Expand Down
3 changes: 3 additions & 0 deletions tools/macos-audio-perf/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.build/
.swiftpm/
Package.resolved
13 changes: 13 additions & 0 deletions tools/macos-audio-perf/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// swift-tools-version:5.9
import PackageDescription

let package = Package(
name: "handy-audio-perf",
platforms: [.macOS(.v13)],
targets: [
.executableTarget(
name: "handy-audio-perf",
path: "Sources/handy-audio-perf"
)
]
)
88 changes: 88 additions & 0 deletions tools/macos-audio-perf/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# handy-audio-perf

Tiny native-Swift harness for measuring the absolute best-case keypress → tone
round-trip latency on macOS, as a baseline to compare Handy against.

**Not part of the Handy app.** Only measurement infrastructure.

## What it measures

On every `Cmd+Shift+H` press, the harness:

1. Captures `t0 = mach_absolute_time()`
2. Builds (or starts, in warm mode) an `AVAudioEngine`
3. Installs a tap on the input node, records to `/tmp/handy-audio-perf/press-*.wav`
4. On the first tap callback, schedules a preloaded sine-tone buffer onto an
`AVAudioPlayerNode` attached to the same engine, then plays it
5. Logs one CSV row per completed press

Stop recording with another `Cmd+Shift+H`.

## Columns

CSV path: `/tmp/handy-audio-perf/perf.csv` (override with `--csv=PATH`)

| Column | Meaning |
|---|---|
| `iso` | ISO timestamp |
| `mode` | `cold` or `warm` |
| `press` | 1-based press index within this process |
| `cold_or_warm` | same as `mode` (redundant; kept for grouping) |
| `mic` | Current default input device name |
| `engine_start_ms` | Time spent in `engine.start()` alone |
| `first_sample_ms_since_t0` | Swift-thread wall time from keypress to the first tap callback |
| `tone_play_call_ms_since_t0` | Swift-thread wall time from keypress to `player.play()` returning |
| `tap_host_ms_since_t0` | Audio-clock time (`AVAudioTime.hostTime`) of the first sample, as ms since keypress. This is the authoritative "when did audio really start" number. |
| `sample_rate` / `channels` | Input format the tap used |

## Run

```bash
cd tools/macos-audio-perf
swift build -c release
# Cold mode (rebuild engine every press)
.build/release/handy-audio-perf
# Warm mode (reuse engine)
.build/release/handy-audio-perf --warm
```

## Auto mode

Run without a human — useful for CI-style verification and for collecting
repeatable numbers:

```bash
.build/release/handy-audio-perf --auto --iterations=5 --min-hold=1 --max-hold=3 --idle=1
.build/release/handy-audio-perf --warm --auto --iterations=5
```

Defaults: 5 iterations, hold 1.0–5.0s random, idle 1.0s between presses.
Prints a per-mode summary (min/median/mean/max for each timing column) and
a per-press table, then exits cleanly.

## Triggers

Any one of these toggles a press:

* `Cmd+Shift+H` (primary)
* `Ctrl+Shift+H`
* `Ctrl+Opt+Space`
* `F19`
* Pressing **Enter** in the terminal where the harness is running

The Carbon hotkeys are global (work while any app is focused); the Enter
fallback is handy for quick sanity checks when terminal-emulator keyboard
protocols swallow a combo.

The first microphone-using press will prompt for Microphone access. Grant it
— the harness needs the real hardware timing.

No Accessibility permission is required: Carbon `RegisterEventHotKey` is
narrowly scoped and doesn't need TCC.

## Scope discipline

Deliberately minimal: one hotkey, one mic, one tone, one wav. Resist feature
creep — every addition risks adding noise to the numbers. If you want to
test a different mic, change the system-default input device in
System Settings → Sound.
Loading
Loading