# ADR-0031: AudioSource Trait Boundary for Real-Time Audio Capture Testability

## Status

✅ Accepted (2026-05-13)

## Context

Issue #800 added the first audio-capture surface to omni-dev:
`omni-dev voice capture`, a `cpal`-backed microphone-to-WAV pipeline. The
pipeline is short — mono mixdown, `rubato` resampling to 16 kHz, RMS-based
idle detection, trailing-silence trim, `hound` WAV writer — but the
backbone is a real-time audio path: cpal delivers samples via a callback
running on the OS audio thread, and the consumer side has to drain and
process those samples without dropping any.

That backbone is hostile to CI testing:

- **No audio device.** CI runners have no microphone and no audio host
  context. `cpal::Host::default_input_device()` returns `None` (or
  panics on some hosts), so any test that touches the cpal types is
  unrunnable in CI.
- **No determinism.** Even on developer machines, cpal samples arrive at
  the device's own cadence with content the test cannot predict.
  Assertions on timing or content are inherently flaky.
- **No mockability.** cpal's types (`Host`, `Device`, `Stream`,
  `StreamConfig`) are concrete structs without trait abstractions a test
  can substitute. A mock cpal layer would be a substantial parallel
  implementation, and would couple test correctness to cpal version
  bumps.
- **Platform-specific Send semantics.** cpal's `Stream` is not `Send` on
  macOS (it wraps a CoreAudio `AudioUnit` containing raw pointers).
  Any abstraction over cpal has to account for that without forcing
  awkward platform-conditional bounds onto all sources.

The capture pipeline behind the audio path is *not* hostile to testing
— resampling, idle detection, trimming, and WAV writing are pure
data-in / data-out functions over `f32` samples. The only obstacle is
that the production source (`cpal`) cannot drive them in tests.

This is the first issue in the umbrella #799 build order. Several
follow-up issues (`voice listen`, file-input capture, hotkey-driven
capture, multi-device capture) will land additional sources behind the
same pipeline. The seam that separates "untestable hardware I/O" from
"testable pipeline" needs a stable, written contract now, before the
pattern is copy-pasted four more times.

Two rejected alternatives:

- **Mock cpal directly.** Introduce a trait that mirrors cpal's `Host`,
  `Device`, and `Stream`, and write a no-op implementation for tests.
  This couples test code to cpal's API shape (which changes), forces
  every future source to fake the cpal call sequence, and tests only
  the cpal interaction — not the pipeline behaviour the issue actually
  cares about.
- **Test at the CLI level only.** Drive `omni-dev voice capture --output
  /tmp/out.wav` against a fixture audio device under `cargo test`.
  Requires a fixture audio device (none exists in CI), asserts only on
  observable WAV file output (no insight into intermediate stages like
  resampler chunk boundaries or idle detector window classification),
  and forces every regression into a full end-to-end reproduction.

## Decision

We will define an `AudioSource` trait at the boundary between hardware
capture and the rest of the capture pipeline. The trait carries
exactly enough surface to drive the pipeline: a chunk-yielding
`next_chunk` that returns interleaved f32 frames, plus `sample_rate`
and `channels` accessors.

```rust
pub trait AudioSource {
    fn next_chunk(&mut self) -> Option<Vec<f32>>;
    fn sample_rate(&self) -> u32;
    fn channels(&self) -> u16;
}
```

Two implementations live behind the trait:

- **`CpalAudioSource`** — the production source. Opens the default
  input device (or a `--device`-named match), starts a cpal input
  stream, and pushes f32-coerced samples through a lock-free SPSC ring
  buffer to the consumer side. Carries a single `#[ignore]`-gated
  smoke test that requires real hardware, runnable locally via
  `cargo test -- --ignored`.
- **`FileAudioSource`** — the test source. Reads a fixture WAV (or an
  in-memory `Vec<f32>`) via `hound` and replays it in
  caller-specified chunk sizes. Decodes integer PCM at any bit depth
  into f32 `[-1.0, 1.0]`, so a single fixture can stand in for any
  capture-side input rate the pipeline needs to exercise.

The pipeline orchestrator `run_capture<S: AudioSource>(source: S, …)`
is generic over the trait. Every stage downstream of the source —
mixdown, resampler, idle detector, trimmer, WAV writer — is tested
via `FileAudioSource` with synthesised or fixture inputs. The
`CpalAudioSource` carries only the cpal-specific concerns: device
enumeration, sample-format coercion, ring-buffer sizing, stream-error
surface. Those concerns are deliberately kept small and
non-conditional on the pipeline stages.

The trait is intentionally not `Send`. cpal's macOS `Stream` is
`!Send`, and the capture pipeline runs synchronously on its owning
thread — the cpal callback is the only thing that crosses a thread
boundary, and it does so internally to cpal via the SPSC ring
buffer. Adding `Send` would force `CpalAudioSource` into an awkward
indirection (a dedicated owner thread or an `unsafe impl Send`
wrapper) to satisfy a bound the pipeline doesn't actually need.

The same trait applies to every future source landing in the #799
umbrella: `FileInputAudioSource` (transcribe an existing recording),
`HotkeyAudioSource` (capture controlled by a global hotkey),
`SystemAudioSource` (system-audio capture rather than mic), and so on.
Each new source provides its own `next_chunk` implementation;
nothing else in the pipeline changes.

## Consequences

**Positive:**

- The capture pipeline is fully exercised in CI through
  `FileAudioSource`-driven integration tests. Resampler chunk
  boundaries, idle detector window classification, trailing-silence
  trim correctness, and WAV writer round-tripping all have unit
  tests that run on every push.
- New audio-source implementations (`voice listen`, hotkey capture,
  file capture) plug into the existing pipeline without re-deriving
  the test harness. The pattern is documented here once, applied N
  times.
- The cpal-specific code is narrowly scoped to `CpalAudioSource` and
  is the only thing changing when cpal version bumps land.
- The error-on-no-audio guard in the orchestrator (which catches
  muted-mic surprises by checking `IdleDetector::has_any_voice`)
  was validated against synthesised silence-only fixtures during
  development, not by waiting to ship and watching a user hit it in
  the wild.

**Negative:**

- The `f32 Vec<f32>` chunk return type per call allocates on every
  pull. For the v1 sync orchestrator this is well below the
  throughput ceiling (each call is hundreds of microseconds at most),
  but if `voice listen` ever needs a tighter loop the trait will
  grow a `next_chunk_into(&mut Vec<f32>)` variant. The current
  shape is a deliberate v1 simplification.
- `CpalAudioSource` has a thin smoke test only. Regressions in the
  cpal interaction (wrong sample format, ring-buffer overrun, missed
  device name) surface only when run locally with `--ignored`. We
  accept this — CI cannot fix it, and a smoke test plus careful
  error-message ergonomics (listing detected devices when a name
  doesn't match) catches the common failure modes.
- The trait is not `Send`. If a future issue needs to move a source
  across threads, the bound will have to be widened conditionally
  (e.g. `pub trait AudioSource: MaybeSend` with a cfg-gated alias),
  or per-source rather than at the trait level. We will pay that
  cost when a concrete use case arises.

**Neutral:**

- `FileAudioSource` is `pub` and lives alongside `CpalAudioSource` in
  `src/voice/audio.rs` rather than under `#[cfg(test)]`. This is
  deliberate: downstream issues will want to use it for non-test
  scenarios too (e.g., a "transcribe an existing WAV" command), and
  keeping it in production code avoids a later `pub(crate)`-to-`pub`
  break.
- Test fixtures are generated programmatically in each test rather
  than committed to disk. Synthetic sine waves and silence are
  trivial to produce in-process, and the byte-stability argument for
  committed fixtures applies to *goldens*, not to *inputs* whose
  exact bit pattern doesn't matter.

## Coverage expectations

The seam this ADR establishes has a direct, quantifiable consequence
for line coverage: roughly 100–120 lines of hardware-bound code in
`CpalAudioSource` (`new`, the f32/i16/u16 callback closures, the
`take_stream_error` polling loop, `find_input_device`'s happy path)
and the CLI `execute()` methods that construct it cannot run in CI.
The `#[ignore]`-gated `cpal_default_input_produces_samples` smoke
test does exercise this code locally, but coverage tools record it
as uncovered by default.

The following gaps are **by design** per this ADR and should not be
chased with additional tests:

| Surface | Why uncovered |
|---|---|
| `CpalAudioSource::new` (stream construction, callback closures) | Needs a working cpal input device. |
| `CpalAudioSource::next_chunk` (polling loop, stream-error path) | Needs in-flight samples from the cpal callback. |
| `find_input_device` happy path | Needs at least one detected device that matches a known name; the unknown-device error path *is* covered. |
| `CaptureCommand::execute` (in `src/cli/voice/capture.rs`) | Constructs `CpalAudioSource`; same hardware constraint. |
| `VoiceCommand::execute` dispatch | Same — its only branch delegates to `CaptureCommand::execute`. |
| `Commands::Voice` dispatch in `src/cli.rs` | Same — its only branch delegates to `VoiceCommand::execute`. |
| `install_ctrl_c_handler` registration failure | `signal-hook` registration can fail only under conditions a test cannot reliably synthesise (signal table corruption). |
| `default_output_path` `dirs::home_dir() == None` branch | Would require unsetting `HOME` globally; racy under parallel tests. |

All *other* code paths in `src/voice/**` and `src/cli/voice/**` should
have unit tests driven by `FileAudioSource` or by direct calls to the
helper functions. When a coverage gap appears in those files that
isn't on the list above, the right response is to add a test, not to
extend this exception list.
