diff --git a/.gitignore b/.gitignore index b663f3b..2d74285 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ __pycache__/ # Blurt runtime config (per-machine) model.txt language.txt +device.txt diff --git a/README.md b/README.md index 25c6d89..ccf5acd 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/blurt.py b/blurt.py index 76d97d4..73712c7 100644 --- a/blurt.py +++ b/blurt.py @@ -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 "") + + +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 "", + 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 "", + ) 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 "", ) - 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 "", + ) + 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 "", + ) + + 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, ) ) diff --git a/mic_check.py b/mic_check.py new file mode 100644 index 0000000..44a362c --- /dev/null +++ b/mic_check.py @@ -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()