# ADR-0032: Separate `AudioInput` Trait at the Transcriber Boundary

## Status

✅ Accepted (2026-05-13)

## Context

[ADR-0031](adr-0031.md) defined an `AudioSource` trait as the seam between
hardware audio capture (cpal) and the rest of the capture pipeline.
Issue #800 landed it as `pub trait AudioSource { next_chunk() -> Option<Vec<f32>>; sample_rate(); channels(); }`,
intentionally **not** `Send` because cpal's `Stream` is `!Send` on macOS,
and intentionally f32 / variable-rate / variable-channel because that is
what cpal hands over at the device boundary.

Issue #801 (this issue) introduced the `Transcriber` trait — the boundary
between the audio pipeline and an ASR backend (Whisper, sherpa-onnx, …).
ASR backends consume audio at a fixed shape: **16 kHz mono signed 16-bit
PCM**. That shape is the result of running the captured audio through
mixdown + resampling, which `voice capture` already does in #800 on its
way to writing WAVs.

That places `Transcriber::transcribe`'s audio parameter at a different
seam than `AudioSource`:

|              | `AudioSource` (#800)         | `AudioInput` (#801)                     |
| ------------ | ---------------------------- | --------------------------------------- |
| Sample type  | `f32` (interleaved frames)   | `i16` mono                              |
| Sample rate  | Variable (carried per-call)  | Fixed: 16 kHz                           |
| Channels     | Variable (carried per-call)  | Fixed: 1                                |
| `Send` bound | **Not** `Send` (cpal/macOS)  | `Send` — `Transcriber: Send + Sync`     |
| Position     | Hardware → pipeline          | Pipeline (post mixdown/resample) → ASR  |

Three options were considered:

1. **Reuse `AudioSource` for both seams.** Force ASR backends to inspect
   `sample_rate()` and `channels()` at runtime, resample/mix internally,
   and convert f32 → i16. The `Send` mismatch would either constrain
   hardware sources further (block cpal/macOS) or constrain ASR
   backends not to require thread-safety. Both directions break a real
   contract.

2. **Widen `AudioSource` to a parameterised trait** with associated
   `Sample` type and `MaybeSend` marker. Plausible but introduces
   visible complexity at every callsite of `AudioSource`, including the
   existing pipeline code that doesn't care about thread-safety. A
   parameterised trait is also harder to substitute behind a `Box<dyn>`
   because callers have to pick the type parameters up-front.

3. **Define a separate `AudioInput` trait** for the ASR-side seam.
   Independent invariants (fixed shape, `Send`-able, i16) live in
   independent contracts. Existing `AudioSource` impls keep working
   unchanged. A future adapter (`MixdownResampleAudioInput<S: AudioSource>`)
   wires the two traits together when the streaming work in #806 lands;
   the adapter is the only place that bridges the `!Send`/`Send` gap,
   and it can do so explicitly (own its own owner thread, or run on
   the audio thread itself).

## Decision

We add a second trait, `AudioInput`, alongside the existing `AudioSource`:

```rust
pub trait AudioInput: Send {
    fn next_chunk(&mut self) -> Option<AudioChunk>;
}

pub type AudioChunk = Vec<i16>; // 16 kHz mono signed PCM, always.
```

`Transcriber::transcribe` takes `Box<dyn AudioInput>`. The 16 kHz / mono
/ i16 shape is part of the trait contract — implementations refuse
mismatched inputs at construction time (e.g.,
`VecAudioInput::from_wav_path` errors when the WAV is not 16 kHz mono
i16) rather than carrying rate/channel metadata for ASR backends to
re-check on every chunk.

Two implementations live behind the trait in #801:

- **`VecAudioInput`** — reads a 16 kHz mono i16 PCM WAV (or accepts an
  in-memory `Vec<i16>`) and yields it in caller-specified chunk sizes.
  Used by the integration snapshot test in #801 and by future
  fixture-driven backend tests.
- *(future)* **`MixdownResampleAudioInput<S: AudioSource>`** — wraps an
  `AudioSource`, performs mixdown + resample + f32→i16 conversion, and
  exposes the result as `AudioInput`. Lands alongside the streaming
  pipeline in #806; not in #801's scope.

`AudioSource` is unchanged. It remains the hardware-capture seam and the
production type behind `voice capture` and (later) `voice listen`. The
two traits coexist because they describe different boundaries, not
different views of the same boundary.

## Consequences

**Positive:**

- ASR backends written against `AudioInput` never need to handle
  resampling, channel mixdown, or sample-format conversion — those
  belong to the pipeline-side adapter, not the inference code.
- The `Send` bound on `Transcriber` doesn't bleed back onto cpal. The
  hardware-capture seam keeps its `!Send` source type; the adapter is
  the only thing that crosses thread boundaries, and it does so
  explicitly.
- Pre-recorded WAV inputs (a near-term use case: "transcribe an
  existing voice memo without going through the mic pipeline") plug
  into the same `Transcriber` trait via `VecAudioInput` without
  detouring through `AudioSource`.

**Negative:**

- Two seams in the same crate where one might have done. The mental
  model is "there are two audio traits; pick the right one for your
  layer." Mitigated by keeping their docstrings tight on their
  respective use cases and by this ADR documenting the rationale.
- A pipeline-side adapter has to exist for live capture → transcription
  to work end-to-end. That adapter is scope for #806 (streaming) and
  doesn't impede #801's batch acceptance criteria.

**Neutral:**

- `AudioChunk = Vec<i16>` is a deliberate v1 choice. If a backend ever
  prefers f32 or a slice-of-bytes interface, the trait can grow a
  parameter (`AudioInput<S = i16>`) without breaking existing
  implementations — but no concrete backend in #801–#806 needs that,
  so we don't pay for the abstraction yet.
- The 16 kHz / mono assumption is hardcoded in `AudioChunk`'s docstring
  rather than carried at the type level (e.g., as a `SampleRate<16000>`
  generic). The single-rate convention matches whisper.cpp /
  sherpa-onnx's native format and pushing it into types would obscure
  the contract more than it would protect against misuse.

## Coverage expectations

Most of the `src/voice/{transcriber,factory,backends/mock}.rs` surface
is covered by direct unit tests and the integration snapshot test
under `tests/voice_transcribe_test.rs`. Two narrow code paths are
**uncovered by design** and should not be chased with additional
tests:

| Surface | Why uncovered |
|---|---|
| `factory::tests::env_guard` `PoisonError` fallback (`Err(poisoned) => poisoned.into_inner()`) | A test-only mutex serialises env-var-mutating tests in `factory::tests`. The `into_inner` fallback fires only if a previous test panicked while holding the guard. Tests are written not to panic mid-mutation; reaching this branch in CI would already be a bug in the test setup, not coverage worth chasing. |
| `factory::tests::unknown_backend_errors` `let-else` panic | Defensive panic in `let Err(err) = … else { panic!(…) }`. Dead code on the happy path the test asserts; coverage tools report it as uncovered because the panic arm is only entered when the test's premise is already false. |

These mirror the `by design` list in ADR-0031. When a coverage gap
appears in `src/voice/{transcriber,factory,backends/mock}.rs` that
isn't on this list, the right response is to add a test, not to
extend this exception list.
