Skip to content

Warn when the capture device cannot carry continuous speech - #87

Merged
jamditis merged 5 commits into
mainfrom
fix/34-narrowband-capture-warning
Jul 28, 2026
Merged

Warn when the capture device cannot carry continuous speech#87
jamditis merged 5 commits into
mainfrom
fix/34-narrowband-capture-warning

Conversation

@jamditis

Copy link
Copy Markdown
Owner

Closes #34.

What was wrong

A Bluetooth conference mic on Windows connects over the hands-free profile (HFP), which downsamples to narrowband and drops frames. faster-whisper receives that and returns garbled transcripts. The same mic over USB Audio Class transcribes cleanly, so the fault is the transport.

The part that cost real time: wake detection does not degrade with it. A live "computah ..." still scores 0.998, because the wake model matches a short fixed pattern that survives the damage while a full sentence does not. The loop looks healthy right up to the transcript, so the mic is the last thing anyone suspects. That is why this warns at startup rather than only in the README.

What changed

  • capture_quality.py (new): assess_input_device(name, native_sr, target_sr) returns a CaptureRisk or None. Two signals, name first because the profile is the thing to act on and the rate is its symptom:
    • a hands-free marker in the device name (hands-free, handsfree, hfp)
    • a native rate below the pipeline's 16 kHz
  • audio.py: Microphone now records native_sr and capture_risk. --list tags unsuitable inputs and prints why; --test-mic reports native_sr next to open_sr and exits 3.
  • pipeline.py: the live loop prints the warning once at startup, then keeps running (the device still serves wake detection fine).
  • README: a "Choosing a microphone" section, and the --test-mic exit codes.

The detail that makes this work

It reads the device's advertised rate, not the rate the stream opened at. Microphone._open() asks for 16 kHz first, and WASAPI auto_convert grants it even on a narrowband device — so _open_sr reports a healthy 16000 while the audio is already ruined. A check against the opened rate would have detected nothing. That trap is now named in the field's own comment so it does not get "simplified" later.

Why the classifier is its own module

audio.py imports sounddevice, which loads native PortAudio and is absent on plenty of boxes (including the one this was written on). Judging a device's advertised properties needs no hardware, so keeping it separate makes it testable everywhere — which matches the split the project already draws, where the pipeline core is hardware-free and audio.py is the one OS-specific seam.

Deliberate non-decisions

  • "headset" is not a marker. A USB headset is a good STT mic; matching it would warn about working hardware and teach the operator to ignore the line.
  • 16 kHz native is not narrowband. That is exactly the Pi's production USB path. <= instead of < would flag the one device known to work, so a test pins it.
  • Warn, do not refuse. A flagged device still serves wake detection, so a degraded loop the operator knows about beats a loop that will not start.

Known blind spot, documented not papered over

Wideband HFP negotiates 16 kHz, so it clears the rate check; and on Linux a bluez/PipeWire HFP source usually carries no hands-free marker, so the name check never fires there. A clean result means "nothing detected", not "device is fine" — stated in the module docstring and the README. Catching that case needs a spectral check on real audio, which costs a capture at startup.

Verification

  • test_capture_quality.py (new, added to the CI fast-test list): 23 checks. Covers the classifier's verdicts, the good devices staying silent, unparseable rate input, and — the half that makes the feature real — that the warning actually reaches stdout at --listen startup, once, without stopping the loop.
  • ruff check and ruff format --check clean; all 17 CI fast tests pass locally.
  • Mutation-checked twice: flipping < to <= fails exactly the Pi-PowerConf case, and suppressing the startup print fails exactly the three surfacing checks. Neither test passes vacuously.

wake-20260727T1000-0b9a6b

A Bluetooth conference mic on Windows connects over the hands-free profile,
which downsamples to narrowband and drops frames. faster-whisper then returns
garbled text. The same mic over USB Audio Class transcribes cleanly, so this is
the transport, not the model.

What made it cost an afternoon is that wake detection does not degrade with it.
A live "computah ..." still scores 0.998, because the wake model matches a short
fixed pattern that survives the damage while a full sentence does not. The loop
looks healthy right up to the transcript, so the mic is the last thing suspected.
Hence a warning at startup rather than only a README note.

Two signals: a hands-free marker in the device name, and a native rate below the
pipeline's 16 kHz. The rate read is the device's advertised rate, not the one the
stream opened at -- _open() asks for 16 kHz first and WASAPI auto_convert grants
it even on a narrowband device, so the opened rate reports 16000 while the audio
is already ruined. Checking that field would have detected nothing.

The classifier is its own module rather than part of audio.py because audio.py
imports sounddevice, which loads native PortAudio and is absent on plenty of
boxes. Judging a device's advertised properties needs no hardware, so keeping it
separate makes it testable everywhere, matching the hardware-free-core split the
project already draws.

Also surfaced where someone choosing a mic will look: --list tags unsuitable
inputs, and --test-mic now exits 3 instead of reporting a clean pass on a device
it just warned about. A diagnostic that returns success on hardware it told you
not to use is how this stayed confusing.

Known blind spot, documented rather than papered over: wideband HFP negotiates
16 kHz and clears the rate check, and on Linux the name marker is usually absent,
so a clean result means "nothing detected", not "device is fine".

wake-20260727T1000-0b9a6b

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 394a805245

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread capture_quality.py Outdated
The rate check read default_samplerate and then asserted the device 'is
narrowband' and told the operator to use a different mic. That field is what
the device advertises, not a measured capability: under WASAPI it is the
shared-mode capture format and the limit is real, but under ALSA or CoreAudio
it can be a default the device exceeds, so the flat claim could send someone
to replace a working mic.

Reports the advertised figure and what it does and does not establish per
host, and softens --test-mic's RESULT line and exit-3 help to read as the
classifier's verdict rather than as ground truth. The hands-free branch keeps
its certainty, since a name match identifies the device outright.

Kept consulting the advertised rate rather than the opened one: a successful
open at 16 kHz is what auto_convert fakes, which is the bug this feature
exists to catch. Separating an advertised default from a hard limit needs a
spectral check on real audio, and that costs a capture at startup.

Detection unchanged, so the 23 checks still pass.
@jamditis

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: ff3b4a67f7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

The name and rate checks only see what a device chooses to report, which is
enough on Windows and not enough anywhere else. A bluez/PipeWire HFP source on
Linux exposes no hands-free marker and PipeWire resamples to 16 kHz and reports
16 kHz, so both existing signals read clean while the audio underneath came off
an 8 kHz transport. That hole was documented in the module and left open.

--test-mic already captures a few seconds, so judging that audio costs nothing
extra. Upsampling cannot invent content it never received, so an 8 kHz source
leaves the band above 4 kHz empty and the cliff at that edge is the evidence.

Measuring the cliff rather than total high-band energy is the part that took
work. Speech falls off steeply on its own, so a raw high-band fraction mostly
measures the tilt: on these signals a dull voice reads 0.00007% and a sloppily
resampled 8 kHz signal reads 0.005%, putting the genuine case on the wrong side
of the narrowband one. Comparing the 1 kHz band above the edge against the 1 kHz
below cancels the tilt and separates them by an order of magnitude in the right
order.

The threshold is measured rather than guessed, and _cliff_ratio is split out from
the verdict so a test can assert on the margin instead of only the verdict. That
test earned its place immediately: the first threshold sat under 2x from the
narrowband ceiling and had to move.

Refs #34.

wake-20260727T1200-f460a6
@jamditis

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 01f169cbc7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread audio.py Outdated
Comment thread audio.py Outdated
Comment thread capture_quality.py Outdated
@jamditis

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7b5c3f8cde

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread audio.py

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: e58b398ddf

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@jamditis
jamditis merged commit bf3ac02 into main Jul 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bluetooth HFP on Windows garbles continuous-speech STT; prefer USB audio

1 participant