Fix Linux capture: negotiate rate, condition audio, pin the device

Transcripts were far worse on Linux than on Windows for the same model, because
of what reached the API rather than the model itself.

Picking any mic from the tray menu never worked: PortAudio exposes the friendly
names through PipeWire's JACK host API, which only accepts 48 kHz, so opening at
16 kHz failed outright and recording always fell back to the system default —
whichever source PipeWire happened to prefer at that moment. That source is
often the raw AMD digital mic, which ships a +0.14 FS DC bias, a full-scale
clipped click in the first 15 ms of every stream, and speech around -50 dBFS.
Windows' vendor driver hides all of this; PipeWire hands over the raw stream.

- Negotiate rate and channels per device, then downmix and resample with
  resample_poly, so every listed device can actually be recorded from.
- Hide devices that cannot work: playback endpoints JACK re-exposes as inputs,
  and raw ALSA hw: devices a sound server already owns (acp: hw:2,0 blocks
  forever on open).
- Persist the chosen device by name in device.txt; PortAudio indices shift as
  USB gear comes and goes. Add a "System default" menu entry.
- Condition every clip before upload: trim the warm-up click, high-pass out DC,
  normalise towards -20 dBFS under a -1 dBFS ceiling.
- Refuse near-silent clips instead of paying for a hallucination, and log
  capture levels (speech, rms, peak, DC, clipping) plus PortAudio status
  warnings, which previously went to a discarded stderr.
- Import scipy.signal as dsp: it was shadowing the stdlib signal module that
  the SIGUSR1 hotkey toggle depends on.

mic_check.py records from Blurt's configured input, reports levels before and
after conditioning, saves the WAV, and transcribes it with the current model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 18:58:39 -04:00
co-authored by Claude Opus 5
parent 0ccada8522
commit e3faf32f55
4 changed files with 348 additions and 22 deletions
+1
View File
@@ -8,3 +8,4 @@ __pycache__/
# Blurt runtime config (per-machine)
model.txt
language.txt
device.txt
+20 -1
View File
@@ -42,7 +42,26 @@ Set from the tray menu, or via files / env vars:
- **Model** — `model.txt` / `BLURT_TRANSCRIPTION_MODEL`. For lowest latency use
`openai/whisper-large-v3-turbo` (OpenRouter, served by Groq).
- **Language** — `language.txt` / `BLURT_TRANSCRIPTION_LANGUAGE` (`en`, `de`).
- **Input device** — tray menu.
- **Input device** — tray menu (persisted by name in `device.txt` /
`BLURT_INPUT_DEVICE`). Pick an explicit mic: PortAudio device indices shift
when USB gear comes and goes, and the OS default source can change under you.
The menu hides playback endpoints and raw ALSA `hw:` devices that the sound
server already owns; `BLURT_INPUT_DEVICE` can still name one directly.
Devices that refuse 16 kHz (raw ALSA and PipeWire's JACK endpoints do) are
captured at their native rate and resampled.
Every clip is conditioned before upload — the first 150 ms are dropped (mics
click when the stream opens), DC bias is filtered out, and the level is
normalised towards -20 dBFS. Near-silent clips are refused instead of being
sent, since transcription models answer silence with confident hallucinations.
## Checking the microphone
`uv run mic_check.py [device] [seconds]` records from Blurt's configured input,
prints levels before and after conditioning, saves the WAV it would have
uploaded, and transcribes it with the current model. Use it when transcripts
come back wrong: bad audio shows up as a DC bias, clipping, or a speech level
far below -30 dBFS.
Logs are written to `blurt.log` in your temp directory (openable from the tray
menu).
+229 -21
View File
@@ -3,6 +3,7 @@ import io
import logging
import os
import random
import re
import shlex
import signal
import subprocess
@@ -11,6 +12,7 @@ import tempfile
import threading
from dataclasses import dataclass
from logging.handlers import RotatingFileHandler
from math import gcd
from pathlib import Path
from typing import Callable
@@ -21,6 +23,7 @@ import pystray
import requests
import sounddevice as sd
from PIL import Image, ImageDraw
from scipy import signal as dsp
from scipy.io import wavfile
OPENAI_TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcriptions"
@@ -73,7 +76,19 @@ TRANSCRIPTION_LANGUAGES = [
]
SAMPLE_RATE = 16000
CHANNELS = 1
DTYPE = "int16"
DTYPE = "float32" # capture with headroom; the WAV sent to the API is int16
WARMUP_SECONDS = 0.15 # drop the click many mics emit when the stream opens
HIGHPASS_HZ = 70.0 # strip DC bias and rumble (raw digital mics carry a lot)
TARGET_RMS_DBFS = -20.0
PEAK_CEILING_DBFS = -1.0
MAX_GAIN_DB = 30.0
SILENT_RMS_DBFS = -65.0
# Playback endpoints that JACK re-exposes as inputs, and raw ALSA `hw:` devices
# that a sound server already owns — offering either in the menu only invites a
# hang or a silent recording.
OUTPUT_ENDPOINT_HINTS = ("HDMI", "DisplayPort", "Headphones")
RAW_ALSA_DEVICE = re.compile(r"\(hw:\d+,\d+\)")
SOUND_SERVER_DEVICES = ("pipewire", "pulse", "default")
APP_NAME = "Blurt"
APP_DIR = Path(__file__).resolve().parent
AGENT_PREFIX = "to my agent"
@@ -230,6 +245,119 @@ def save_transcription_language(language: str):
logging.info("Saved transcription language to %s: %s", language_file, language)
def load_input_device_name() -> str:
return load_config_value("BLURT_INPUT_DEVICE", "device.txt")
def save_input_device_name(name: str):
device_file = APP_DIR / "device.txt"
device_file.write_text(f"{name}\n")
logging.info("Saved input device to %s: %s", device_file, name or "<system default>")
def audio_stats(samples: np.ndarray) -> dict:
"""Level report for a float32 mono clip, in the units that explain bad audio."""
if len(samples) == 0:
return {"rms_dbfs": -120.0, "peak_dbfs": -120.0, "dc": 0.0, "clipped_pct": 0.0}
dc = float(samples.mean())
centered = samples - dc
rms = float(np.sqrt(np.mean(np.square(centered))))
peak = float(np.abs(samples).max())
return {
"rms_dbfs": 20 * np.log10(max(rms, 1e-6)),
"peak_dbfs": 20 * np.log10(max(peak, 1e-6)),
"dc": dc,
"clipped_pct": float((np.abs(samples) >= 0.999).mean() * 100),
}
def input_stream_settings(device) -> tuple[int, int]:
"""Rate and channel count the device will actually open with.
Only the resampling backends (`pipewire`, `default`) accept 16 kHz mono:
raw ALSA `hw:` devices and PipeWire's JACK endpoints run at their own rate,
so we capture natively and resample ourselves."""
info = sd.query_devices(device, "input")
native_rate = int(info["default_samplerate"])
max_channels = max(int(info["max_input_channels"]), CHANNELS)
for rate in dict.fromkeys((SAMPLE_RATE, native_rate)):
for channels in dict.fromkeys((CHANNELS, max_channels)):
try:
sd.check_input_settings(
device=device, samplerate=rate, channels=channels, dtype=DTYPE
)
except Exception:
continue
return rate, channels
logging.warning(
"No probed capture format worked for %s; trying native %d Hz / %d ch",
info["name"],
native_rate,
max_channels,
)
return native_rate, max_channels
def resample_to_target(samples: np.ndarray, rate: int) -> np.ndarray:
if rate == SAMPLE_RATE:
return samples
divisor = gcd(rate, SAMPLE_RATE)
resampled = dsp.resample_poly(samples, SAMPLE_RATE // divisor, rate // divisor)
logging.info("Resampled %d Hz -> %d Hz", rate, SAMPLE_RATE)
return resampled.astype(np.float32)
def without_warmup(samples: np.ndarray) -> np.ndarray:
"""The clip minus the stream-open click, which otherwise skews every stat."""
warmup = int(WARMUP_SECONDS * SAMPLE_RATE)
return samples[warmup:] if len(samples) > warmup * 2 else samples
def speech_level_dbfs(samples: np.ndarray) -> float:
"""Level of the loudest tenth of the clip: the part that should be speech.
Robust to the click at stream start and to long pauses."""
frame = SAMPLE_RATE // 50
if len(samples) < frame * 2:
return -120.0
centered = samples - samples.mean()
usable = centered[: len(centered) // frame * frame].reshape(-1, frame)
energy = np.sqrt(np.mean(np.square(usable), axis=1))
return 20 * np.log10(max(float(np.percentile(energy, 90)), 1e-6))
def condition_audio(samples: np.ndarray) -> np.ndarray:
"""Make raw capture look like what a Windows mic driver would have handed us:
no start-up click, no DC bias, speech at a predictable level."""
trimmed = len(samples)
samples = without_warmup(samples)
trimmed -= len(samples)
sos = dsp.butter(2, HIGHPASS_HZ, btype="highpass", fs=SAMPLE_RATE, output="sos")
# Seed the filter with the first sample, or the DC step at t=0 rings for
# ~100 ms and lands right on top of the first word.
zi = dsp.sosfilt_zi(sos) * (samples[0] if len(samples) else 0.0)
filtered = dsp.sosfilt(sos, samples, zi=zi)[0].astype(np.float32)
rms = float(np.sqrt(np.mean(np.square(filtered)))) if len(filtered) else 0.0
# 99.9th percentile, not the max: one keyboard clack must not hold the
# whole clip 20 dB down. Stray peaks above the ceiling get clipped below.
peak = float(np.percentile(np.abs(filtered), 99.9)) if len(filtered) else 0.0
if rms > 0 and peak > 0:
gain = min(
10 ** ((TARGET_RMS_DBFS - 20 * np.log10(rms)) / 20),
10 ** ((PEAK_CEILING_DBFS - 20 * np.log10(peak)) / 20),
10 ** (MAX_GAIN_DB / 20),
)
else:
gain = 1.0
logging.info(
"Conditioned audio: trimmed=%d samples highpass=%gHz gain=%+.1f dB",
trimmed,
HIGHPASS_HZ,
20 * np.log10(gain),
)
return np.clip(filtered * gain, -1.0, 1.0)
def get_model_label(model: str) -> str:
for model_id, label, _ in TRANSCRIPTION_MODELS:
if model_id == model:
@@ -431,10 +559,13 @@ class BlurtApp:
self.recording = False
self.audio_frames: list[np.ndarray] = []
self.stream: sd.InputStream | None = None
self.capture_rate = SAMPLE_RATE
self.capture_channels = CHANNELS
self.lock = threading.Lock()
self.icon: pystray.Icon | None = None
self.device_index: int | None = None
self.input_devices = self._get_input_devices()
self.device_name = load_input_device_name()
self.device_index = self._resolve_device(self.device_name)
self.api_keys = {
"openai": load_api_key(),
"openrouter": load_openrouter_key(),
@@ -458,15 +589,39 @@ class BlurtApp:
def _get_input_devices(self) -> list[dict]:
devices = sd.query_devices()
input_devices = [
self.all_input_devices = [
{"index": i, "name": d["name"]}
for i, d in enumerate(devices)
if d["max_input_channels"] > 0
]
for dev in input_devices:
input_devices = []
server_present = any(
dev["name"] in SOUND_SERVER_DEVICES for dev in self.all_input_devices
)
for dev in self.all_input_devices:
if any(hint in dev["name"] for hint in OUTPUT_ENDPOINT_HINTS):
logging.info("Skipping output endpoint %s: %s", dev["index"], dev["name"])
continue
if server_present and RAW_ALSA_DEVICE.search(dev["name"]):
logging.info("Skipping raw ALSA device %s: %s", dev["index"], dev["name"])
continue
logging.info("Input device %s: %s", dev["index"], dev["name"])
input_devices.append(dev)
return input_devices
def _resolve_device(self, name: str) -> int | None:
"""Devices are addressed by name, not index: PortAudio indices shift
whenever a USB mic or dock comes and goes."""
if not name:
logging.info("No input device configured; recording from the system default")
return None
for dev in self.all_input_devices:
if dev["name"] == name:
logging.info("Using configured input device %s: %s", dev["index"], name)
return dev["index"]
logging.warning("Configured input device %r is not connected; using system default", name)
return None
def _create_icon_image(self, color: tuple[int, int, int]) -> Image.Image:
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
@@ -490,7 +645,7 @@ class BlurtApp:
def _audio_callback(self, indata, frames, time_info, status):
if status:
print(f"sounddevice: {status}", file=sys.stderr)
logging.warning("sounddevice input status: %s", status)
self.audio_frames.append(indata.copy())
def _provider_for_model(self, model: str) -> str:
@@ -548,17 +703,28 @@ class BlurtApp:
return
self.audio_frames = []
try:
self.capture_rate, self.capture_channels = input_stream_settings(self.device_index)
self.stream = sd.InputStream(
device=self.device_index,
samplerate=SAMPLE_RATE,
channels=CHANNELS,
samplerate=self.capture_rate,
channels=self.capture_channels,
dtype=DTYPE,
callback=self._audio_callback,
)
self.stream.start()
logging.info("Started recording with device_index=%s", self.device_index)
logging.info(
"Started recording: device_index=%s name=%s rate=%d channels=%d",
self.device_index,
self.device_name or "<system default>",
self.capture_rate,
self.capture_channels,
)
except Exception as e:
logging.exception("Could not start recording")
logging.exception(
"Could not start recording on device_index=%s name=%s",
self.device_index,
self.device_name or "<system default>",
)
self.platform.notify("Error", f"Could not start recording: {e}")
return
self.recording = True
@@ -575,15 +741,48 @@ class BlurtApp:
if not self.audio_frames:
logging.warning("Stopped recording with no audio frames")
return None
audio_data = np.concatenate(self.audio_frames, axis=0)
frames = np.concatenate(self.audio_frames, axis=0)
self.audio_frames = []
audio_data = frames.mean(axis=1) if frames.shape[1] > 1 else frames[:, 0]
audio_data = resample_to_target(audio_data, self.capture_rate)
raw = audio_stats(without_warmup(audio_data))
speech_level = speech_level_dbfs(without_warmup(audio_data))
logging.info(
"Stopped recording: samples=%d shape=%s dtype=%s",
"Stopped recording: samples=%d (%.1fs) speech=%.1f dBFS rms=%.1f dBFS "
"peak=%.1f dBFS dc=%+.4f clipped=%.3f%% device=%s",
len(audio_data),
audio_data.shape,
audio_data.dtype,
len(audio_data) / SAMPLE_RATE,
speech_level,
raw["rms_dbfs"],
raw["peak_dbfs"],
raw["dc"],
raw["clipped_pct"],
self.device_name or "<system default>",
)
return audio_data
if speech_level < SILENT_RMS_DBFS:
logging.error(
"Capture is essentially silent (speech level %.1f dBFS) on device %s; "
"not sending it for transcription",
speech_level,
self.device_name or "<system default>",
)
return None
if raw["clipped_pct"] > 1.0:
logging.warning(
"Capture clips on %.2f%% of samples; lower the input gain for %s",
raw["clipped_pct"],
self.device_name or "<system default>",
)
conditioned = condition_audio(audio_data)
clean = audio_stats(conditioned)
logging.info(
"Conditioned capture: rms=%.1f dBFS peak=%.1f dBFS dc=%+.4f",
clean["rms_dbfs"],
clean["peak_dbfs"],
clean["dc"],
)
return (conditioned * 32767).astype(np.int16)
def _transcribe(self, audio_data: np.ndarray):
self.platform.notify(
@@ -777,25 +976,34 @@ class BlurtApp:
return self.transcription_language == language
return check
def _select_device(self, device_index):
def _select_device(self, name: str):
def handler(icon, item):
self.device_index = device_index
self.device_name = name
self.device_index = self._resolve_device(name)
save_input_device_name(name)
self.icon.update_menu()
return handler
def _is_device_selected(self, device_index):
def _is_device_selected(self, name: str):
def check(item):
return self.device_index == device_index
return self.device_name == name
return check
def _build_menu(self):
device_items = []
device_items = [
pystray.MenuItem(
"System default",
self._select_device(""),
checked=self._is_device_selected(""),
radio=True,
)
]
for dev in self.input_devices:
device_items.append(
pystray.MenuItem(
dev["name"],
self._select_device(dev["index"]),
checked=self._is_device_selected(dev["index"]),
self._select_device(dev["name"]),
checked=self._is_device_selected(dev["name"]),
radio=True,
)
)
+98
View File
@@ -0,0 +1,98 @@
"""Diagnose Blurt's capture path: record, measure, transcribe, save the WAV.
Usage: uv run mic_check.py [device] [seconds]
device is a sounddevice index or name substring; omitted = whatever
Blurt itself is configured to use (device.txt, else system default).
"""
import io
import sys
import tempfile
from pathlib import Path
import numpy as np
import sounddevice as sd
from scipy.io import wavfile
import blurt
OUT_WAV = Path(tempfile.gettempdir()) / "blurt_mic_check.wav"
def report(label: str, samples: np.ndarray) -> None:
s = blurt.audio_stats(samples)
frame = blurt.SAMPLE_RATE // 50
energy = np.array([
np.sqrt(np.mean(np.square(samples[i:i + frame])) + 1e-20)
for i in range(0, max(len(samples) - frame, 1), frame)
])
snr = 20 * np.log10(np.percentile(energy, 90) / (np.percentile(energy, 10) + 1e-12))
print(f" {label}")
print(f" rms {s['rms_dbfs']:+7.1f} dBFS (speech wants -30..-15)")
print(f" peak {s['peak_dbfs']:+7.1f} dBFS")
print(f" dc bias {s['dc']:+7.4f} (wants ~0.0000)")
print(f" clipped {s['clipped_pct']:7.3f} %")
print(f" loud/quiet spread {snr:.1f} dB (real speech wants > 25)")
def main() -> None:
args = sys.argv[1:]
device = blurt.load_input_device_name() or None
if args and args[0]:
try:
device = int(args[0])
except ValueError:
device = args[0]
seconds = float(args[1]) if len(args) > 1 else 6.0
print("Input devices:")
for i, d in enumerate(sd.query_devices()):
if d["max_input_channels"] > 0:
print(f" {i:>3} {d['name']}")
resolved = sd.query_devices(device, "input") if device else sd.query_devices(kind="input")
rate, channels = blurt.input_stream_settings(device)
print(f"\nRecording {seconds:g}s from: {resolved['name']} at {rate} Hz / {channels} ch — speak now.")
frames = sd.rec(
int(seconds * rate),
samplerate=rate,
channels=channels,
dtype=blurt.DTYPE,
device=device,
)
sd.wait()
audio = frames.mean(axis=1) if channels > 1 else frames[:, 0]
audio = blurt.resample_to_target(audio, rate)
print("\nCapture:")
report("raw", audio)
conditioned = blurt.condition_audio(audio)
report("after Blurt conditioning", conditioned)
buffer = io.BytesIO()
wavfile.write(buffer, blurt.SAMPLE_RATE, (conditioned * 32767).astype(np.int16))
wav_bytes = buffer.getvalue()
OUT_WAV.write_bytes(wav_bytes)
print(f"\nWAV saved: {OUT_WAV} ({len(wav_bytes)} bytes) — play it to hear what the API hears")
app = blurt.BlurtApp.__new__(blurt.BlurtApp)
app.api_keys = {"openai": blurt.load_api_key(), "openrouter": blurt.load_openrouter_key()}
app.transcription_model = blurt.load_transcription_model()
app.transcription_language = blurt.load_transcription_language()
app.session = blurt.requests.Session()
provider = app._provider_for_model(app.transcription_model)
if not app.api_keys.get(provider):
print(f"\nNo {provider} key available; skipping transcription.")
return
print(f"\nTranscribing with {app.transcription_model} ({app.transcription_language})…")
result = (
app._transcribe_openrouter(wav_bytes)
if provider == "openrouter"
else app._transcribe_openai(wav_bytes)
)
print("\n--- transcript ---")
print(result.get("text", result))
if __name__ == "__main__":
main()