Files
blurt/blurt.py
T
felixmandClaude Opus 5 6ad8526920 Add Voxtral Small 24B as a transcription option
Whisper large-v3 mis-segments unfamiliar proper nouns: "Whisper Large"
came back as "Wisp Alarge", and it leaves doubled words and sentence
breaks in the wrong places. Voxtral decodes with a 24B language model,
so ambiguous acoustics get resolved against what a sentence can mean.
Checked both against saved captures: Voxtral fixed the proper noun and
the punctuation, at 1.7 s round-trip for 15 s of audio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrC2xo3RUExhVPzWGnGfRK
2026-08-22 09:22:13 -04:00

1210 lines
43 KiB
Python

import base64
import io
import logging
import os
import random
import re
import shlex
import signal
import subprocess
import sys
import tempfile
import threading
import time
from dataclasses import dataclass
from logging.handlers import RotatingFileHandler
from math import gcd
from pathlib import Path
from typing import Callable
import numpy as np
import keyring
import pyperclip
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"
OPENROUTER_TRANSCRIPTIONS_URL = "https://openrouter.ai/api/v1/audio/transcriptions"
OPENROUTER_APP_URL = "https://git.felixm.de/felixm/blurt"
OPENROUTER_APP_TITLE = "Blurt"
DEFAULT_TRANSCRIPTION_MODEL = "whisper-1"
DEFAULT_TRANSCRIPTION_LANGUAGE = "en"
TRANSCRIPTION_MODELS = [
("whisper-1", "OpenAI: whisper-1", "openai"),
("gpt-4o-mini-transcribe", "OpenAI: gpt-4o-mini-transcribe", "openai"),
(
"gpt-4o-mini-transcribe-2025-12-15",
"OpenAI: gpt-4o-mini-transcribe (2025-12-15)",
"openai",
),
("gpt-4o-transcribe", "OpenAI: gpt-4o-transcribe", "openai"),
("gpt-4o-transcribe-diarize", "OpenAI: gpt-4o-transcribe-diarize", "openai"),
(
"openai/whisper-large-v3-turbo",
"OpenRouter: whisper-large-v3-turbo (fast)",
"openrouter",
),
(
"openai/whisper-large-v3",
"OpenRouter: whisper-large-v3 (accurate)",
"openrouter",
),
(
"openai/gpt-4o-mini-transcribe",
"OpenRouter: gpt-4o-mini-transcribe",
"openrouter",
),
(
"openai/gpt-4o-transcribe",
"OpenRouter: gpt-4o-transcribe",
"openrouter",
),
(
"mistralai/voxtral-small-24b-2507-stt",
"OpenRouter: voxtral-small-24b (LLM-based)",
"openrouter",
),
]
PROVIDER_KEY_HINTS = {
"openai": ("set OPENAI_API_KEY or configure keyring/Credential Manager entry blurt/openai-api-key"),
"openrouter": (
"set OPENROUTER_API_KEY or configure Windows Credential Manager entry "
"blurt/openrouter-api-key"
),
}
TRANSCRIPTION_LANGUAGES = [
("en", "English"),
("de", "German"),
]
SAMPLE_RATE = 16000
CHANNELS = 1
DTYPE = "float32" # capture with headroom; the WAV sent to the API is int16
WARMUP_SECONDS = 0.15 # most a stream-open click may cost us off the front
DEAD_AIR_SECONDS = 1.0 # digital silence this long is a dropped input, not a pause
DEAD_AIR_DBFS = -75.0 # below any real mic's self-noise: nothing arrived at all
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"
PID_FILE = Path(tempfile.gettempdir()) / "blurt.pid"
LOG_FILE = Path(tempfile.gettempdir()) / "blurt.log"
KEYRING_CREDENTIALS = [
("openai-api-key", "felixm"),
("blurt", "openai-api-key"),
]
OPENROUTER_KEYRING_CREDENTIALS = [
("openrouter-api-key", "felixm"),
("blurt", "openrouter-api-key"),
]
def setup_logging():
logger = logging.getLogger()
if logger.handlers:
return
logger.setLevel(logging.INFO)
handler = RotatingFileHandler(
LOG_FILE,
maxBytes=512 * 1024,
backupCount=3,
encoding="utf-8",
)
handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s [%(threadName)s] %(message)s")
)
logger.addHandler(handler)
def log_thread_exception(args):
logging.critical(
"Uncaught thread exception in %s",
args.thread.name if args.thread else "<unknown>",
exc_info=(args.exc_type, args.exc_value, args.exc_traceback),
)
threading.excepthook = log_thread_exception
def load_config_value(env_name: str, file_name: str, default: str = "") -> str:
env_value = os.environ.get(env_name, "").strip()
if env_value:
logging.info("Loaded %s from environment", env_name)
return env_value
config_file = APP_DIR / file_name
try:
value = config_file.read_text().strip()
logging.info(
"Loaded %s from %s: %s",
env_name,
config_file,
"present" if value else "empty",
)
return value
except FileNotFoundError:
if default:
logging.info("Config file not found, using default for %s: %s", env_name, default)
else:
logging.warning("Config file not found for %s: %s", env_name, config_file)
return default
def _load_key_from_keyring(credentials: list[tuple[str, str]], label: str) -> str:
for service_name, username in credentials:
try:
stored_key = keyring.get_password(service_name, username)
except Exception:
logging.exception(
"Could not read %s key from keyring entry %s/%s",
label,
service_name,
username,
)
continue
if stored_key:
logging.info("Loaded %s key from keyring entry %s/%s", label, service_name, username)
return stored_key.strip()
return ""
def load_api_key() -> str:
env_key = os.environ.get("OPENAI_API_KEY", "").strip()
if env_key:
logging.info("Loaded OPENAI_API_KEY from environment")
return env_key
key = _load_key_from_keyring(KEYRING_CREDENTIALS, "OpenAI")
if key:
return key
logging.warning("No OpenAI API key found in OPENAI_API_KEY or configured keyring entries")
return ""
def load_openrouter_key() -> str:
env_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
if env_key:
logging.info("Loaded OPENROUTER_API_KEY from environment")
return env_key
key = _load_key_from_keyring(OPENROUTER_KEYRING_CREDENTIALS, "OpenRouter")
if key:
return key
logging.warning(
"No OpenRouter API key found in OPENROUTER_API_KEY or configured keyring entries"
)
return ""
def load_transcription_model() -> str:
model = load_config_value(
"BLURT_TRANSCRIPTION_MODEL",
"model.txt",
DEFAULT_TRANSCRIPTION_MODEL,
)
valid_models = {model_id for model_id, _, _ in TRANSCRIPTION_MODELS}
if model not in valid_models:
logging.warning("Unknown transcription model %s, using %s", model, DEFAULT_TRANSCRIPTION_MODEL)
return DEFAULT_TRANSCRIPTION_MODEL
return model
def save_transcription_model(model: str):
model_file = APP_DIR / "model.txt"
model_file.write_text(f"{model}\n")
logging.info("Saved transcription model to %s: %s", model_file, model)
def load_transcription_language() -> str:
language = load_config_value(
"BLURT_TRANSCRIPTION_LANGUAGE",
"language.txt",
DEFAULT_TRANSCRIPTION_LANGUAGE,
)
valid_languages = {language_id for language_id, _ in TRANSCRIPTION_LANGUAGES}
if language not in valid_languages:
logging.warning(
"Unknown transcription language %s, using %s",
language,
DEFAULT_TRANSCRIPTION_LANGUAGE,
)
return DEFAULT_TRANSCRIPTION_LANGUAGE
return language
def save_transcription_language(language: str):
language_file = APP_DIR / "language.txt"
language_file.write_text(f"{language}\n")
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 leading_click_samples(samples: np.ndarray) -> int:
"""Length of the stream-open click, capped at WARMUP_SECONDS. Cutting a fixed
slice off the front of every clip costs a syllable on mics that do not click,
so cut only what is genuinely louder than the rest of the recording."""
cap = int(WARMUP_SECONDS * SAMPLE_RATE)
frame = SAMPLE_RATE // 200
if len(samples) < cap * 2:
return 0
centered = samples - float(samples.mean())
body = float(np.percentile(np.abs(centered[cap:]), 99.9))
head = np.abs(centered[:cap]).reshape(-1, frame).max(axis=1)
loud = np.flatnonzero(head > max(body * 2.0, 0.02))
return int(loud[-1] + 1) * frame if len(loud) else 0
def frame_levels(samples: np.ndarray) -> np.ndarray:
"""Per-20 ms RMS level in dBFS: the resolution speech energy lives at.
Each frame is centred on itself, so a steady DC bias — or a stream frozen
on one value — reads as the silence it actually is."""
frame = SAMPLE_RATE // 50
if len(samples) < frame * 2:
return np.full(1, -120.0)
usable = samples[: len(samples) // frame * frame].reshape(-1, frame)
centered = usable - usable.mean(axis=1, keepdims=True)
energy = np.sqrt(np.mean(np.square(centered), axis=1))
return 20 * np.log10(np.maximum(energy, 1e-6))
def speech_level_dbfs(samples: np.ndarray) -> float:
"""Level of the loudest tenth of the clip: the part that should be speech.
Robust to a click at stream start and to long pauses."""
return float(np.percentile(frame_levels(samples), 90))
def level_profile(samples: np.ndarray) -> dict:
"""Speech level, room-noise floor, and the longest run of nothing at all.
Even a silent room registers well above DEAD_AIR_DBFS on a live mic, so a
stretch below it is not the speaker pausing — it is audio that never came."""
levels = frame_levels(samples)
longest = run = 0
for level in levels:
run = run + 1 if level < DEAD_AIR_DBFS else 0
longest = max(longest, run)
return {
"speech_dbfs": float(np.percentile(levels, 90)),
"floor_dbfs": float(np.percentile(levels, 10)),
"dead_seconds": longest / 50.0,
}
def dump_capture(tag: str, samples: np.ndarray):
"""Keep a copy of the clip when BLURT_SAVE_CAPTURES is set, so a transcript
that came back short can be checked against what was actually recorded."""
target = os.environ.get("BLURT_SAVE_CAPTURES", "")
if not target:
return
directory = APP_DIR / "captures" if target == "1" else Path(target)
try:
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{time.strftime('%Y%m%d-%H%M%S')}-{tag}.wav"
wavfile.write(path, SAMPLE_RATE, samples)
logging.info("Saved %s capture to %s", tag, path)
except Exception:
logging.exception("Could not save %s capture to %s", tag, directory)
def condition_audio(samples: np.ndarray) -> np.ndarray:
"""Make raw capture look like what a Windows mic driver would have handed us:
no DC bias, speech at a predictable level."""
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
amplified = filtered * gain
limited = float(np.mean(np.abs(amplified) > 1.0) * 100) if len(amplified) else 0.0
logging.info(
"Conditioned audio: highpass=%gHz gain=%+.1f dB limited=%.3f%%",
HIGHPASS_HZ,
20 * np.log10(gain),
limited,
)
return np.clip(amplified, -1.0, 1.0)
def get_model_label(model: str) -> str:
for model_id, label, _ in TRANSCRIPTION_MODELS:
if model_id == model:
return label
return model
def get_language_label(language: str) -> str:
for language_id, label in TRANSCRIPTION_LANGUAGES:
if language_id == language:
return label
return language
@dataclass
class Platform:
notify: Callable[[str, str], None]
dispatch: Callable[[str], None]
open_logs: Callable[[], None]
setup_toggle_trigger: Callable[["BlurtApp"], None]
cleanup_toggle_trigger: Callable[[], None]
def _make_windows_platform() -> Platform:
from pynput import keyboard
from winotify import Notification
listener = None
def notify(title: str, message: str):
try:
toast = Notification(app_id=APP_NAME, title=title, msg=message)
toast.show()
except Exception:
logging.exception("Windows notification failed")
def dispatch(task: str):
wezterm = r"C:\Program Files\WezTerm\wezterm-gui.exe"
dispatch_sh = "/mnt/c/Users/felix.martin/AppData/Roaming/FlowLauncher/Plugins/Dispatch-1.0.0/dispatch.sh"
safe_query = shlex.quote(task)
cmd = [
wezterm,
"-e", "wsl.exe",
"-d", "Ubuntu",
"-e", "zsh",
"-c", f"{dispatch_sh} {safe_query}",
]
logging.info("Dispatching agent task with %d characters", len(task))
subprocess.Popen(cmd, creationflags=subprocess.DETACHED_PROCESS)
def open_logs():
subprocess.Popen(["notepad.exe", str(LOG_FILE)])
def setup_toggle_trigger(app: "BlurtApp"):
nonlocal listener
def on_press(key):
if key == keyboard.Key.f20:
app.toggle_recording()
listener = keyboard.Listener(on_press=on_press)
listener.start()
logging.info("Started Windows F20 hotkey listener")
def cleanup_toggle_trigger():
nonlocal listener
if listener:
listener.stop()
return Platform(
notify=notify,
dispatch=dispatch,
open_logs=open_logs,
setup_toggle_trigger=setup_toggle_trigger,
cleanup_toggle_trigger=cleanup_toggle_trigger,
)
def _make_linux_platform() -> Platform:
prev_handler = None
def notify(title: str, message: str):
try:
subprocess.Popen(
["notify-send", APP_NAME, f"{title}: {message}"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
logging.exception("Linux notification failed")
def dispatch(task: str):
dispatch_sh = Path.home() / ".local" / "bin" / "dispatch.sh"
safe_query = shlex.quote(task)
cmd = ["bash", str(dispatch_sh), safe_query]
logging.info("Dispatching agent task with %d characters", len(task))
subprocess.Popen(
cmd,
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def open_logs():
subprocess.Popen(
["xdg-open", str(LOG_FILE)],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def setup_toggle_trigger(app: "BlurtApp"):
nonlocal prev_handler
PID_FILE.write_text(str(os.getpid()))
logging.info("Wrote toggle PID file: %s", PID_FILE)
def on_sigusr1(signum, frame):
threading.Thread(target=app.toggle_recording, daemon=True).start()
prev_handler = signal.signal(signal.SIGUSR1, on_sigusr1)
def cleanup_toggle_trigger():
nonlocal prev_handler
if prev_handler is not None:
signal.signal(signal.SIGUSR1, prev_handler)
PID_FILE.unlink(missing_ok=True)
return Platform(
notify=notify,
dispatch=dispatch,
open_logs=open_logs,
setup_toggle_trigger=setup_toggle_trigger,
cleanup_toggle_trigger=cleanup_toggle_trigger,
)
def get_platform() -> Platform:
if sys.platform == "win32":
return _make_windows_platform()
return _make_linux_platform()
class AwakeKeeper:
"""Periodically clicks the Windows Start button area to prevent PC lock."""
def __init__(self):
from pynput.mouse import Controller, Button
self._mouse = Controller()
self._button = Button.left
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
@property
def is_active(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def start(self):
if self.is_active:
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run, daemon=True, name="AwakeKeeper"
)
self._thread.start()
logging.info("AwakeKeeper started")
def stop(self):
self._stop_event.set()
self._thread = None
logging.info("AwakeKeeper stopped")
def _run(self):
while not self._stop_event.wait(timeout=random.uniform(60, 120)):
x = random.randint(5, 20)
y = random.randint(5, 20)
self._mouse.position = (x, y)
self._mouse.click(self._button)
logging.debug("AwakeKeeper clicked at (%d, %d)", x, y)
def send_toggle():
"""Send toggle signal to running blurt instance (Linux only)."""
if sys.platform == "win32":
print("'blurt toggle' is not supported on Windows.", file=sys.stderr)
sys.exit(1)
try:
pid = int(PID_FILE.read_text().strip())
logging.info("Sending toggle signal to PID %s", pid)
os.kill(pid, signal.SIGUSR1)
except (FileNotFoundError, ValueError, ProcessLookupError):
print("No running blurt instance found.", file=sys.stderr)
sys.exit(1)
class BlurtApp:
def __init__(self, platform: Platform):
self.platform = platform
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.input_devices = self._get_input_devices()
self.device_name = load_input_device_name()
self.device_index = self._resolve_device(self.device_name)
if not self.device_name:
logging.warning(
"No input device pinned: recording follows the system default, which "
"the sound server is free to move to another microphone between (or "
"during) clips. Pick one under Input device in the tray menu."
)
self.api_keys = {
"openai": load_api_key(),
"openrouter": load_openrouter_key(),
}
self.transcription_model = self._available_model_or_fallback(
load_transcription_model()
)
self.transcription_language = load_transcription_language()
self.session = requests.Session()
self._awake_keeper: AwakeKeeper | None = (
AwakeKeeper() if sys.platform == "win32" else None
)
self.pending_transcriptions = 0
logging.info(
"BlurtApp initialized with %d input devices, model=%s, language=%s, awake_available=%s",
len(self.input_devices),
self.transcription_model,
self.transcription_language,
self._awake_keeper is not None,
)
def _get_input_devices(self) -> list[dict]:
devices = sd.query_devices()
self.all_input_devices = [
{"index": i, "name": d["name"]}
for i, d in enumerate(devices)
if d["max_input_channels"] > 0
]
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)
draw.ellipse([8, 8, 56, 56], fill=color)
return img
def _update_icon(self):
if self.icon:
if self.recording:
color = (220, 40, 40) # red
state = "Recording..."
elif self.pending_transcriptions > 0:
color = (40, 100, 220) # blue
state = "Transcribing..."
else:
color = (120, 120, 120) # gray
state = "Ready"
self.icon.icon = self._create_icon_image(color)
self.icon.title = f"{APP_NAME} - {state}"
self.icon.update_menu()
def _audio_callback(self, indata, frames, time_info, status):
if status:
logging.warning("sounddevice input status: %s", status)
self.audio_frames.append(indata.copy())
def _provider_for_model(self, model: str) -> str:
for model_id, _, provider in TRANSCRIPTION_MODELS:
if model_id == model:
return provider
return "openai"
def _available_model_or_fallback(self, model: str) -> str:
provider = self._provider_for_model(model)
if self.api_keys.get(provider):
return model
for fallback_model, _, fallback_provider in TRANSCRIPTION_MODELS:
if self.api_keys.get(fallback_provider):
logging.warning(
"Model %s requires a %s API key, which is unavailable; "
"using available model %s for this session",
model,
provider,
fallback_model,
)
return fallback_model
logging.error(
"Model %s requires a %s API key, but no API keys are available for any provider",
model,
provider,
)
return model
def _current_api_key(self) -> str:
return self.api_keys.get(self._provider_for_model(self.transcription_model), "")
def _api_key_label(self) -> str:
provider = self._provider_for_model(self.transcription_model)
provider_label = "OpenRouter" if provider == "openrouter" else "OpenAI"
key = self._current_api_key()
if not key:
return f"API key: {provider_label} (missing)"
return f"API key: {provider_label} (…{key[-4:]})"
def _start_recording(self):
if not self._current_api_key():
provider = self._provider_for_model(self.transcription_model)
key_hint = PROVIDER_KEY_HINTS.get(provider, f"set the {provider} API key")
logging.error(
"Recording blocked: model=%s provider=%s has no API key; "
"%s or select a model with an available key",
self.transcription_model,
provider,
key_hint,
)
self.platform.notify("Error", f"No {provider} API key; {key_hint}.")
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=self.capture_rate,
channels=self.capture_channels,
dtype=DTYPE,
callback=self._audio_callback,
)
self.stream.start()
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 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
self._update_icon()
self.platform.notify("Recording", "Press hotkey or click tray to stop.")
def _stop_recording(self) -> np.ndarray | None:
if self.stream:
self.stream.stop()
self.stream.close()
self.stream = None
self.recording = False
self._update_icon()
if not self.audio_frames:
logging.warning("Stopped recording with no audio frames")
return None
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)
dump_capture("raw", audio_data)
click = leading_click_samples(audio_data)
audio_data = audio_data[click:]
raw = audio_stats(audio_data)
profile = level_profile(audio_data)
speech_level = profile["speech_dbfs"]
logging.info(
"Stopped recording: samples=%d (%.1fs) click=%.0fms speech=%.1f dBFS "
"floor=%.1f dBFS rms=%.1f dBFS peak=%.1f dBFS dc=%+.4f clipped=%.3f%% "
"dead=%.1fs device=%s",
len(audio_data),
len(audio_data) / SAMPLE_RATE,
click * 1000 / SAMPLE_RATE,
speech_level,
profile["floor_dbfs"],
raw["rms_dbfs"],
raw["peak_dbfs"],
raw["dc"],
raw["clipped_pct"],
profile["dead_seconds"],
self.device_name or "<system default>",
)
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 profile["dead_seconds"] >= DEAD_AIR_SECONDS:
logging.warning(
"Capture holds %.1fs of digital silence, below any live mic's own "
"noise: the input dropped out mid-clip rather than the speaker "
"pausing. Pin an input device if this repeats. device=%s",
profile["dead_seconds"],
self.device_name or "<system default>",
)
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"],
)
sent = (conditioned * 32767).astype(np.int16)
dump_capture("sent", sent)
return sent
def _transcribe(self, audio_data: np.ndarray):
self.platform.notify(
"Transcribing",
f"Using {get_model_label(self.transcription_model)} ({self.transcription_language})",
)
logging.info("Starting transcription: samples=%d", len(audio_data))
buffer = io.BytesIO()
wavfile.write(buffer, SAMPLE_RATE, audio_data)
wav_bytes = buffer.getvalue()
logging.info("Encoded WAV in memory: %d bytes", len(wav_bytes))
try:
if self._provider_for_model(self.transcription_model) == "openrouter":
result = self._transcribe_openrouter(wav_bytes)
else:
result = self._transcribe_openai(wav_bytes)
text = result.get("text", "").lstrip()
logging.info("Transcription text length: %d", len(text))
if "usage" in result:
logging.info("Transcription usage: %s", result["usage"])
if not text:
error_msg = result.get("error", {}).get("message", "Unknown error")
logging.error("Transcription response did not include text: %s", result)
self.platform.notify("Error", f"Transcription failed: {error_msg}")
return
if text.lower().startswith(AGENT_PREFIX):
task = text[len(AGENT_PREFIX):].lstrip(" ,.:;-")
if task:
pyperclip.copy(task)
try:
self.platform.dispatch(task)
except Exception as e:
self.platform.notify("Dispatch Error", str(e))
preview = task[:50] + ("..." if len(task) > 50 else "")
self.platform.notify("Dispatched", preview)
logging.info("Dispatched transcription task with %d characters", len(task))
else:
pyperclip.copy(text)
self.platform.notify("Error", f"No task after '{AGENT_PREFIX}'")
logging.warning("Agent prefix detected with no task")
else:
pyperclip.copy(text)
preview = text[:50] + ("..." if len(text) > 50 else "")
self.platform.notify("Copied", preview)
logging.info("Copied transcription to clipboard")
except requests.RequestException as e:
logging.exception("API request failed")
self.platform.notify("Error", f"API request failed: {e}")
except Exception as e:
logging.exception("Unexpected transcription failure")
self.platform.notify("Error", f"Unexpected transcription failure: {e}")
finally:
self.pending_transcriptions -= 1
self._update_icon()
def _transcribe_openai(self, wav_bytes: bytes) -> dict:
data = {
"model": self.transcription_model,
"language": self.transcription_language,
}
if self.transcription_model == "gpt-4o-transcribe-diarize":
data.update(response_format="diarized_json", chunking_strategy="auto")
response = self.session.post(
OPENAI_TRANSCRIPTIONS_URL,
headers={"Authorization": f"Bearer {self.api_keys['openai']}"},
files={"file": ("blurt.wav", wav_bytes, "audio/wav")},
data=data,
timeout=30,
)
self._log_transcription_response(response)
response.raise_for_status()
return response.json()
def _transcribe_openrouter(self, wav_bytes: bytes) -> dict:
audio_b64 = base64.b64encode(wav_bytes).decode("ascii")
response = self.session.post(
OPENROUTER_TRANSCRIPTIONS_URL,
headers={
"Authorization": f"Bearer {self.api_keys['openrouter']}",
"HTTP-Referer": OPENROUTER_APP_URL,
"X-OpenRouter-Title": OPENROUTER_APP_TITLE,
},
json={
"model": self.transcription_model,
"input_audio": {"data": audio_b64, "format": "wav"},
"language": self.transcription_language,
},
timeout=30,
)
self._log_transcription_response(response)
response.raise_for_status()
return response.json()
def _log_transcription_response(self, response: requests.Response):
logging.info(
"Transcription response: status=%s content_type=%s bytes=%d",
response.status_code,
response.headers.get("content-type", ""),
len(response.content),
)
if not response.ok:
logging.error("Transcription error response: %s", response.text[:1000])
def toggle_recording(self):
with self.lock:
if not self.recording:
self._start_recording()
else:
audio_data = self._stop_recording()
if audio_data is not None:
self.pending_transcriptions += 1
self._update_icon()
t = threading.Thread(
target=self._transcribe, args=(audio_data,), daemon=True
)
t.start()
else:
self.platform.notify("Error", "No audio recorded.")
def _on_click(self, icon, item):
self.toggle_recording()
def _show_logs(self, icon, item):
try:
logging.info("Opening log file: %s", LOG_FILE)
self.platform.open_logs()
except Exception as e:
logging.exception("Could not open log file")
self.platform.notify("Error", f"Could not open log file: {e}")
def _toggle_awake(self, icon, item):
if self._awake_keeper is None:
return
if self._awake_keeper.is_active:
self._awake_keeper.stop()
else:
self._awake_keeper.start()
self.icon.update_menu()
def _select_model(self, model: str):
def handler(icon, item):
self.api_keys = {
"openai": load_api_key(),
"openrouter": load_openrouter_key(),
}
provider = self._provider_for_model(model)
if not self.api_keys.get(provider):
logging.error(
"Model selection blocked: model=%s provider=%s has no API key",
model,
provider,
)
self.platform.notify(
"Model Unavailable",
f"No {provider} API key is configured.",
)
self.icon.update_menu()
return
self.transcription_model = model
save_transcription_model(model)
self.icon.update_menu()
self.platform.notify("Model Selected", get_model_label(model))
return handler
def _select_language(self, language: str):
def handler(icon, item):
self.transcription_language = language
save_transcription_language(language)
self.icon.update_menu()
self.platform.notify("Language Selected", get_language_label(language))
return handler
def _is_model_selected(self, model: str):
def check(item):
return self.transcription_model == model
return check
def _is_provider_available(self, provider: str):
def check(item):
return bool(self.api_keys.get(provider))
return check
def _is_language_selected(self, language: str):
def check(item):
return self.transcription_language == language
return check
def _select_device(self, name: str):
def handler(icon, item):
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, name: str):
def check(item):
return self.device_name == name
return check
def _build_menu(self):
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["name"]),
checked=self._is_device_selected(dev["name"]),
radio=True,
)
)
model_items = []
for model, label, provider in TRANSCRIPTION_MODELS:
model_items.append(
pystray.MenuItem(
label,
self._select_model(model),
checked=self._is_model_selected(model),
enabled=self._is_provider_available(provider),
radio=True,
)
)
language_items = []
for language, label in TRANSCRIPTION_LANGUAGES:
language_items.append(
pystray.MenuItem(
label,
self._select_language(language),
checked=self._is_language_selected(language),
radio=True,
)
)
awake_items = []
if self._awake_keeper is not None:
awake_items.append(
pystray.MenuItem(
"Awake",
self._toggle_awake,
checked=lambda item: self._awake_keeper.is_active,
)
)
return pystray.Menu(
pystray.MenuItem(
lambda item: "Stop Recording" if self.recording else "Start Recording",
self._on_click,
default=True,
),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Input Device", pystray.Menu(*device_items)),
pystray.MenuItem("Model", pystray.Menu(*model_items)),
pystray.MenuItem(lambda item: self._api_key_label(), None, enabled=False),
pystray.MenuItem("Language", pystray.Menu(*language_items)),
pystray.MenuItem("Show Logs", self._show_logs),
*awake_items,
pystray.Menu.SEPARATOR,
pystray.MenuItem("Quit", self._on_quit),
)
def _on_quit(self, icon, item):
if self.recording:
self._stop_recording()
if self._awake_keeper and self._awake_keeper.is_active:
self._awake_keeper.stop()
self.platform.cleanup_toggle_trigger()
icon.stop()
def _setup(self, icon):
icon.visible = True
def run(self):
self.platform.setup_toggle_trigger(self)
logging.info("Starting tray icon")
self.icon = pystray.Icon(
APP_NAME,
icon=self._create_icon_image((120, 120, 120)),
title=f"{APP_NAME} - Ready",
menu=self._build_menu(),
)
self.icon.run(setup=self._setup)
def _silence_benign_gtk_warnings():
"""Drop a harmless, repeating Gdk-CRITICAL emitted by Gtk.StatusIcon.
pystray's GTK backend (the one qtile's XEmbed Systray widget needs) uses the
deprecated Gtk.StatusIcon, which logs this assertion on icon/menu updates:
gdk_window_thaw_toplevel_updates: assertion
'window->update_and_descendants_freeze_count > 0' failed
It is cosmetic. Suppress only that message and re-emit every other Gdk log so
genuine problems still surface.
"""
try:
from gi.repository import GLib
except Exception:
return
benign = "gdk_window_thaw_toplevel_updates"
def handler(domain, level, message, _user_data):
if benign not in message:
print(f"{domain}: {message}", file=sys.stderr)
GLib.log_set_handler(
"Gdk",
GLib.LogLevelFlags.LEVEL_CRITICAL
| GLib.LogLevelFlags.LEVEL_WARNING
| GLib.LogLevelFlags.FLAG_FATAL
| GLib.LogLevelFlags.FLAG_RECURSION,
handler,
None,
)
def main():
setup_logging()
logging.info("Starting %s argv=%s platform=%s log_file=%s", APP_NAME, sys.argv, sys.platform, LOG_FILE)
if len(sys.argv) > 1 and sys.argv[1] == "toggle":
send_toggle()
return
_silence_benign_gtk_warnings()
platform = get_platform()
app = BlurtApp(platform)
app.run()
if __name__ == "__main__":
main()