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:
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user