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:
@@ -8,3 +8,4 @@ __pycache__/
|
|||||||
# Blurt runtime config (per-machine)
|
# Blurt runtime config (per-machine)
|
||||||
model.txt
|
model.txt
|
||||||
language.txt
|
language.txt
|
||||||
|
device.txt
|
||||||
|
|||||||
@@ -42,7 +42,26 @@ Set from the tray menu, or via files / env vars:
|
|||||||
- **Model** — `model.txt` / `BLURT_TRANSCRIPTION_MODEL`. For lowest latency use
|
- **Model** — `model.txt` / `BLURT_TRANSCRIPTION_MODEL`. For lowest latency use
|
||||||
`openai/whisper-large-v3-turbo` (OpenRouter, served by Groq).
|
`openai/whisper-large-v3-turbo` (OpenRouter, served by Groq).
|
||||||
- **Language** — `language.txt` / `BLURT_TRANSCRIPTION_LANGUAGE` (`en`, `de`).
|
- **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
|
Logs are written to `blurt.log` in your temp directory (openable from the tray
|
||||||
menu).
|
menu).
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import io
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -11,6 +12,7 @@ import tempfile
|
|||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from math import gcd
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ import pystray
|
|||||||
import requests
|
import requests
|
||||||
import sounddevice as sd
|
import sounddevice as sd
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
|
from scipy import signal as dsp
|
||||||
from scipy.io import wavfile
|
from scipy.io import wavfile
|
||||||
|
|
||||||
OPENAI_TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcriptions"
|
OPENAI_TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcriptions"
|
||||||
@@ -73,7 +76,19 @@ TRANSCRIPTION_LANGUAGES = [
|
|||||||
]
|
]
|
||||||
SAMPLE_RATE = 16000
|
SAMPLE_RATE = 16000
|
||||||
CHANNELS = 1
|
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_NAME = "Blurt"
|
||||||
APP_DIR = Path(__file__).resolve().parent
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
AGENT_PREFIX = "to my agent"
|
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)
|
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:
|
def get_model_label(model: str) -> str:
|
||||||
for model_id, label, _ in TRANSCRIPTION_MODELS:
|
for model_id, label, _ in TRANSCRIPTION_MODELS:
|
||||||
if model_id == model:
|
if model_id == model:
|
||||||
@@ -431,10 +559,13 @@ class BlurtApp:
|
|||||||
self.recording = False
|
self.recording = False
|
||||||
self.audio_frames: list[np.ndarray] = []
|
self.audio_frames: list[np.ndarray] = []
|
||||||
self.stream: sd.InputStream | None = None
|
self.stream: sd.InputStream | None = None
|
||||||
|
self.capture_rate = SAMPLE_RATE
|
||||||
|
self.capture_channels = CHANNELS
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self.icon: pystray.Icon | None = None
|
self.icon: pystray.Icon | None = None
|
||||||
self.device_index: int | None = None
|
|
||||||
self.input_devices = self._get_input_devices()
|
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 = {
|
self.api_keys = {
|
||||||
"openai": load_api_key(),
|
"openai": load_api_key(),
|
||||||
"openrouter": load_openrouter_key(),
|
"openrouter": load_openrouter_key(),
|
||||||
@@ -458,15 +589,39 @@ class BlurtApp:
|
|||||||
|
|
||||||
def _get_input_devices(self) -> list[dict]:
|
def _get_input_devices(self) -> list[dict]:
|
||||||
devices = sd.query_devices()
|
devices = sd.query_devices()
|
||||||
input_devices = [
|
self.all_input_devices = [
|
||||||
{"index": i, "name": d["name"]}
|
{"index": i, "name": d["name"]}
|
||||||
for i, d in enumerate(devices)
|
for i, d in enumerate(devices)
|
||||||
if d["max_input_channels"] > 0
|
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"])
|
logging.info("Input device %s: %s", dev["index"], dev["name"])
|
||||||
|
input_devices.append(dev)
|
||||||
return input_devices
|
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:
|
def _create_icon_image(self, color: tuple[int, int, int]) -> Image.Image:
|
||||||
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
|
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
@@ -490,7 +645,7 @@ class BlurtApp:
|
|||||||
|
|
||||||
def _audio_callback(self, indata, frames, time_info, status):
|
def _audio_callback(self, indata, frames, time_info, status):
|
||||||
if status:
|
if status:
|
||||||
print(f"sounddevice: {status}", file=sys.stderr)
|
logging.warning("sounddevice input status: %s", status)
|
||||||
self.audio_frames.append(indata.copy())
|
self.audio_frames.append(indata.copy())
|
||||||
|
|
||||||
def _provider_for_model(self, model: str) -> str:
|
def _provider_for_model(self, model: str) -> str:
|
||||||
@@ -548,17 +703,28 @@ class BlurtApp:
|
|||||||
return
|
return
|
||||||
self.audio_frames = []
|
self.audio_frames = []
|
||||||
try:
|
try:
|
||||||
|
self.capture_rate, self.capture_channels = input_stream_settings(self.device_index)
|
||||||
self.stream = sd.InputStream(
|
self.stream = sd.InputStream(
|
||||||
device=self.device_index,
|
device=self.device_index,
|
||||||
samplerate=SAMPLE_RATE,
|
samplerate=self.capture_rate,
|
||||||
channels=CHANNELS,
|
channels=self.capture_channels,
|
||||||
dtype=DTYPE,
|
dtype=DTYPE,
|
||||||
callback=self._audio_callback,
|
callback=self._audio_callback,
|
||||||
)
|
)
|
||||||
self.stream.start()
|
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:
|
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}")
|
self.platform.notify("Error", f"Could not start recording: {e}")
|
||||||
return
|
return
|
||||||
self.recording = True
|
self.recording = True
|
||||||
@@ -575,15 +741,48 @@ class BlurtApp:
|
|||||||
if not self.audio_frames:
|
if not self.audio_frames:
|
||||||
logging.warning("Stopped recording with no audio frames")
|
logging.warning("Stopped recording with no audio frames")
|
||||||
return None
|
return None
|
||||||
audio_data = np.concatenate(self.audio_frames, axis=0)
|
frames = np.concatenate(self.audio_frames, axis=0)
|
||||||
self.audio_frames = []
|
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(
|
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),
|
len(audio_data),
|
||||||
audio_data.shape,
|
len(audio_data) / SAMPLE_RATE,
|
||||||
audio_data.dtype,
|
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):
|
def _transcribe(self, audio_data: np.ndarray):
|
||||||
self.platform.notify(
|
self.platform.notify(
|
||||||
@@ -777,25 +976,34 @@ class BlurtApp:
|
|||||||
return self.transcription_language == language
|
return self.transcription_language == language
|
||||||
return check
|
return check
|
||||||
|
|
||||||
def _select_device(self, device_index):
|
def _select_device(self, name: str):
|
||||||
def handler(icon, item):
|
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()
|
self.icon.update_menu()
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
def _is_device_selected(self, device_index):
|
def _is_device_selected(self, name: str):
|
||||||
def check(item):
|
def check(item):
|
||||||
return self.device_index == device_index
|
return self.device_name == name
|
||||||
return check
|
return check
|
||||||
|
|
||||||
def _build_menu(self):
|
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:
|
for dev in self.input_devices:
|
||||||
device_items.append(
|
device_items.append(
|
||||||
pystray.MenuItem(
|
pystray.MenuItem(
|
||||||
dev["name"],
|
dev["name"],
|
||||||
self._select_device(dev["index"]),
|
self._select_device(dev["name"]),
|
||||||
checked=self._is_device_selected(dev["index"]),
|
checked=self._is_device_selected(dev["name"]),
|
||||||
radio=True,
|
radio=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user