Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ jobs:
test_brain_bridge.py \
test_brain_dispatch.py \
test_sanitize_reply.py \
test_capture_quality.py \
test_chime.py \
test_config_validation.py \
test_confidence_guard.py \
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ All notable changes to computah are recorded here. The format follows
## [Unreleased]

### Added
- Capture-device suitability warning (#34): `capture_quality.py` judges whether a
mic can carry continuous speech, and the live loop says so at startup instead of
letting it surface as a garbled transcript. A Bluetooth hands-free (HFP) mic on
Windows downsamples to narrowband and drops frames, so faster-whisper returns
garbled text while wake detection keeps firing at 0.998 -- the loop looks
healthy right up to the transcript. Two property signals are a hands-free
marker in the device name and, on WASAPI only, a default rate below the
pipeline's 16 kHz. Other host APIs expose a preferred default rather than a
bandwidth limit. `audio.py --list` tags unsuitable inputs, and `--test-mic`
also flags the 4 kHz cliff left by an upsampled narrowband stream. It exits 3
for a flagged device and 4 when the audio is unanalyzable or the narrowband
cliff is absent, since that check cannot rule out wideband HFP recompression
and frame drops. The module is hardware-free so `test_capture_quality.py` runs
with no PortAudio.
- `prep_wake_samples.py --clean` (#8): removes leftover clips of a take the run
just re-recorded (a `<stem>_NNN.wav` clip whose stem this run wrote) from
`--output`, so re-recording with fewer utterances no longer strands orphaned
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ python -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python test_confidence_guard.py # mishear guard decision + aggregation — fast, no models
.venv/bin/python test_preroll.py # pre-roll buffer keeps a no-pause request's leading audio — fast, no models
.venv/bin/python test_endpoint_config.py # endpoint_silence_ms / max_request_ms tune capture endpointing — fast, no models
.venv/bin/python test_capture_quality.py # capture-device suitability verdicts + the startup warning — fast, no models, no PortAudio
.venv/bin/python test_chime.py # wake-acknowledgment cue generator + both-loop wiring — fast, no models
.venv/bin/python test_live_driver.py # live_driver hardware path honors the guard — fast, no models
.venv/bin/python test_pipeline_bridge.py # full chain + bridge brain (loads models)
Expand Down Expand Up @@ -178,6 +179,7 @@ bug.

- `pipeline.py` — stages, chain, CLI, and the live-streaming turn (`stream_detect_wake`, `capture_request`, `run_turn`). `run_turn` exposes `on_wake` (fires at the wake->capture boundary, for the chime) and `on_capture` (fires after capture, for half-duplex mic pause); `run_loop` is the desktop live loop and wires both.
- `live_driver.py` — always-on live loop: real mic (arecord on stdin) -> wake -> chime -> STT -> brain -> spoken reply, re-arming after each turn. Gates the transcript through `pipeline.guard_transcript` before dispatch, the same mishear guard as `run_turn`.
- `capture_quality.py` — whether a capture device can carry continuous speech (issue #34): flags a Bluetooth hands-free (HFP) endpoint by name, a low WASAPI shared-mode capture format, and the 4 kHz cliff left by upsampled narrowband audio. Hardware-free (no sounddevice) so it is testable anywhere; `audio.py` stores the advertised-property verdict on `Microphone.capture_risk`, which `run_loop` prints at startup and `--list`/`--test-mic` surface. A spectral result without that cliff stays inconclusive because it cannot rule out wideband HFP codec damage or frame drops.
- `chime.py` — the wake-acknowledgment cue (issue #41): a pure-DSP generator for the two-tone rising chime played the instant the wake fires, before capture. Backend-free (no sounddevice/aplay) so both live loops can render the cue and play it through their own output. Gated by the `wake_chime` config key (opt-in, default off — it regresses the no-pause case on a half-duplex device); half-duplex handling keeps the cue out of the captured request when it is on.
- `brain_bridge.py` — bridge plus transports.
- `sim_persona.py` — test stand-in for the assistant.
Expand Down
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,65 @@ Run the pipeline on a wav file:

The output path receives the spoken reply.

## Choosing a microphone

The live loop needs a capture device that delivers full-band audio. Use a USB
Audio Class mic (or another wired/USB mic). **Do not use a Bluetooth mic on
Windows for continuous speech**, including a conference mic like the Anker
PowerConf paired over Bluetooth.

Windows captures from a Bluetooth mic over the hands-free profile (HFP), which
downsamples and recompresses the stream to narrowband and drops frames.
faster-whisper then receives degraded audio and returns garbled transcripts. The
same PowerConf over USB delivers its DSP-cleaned full-band stream intact and
transcribes cleanly.

This does not look like an audio problem. Wake detection keeps working over HFP --
a live "computah ..." still scores 0.998 -- because the wake model matches a short
fixed pattern that survives the degradation. Only the sentence after it comes back
wrong, so the loop appears healthy right up to the transcript.

To check a device before relying on it:

```bash
.venv/bin/python audio.py --list # flags unsuitable input devices
.venv/bin/python audio.py --test-mic "powerconf" # see the exit codes below
```

`--list` tags any input device it can tell is unsuitable and prints why. A
hands-free marker in the name works on every host. A low default rate is used only
for WASAPI, where it represents the shared-mode capture format; ALSA and CoreAudio
defaults are preferences and do not prove the device is narrowband. The live loop
prints the same warning at startup and keeps running, since the device is still
good enough for wake detection.

`--test-mic` goes further, because it has audio to look at: after capturing, it
inspects the spectrum for the cliff that upsampling leaves behind. Audio that
arrived at 16 kHz but started at 8 kHz carries almost nothing above 4 kHz, and no
amount of resampling puts it back. That catches the common Linux case where
PipeWire hides an 8 kHz transport behind a 16 kHz stream.

The spectral check has a strict boundary: energy above 4 kHz rules out that one
upsampling signature, but it does not rule out wideband HFP. A wideband HFP codec
can carry energy above 4 kHz while recompression and frame drops still garble
speech. A capture without the narrowband cliff is therefore reported as
inconclusive, as is a capture that is too short, silent, or constant.

`--test-mic` exit codes, which point at different fixes:

| Code | Meaning |
| --- | --- |
| 2 | Nothing arrived (only zeros). The mic is muted, unplugged, or the wrong source. |
| 3 | Frames arrived, but the device is unsuitable for speech. It works; pick a different one. |
| 4 | Frames arrived, but the available checks cannot establish suitability. Prefer USB or verify with transcription. |

The two layers fail differently. `--list` sees only what the device advertises:
wideband HFP negotiates 16 kHz and passes the rate test, and on Linux the
hands-free marker is usually absent from the name. `--test-mic` can flag an
upsampled narrowband stream from the audio, while leaving wideband HFP and a
resampler noisy enough to fill the empty band unresolved. A clean `--list` is not
proof a Bluetooth link is fine. Prefer USB.

## Configuration

`config.json` contains safe defaults that can be committed:
Expand Down
101 changes: 88 additions & 13 deletions audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
import soundfile as sf
from scipy.signal import resample_poly

import capture_quality

# Must match pipeline.py: openWakeWord's frame is 80 ms at 16 kHz = 1280 samples.
TARGET_SR = 16000
FRAME_SIZE = 1280
Expand Down Expand Up @@ -146,6 +148,13 @@ def __init__(
self._open_sr = None
self._open_ch = None
self.device_label = None
# PortAudio's default is kept separate from _open_sr because it is the
# WASAPI shared-mode capture format, while on other host APIs it is only
# a preference. capture_quality receives the host API so it can make that
# distinction instead of treating the default as native bandwidth.
self.device_default_sr = None
self.host_api = None
self.capture_risk = None

def _callback(self, indata, frames, time_info, status):
if status:
Expand All @@ -154,17 +163,17 @@ def _callback(self, indata, frames, time_info, status):

def _open(self, idx: int, host: str):
"""Open the input stream, preferring 16 kHz mono via the OS engine, with
native-rate fallbacks. Returns (stream, open_sr, open_channels)."""
default-rate fallbacks. Returns (stream, open_sr, open_channels)."""
extra = _wasapi_autoconvert(host)
info = sd.query_devices(idx)
native_sr = int(info["default_samplerate"])
native_ch = max(1, int(info["max_input_channels"]))
# Order matters: clean engine-converted path first, then native fallbacks.
default_sr = int(info["default_samplerate"])
max_ch = max(1, int(info["max_input_channels"]))
# Order matters: clean engine-converted path first, then device defaults.
attempts = [
(self.target_sr, 1, extra),
(native_sr, 1, extra),
(native_sr, native_ch, extra),
(native_sr, native_ch, None),
(default_sr, 1, extra),
(default_sr, max_ch, extra),
(default_sr, max_ch, None),
]
last = None
for sr, ch, ex in attempts:
Expand All @@ -186,7 +195,23 @@ def _open(self, idx: int, host: str):
def __enter__(self):
idx, info, host = find_device(self.name, "input")
self.device_label = f"{info['name']} ({host})"
self.device_default_sr = int(info["default_samplerate"])
self.host_api = host
self.capture_risk = capture_quality.assess_input_device(
info["name"],
self.device_default_sr,
self.target_sr,
host_api=self.host_api,
Comment thread
jamditis marked this conversation as resolved.
)
self._stream, self._open_sr, self._open_ch = self._open(idx, host)
if self.capture_risk is None and 0 < self._open_sr < self.target_sr:
self.capture_risk = capture_quality.CaptureRisk(
"narrowband",
f"{self.device_label} opened at {self._open_sr} Hz after the "
f"{self.target_sr} Hz attempt failed. The pipeline can resample "
"that stream, but it cannot restore the missing speech bandwidth; "
"choose an input or profile that opens at the target rate.",
)
self._stream.start()
return self

Expand Down Expand Up @@ -280,18 +305,36 @@ def play_wav(path, name=None) -> None:


def list_devices() -> None:
"""Print the audio device table (index, I/O, host API, default rate)."""
"""Print the audio device table (index, I/O, host API, default rate).

Tags input devices that cannot carry continuous speech, so the table helps
pick a mic rather than only listing what exists (issue #34).
"""
flagged = []
for i, d in enumerate(sd.query_devices()):
io = []
if d["max_input_channels"] > 0:
io.append(f"IN x{d['max_input_channels']}")
if d["max_output_channels"] > 0:
io.append(f"OUT x{d['max_output_channels']}")
host = sd.query_hostapis(d["hostapi"])["name"]
risk = None
if d["max_input_channels"] > 0:
risk = capture_quality.assess_input_device(
d["name"],
d["default_samplerate"],
TARGET_SR,
host_api=host,
)
tag = f" <-- {risk.kind}, not for speech" if risk else ""
if risk:
flagged.append((i, risk))
print(
f"[{i:2d}] {d['name'][:44]:44s} {','.join(io):10s} "
f"{host:18s} @{int(d['default_samplerate'])}"
f"{host:18s} @{int(d['default_samplerate'])}{tag}"
)
for idx, risk in flagged:
print(f"\n[{idx}] {risk.message}")


def _test_mic(name: str, seconds: float) -> int:
Expand All @@ -302,16 +345,21 @@ def _test_mic(name: str, seconds: float) -> int:
peak = 0
sumsq = 0.0
n_samples = 0
captured = []
target = int(seconds * TARGET_SR / FRAME_SIZE)
with Microphone(subs) as mic:
print(
f"device: {mic.device_label} open_sr={mic._open_sr} open_ch={mic._open_ch}"
f"device: {mic.device_label} default_sr={mic.device_default_sr} "
f"open_sr={mic._open_sr} open_ch={mic._open_ch}"
)
if mic.capture_risk is not None:
print(f"WARNING: {mic.capture_risk.message}")
for frame in mic.frames():
assert frame.dtype == np.int16 and frame.shape == (FRAME_SIZE,), (
f"bad frame: dtype={frame.dtype} shape={frame.shape}"
)
n_frames += 1
captured.append(frame)
peak = max(peak, int(np.max(np.abs(frame))))
sumsq += float(np.sum(frame.astype(np.float32) ** 2))
n_samples += frame.size
Expand All @@ -325,8 +373,33 @@ def _test_mic(name: str, seconds: float) -> int:
if peak == 0:
print("RESULT: dead stream (only zeros) - check the mic's source")
return 2
print("RESULT: live frame stream - shape and dtype correct for the pipeline")
return 0

# The audio is already in hand, so judging it costs nothing extra. The cliff
# check sees an 8 kHz source that PipeWire advertises and emits at 16 kHz,
# while leaving wideband HFP explicitly inconclusive.
spectrum = capture_quality.assess_capture_spectrum(
np.concatenate(captured), TARGET_SR
)
if spectrum.risk is not None:
print(f"WARNING: {spectrum.risk.message}")

risk = mic.capture_risk or spectrum.risk
if risk is not None:
# The frames are structurally perfect and the content is still suspect,
# so a bare "correct for the pipeline" here would read as a pass on a mic
# that garbles every sentence. Non-zero exit, and worded as the
# classifier's verdict rather than as ground truth, because the three
# signals differ in strength: the hands-free name identifies the device
# outright, the advertised rate is conclusive only on WASAPI, and the
# spectral cliff is measured from the audio but can be masked by a noisy
# resampler (see the two assess_* docstrings in capture_quality).
print(
"RESULT: live frame stream, but this device is flagged as unsuitable "
f"for continuous speech ({risk.kind}) - see the warning above"
)
return 3
print(f"RESULT: inconclusive - {spectrum.message}")
return 4


def _cli() -> int:
Expand All @@ -337,7 +410,9 @@ def _cli() -> int:
metavar="NAME",
nargs="?",
const="",
help="capture from a mic (name substring; empty = default)",
help="capture from a mic (name substring; empty = default). exits 2 if "
"nothing arrived, 3 if flagged as unsuitable for continuous speech, or "
"4 if suitability remains inconclusive",
)
p.add_argument("--play", metavar="WAV", help="play a WAV through an output device")
p.add_argument(
Expand Down
Loading
Loading