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 concurrent.futures import ThreadPoolExecutor 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 from blurt_client import TranscriptionClient, TranscriptionConfig from blurt_session import RecordingSession, RecoveryStore, TranscriptionScheduler 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", ), ( "microsoft/mai-transcribe-2", "OpenRouter: MAI-Transcribe 2", "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 SPOOL_DIR = APP_DIR / "spool" # clips wait here until their transcript is delivered AGENT_PREFIX = "to my agent" TIMEOUT_CONNECT_SECONDS = 10.0 # reaching the API is fast or not happening TIMEOUT_BASE_SECONDS = 30.0 # enough for a short clip, upload included TIMEOUT_PER_AUDIO_MINUTE = 30.0 # LLM-based models take minutes per minute of audio TIMEOUT_MAX_SECONDS = 300.0 # past this the request is hung, not working CHUNK_TARGET_SECONDS = 240 # where we would like each cut to land CHUNK_MAX_SECONDS = 300 # OpenAI refuses uploads past 25 MB, and LLM models crawl CHUNK_SEARCH_SECONDS = 30 # how far either side of the target a pause may be CHUNK_MIN_LAST_SECONDS = 60 # a tail shorter than this is not worth its own request CHUNK_SMOOTH_FRAMES = 15 # 300 ms: cut inside a real pause, not between two words CHUNK_WORKERS = 3 # parallel uploads; the clip is recorded, only latency is left 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 "", 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 "") 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 transcription_timeout(seconds_of_audio: float) -> tuple[float, float]: """Connect and read timeouts for one upload. A fixed read timeout throws away exactly the long recordings that took the most effort to make.""" read = TIMEOUT_BASE_SECONDS + TIMEOUT_PER_AUDIO_MINUTE * (seconds_of_audio / 60.0) return TIMEOUT_CONNECT_SECONDS, min(read, TIMEOUT_MAX_SECONDS) def split_at_silence(samples: np.ndarray) -> list[tuple[int, int]]: """Sample ranges to upload separately, in order and covering the whole clip. Cutting is only worth its complexity because a long clip is slow and, past 25 MB, refused outright — so cut as rarely as the limit allows, and put each cut in the middle of a pause where losing a few samples costs no word.""" total = len(samples) if total <= CHUNK_MAX_SECONDS * SAMPLE_RATE: return [(0, total)] frame = SAMPLE_RATE // 50 # frame_levels reads dBFS off a float clip in [-1, 1]; the audio we cut is # int16, so scale it or every level logged here is 90 dB too loud. scaled = samples.astype(np.float32) / 32768.0 if samples.dtype == np.int16 else samples levels = frame_levels(scaled) smoothed = np.convolve( levels, np.ones(CHUNK_SMOOTH_FRAMES) / CHUNK_SMOOTH_FRAMES, mode="same" ) ranges: list[tuple[int, int]] = [] start = 0 while total - start > CHUNK_MAX_SECONDS * SAMPLE_RATE: # Never exceed the upload limit, and never leave a tail too short to be # worth a request of its own. latest = min( start + int((CHUNK_TARGET_SECONDS + CHUNK_SEARCH_SECONDS) * SAMPLE_RATE), start + CHUNK_MAX_SECONDS * SAMPLE_RATE, total - CHUNK_MIN_LAST_SECONDS * SAMPLE_RATE, ) earliest = min( start + int((CHUNK_TARGET_SECONDS - CHUNK_SEARCH_SECONDS) * SAMPLE_RATE), latest, ) first_frame = max(earliest // frame, 0) last_frame = min(latest // frame, len(smoothed) - 1) if last_frame <= first_frame: cut, level = latest, float(smoothed[min(first_frame, len(smoothed) - 1)]) else: window = smoothed[first_frame : last_frame + 1] quietest = first_frame + int(np.argmin(window)) # Frames within 3 dB of the quietest are all the same pause; its # centre is the furthest we can get from either neighbouring word. floor = smoothed[quietest] + 3.0 left = right = quietest while left > first_frame and smoothed[left - 1] <= floor: left -= 1 while right < last_frame and smoothed[right + 1] <= floor: right += 1 level = float(smoothed[quietest]) cut = min((left + right) // 2 * frame + frame // 2, latest) cut = max(cut, start + 1) logging.info( "Chunk %d cut at %.1fs: quietest point in the window is %.1f dBFS, " "%.1fs of clip left", len(ranges) + 1, cut / SAMPLE_RATE, level, (total - cut) / SAMPLE_RATE, ) ranges.append((start, cut)) start = cut ranges.append((start, total)) return ranges def _spool_path(name: str) -> Path: """A free path for `name`. Two clips landing in the same millisecond is unlikely, but overwriting one of them would lose it for good.""" path = SPOOL_DIR / f"{name}.wav" counter = 1 while path.exists(): path = SPOOL_DIR / f"{name}-{counter}.wav" counter += 1 return path def spool_stem() -> str: """Timestamp down to the millisecond, zero-padded so names still sort oldest first.""" now = time.time() return f"{time.strftime('%Y%m%d-%H%M%S', time.localtime(now))}-{int(now * 1000) % 1000:03d}" def _write_spool(samples: np.ndarray, name: str) -> Path | None: """Write one clip into the spool, or None if the spool is unwritable. The WAV lands under a `.tmp` name and is renamed into place, so a retry globbing the folder mid-write never picks up half a file.""" try: SPOOL_DIR.mkdir(parents=True, exist_ok=True) path = _spool_path(name) partial = path.with_name(f"{path.name}.tmp") wavfile.write(partial, SAMPLE_RATE, samples) os.replace(partial, path) return path except Exception: logging.exception("Could not write %s to the spool %s", name, SPOOL_DIR) return None def spool_capture(samples: np.ndarray) -> Path | None: """Park the clip on disk before uploading it, so a failed request costs a retry instead of the words. Returns None if the spool is unwritable — disk trouble must not stop us from transcribing what is already in memory.""" path = _write_spool(samples, spool_stem()) if path is None: logging.error("Transcribing from memory without a safety net") return None logging.info("Spooled clip to %s: %d samples", path, len(samples)) return path def spool_chunk(samples: np.ndarray, stem: str, index: int) -> Path | None: """Park one failed chunk under its own name, so the retry re-uploads only the audio that is actually missing instead of the whole clip.""" path = _write_spool(samples, f"{stem}-part{index:02d}") if path is None: logging.error("Failed chunk %d is not in the spool; its audio is gone", index) return None logging.info("Spooled failed chunk %d to %s: %d samples", index, path, len(samples)) return path def load_spooled_capture(path: Path) -> np.ndarray | None: """Read back a spooled clip, refusing anything we did not write ourselves: a stray WAV in the folder would otherwise be uploaded at the wrong rate.""" try: rate, samples = wavfile.read(path) except Exception: logging.exception("Could not read spooled clip %s; leaving it in place", path) return None if rate != SAMPLE_RATE or samples.dtype != np.int16 or samples.ndim != 1: logging.warning( "Skipping spooled clip %s: expected int16 mono at %d Hz, got %s %s at %d Hz", path, SAMPLE_RATE, samples.dtype, "mono" if samples.ndim == 1 else f"{samples.ndim}-D", rate, ) return None return samples class TranscriptionError(Exception): """The API answered but gave us no usable text, so the clip stays spooled.""" def error_text(error: BaseException) -> str: return f"{type(error).__name__}: {error}" def rejected_status(error: BaseException) -> int | None: """The HTTP status if the API refused the request itself, else None. A 4xx that is not 408 or 429 says the upload was wrong, not unlucky: the same audio can only be rejected again, so keeping it would leave a clip in the spool that no retry can ever clear.""" if not isinstance(error, requests.HTTPError): return None status = getattr(getattr(error, "response", None), "status_code", None) if isinstance(status, int) and 400 <= status < 500 and status not in (408, 429): return status return None def response_snippet(error: BaseException) -> str: return str(getattr(getattr(error, "response", None), "text", ""))[:200] 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() # Redraws come from every chunk worker at once; some tray backends do not # survive that. Never taken while self.lock is held, and never takes it. self._icon_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 self.chunks_total = 0 self.chunks_done = 0 self._inflight_spool: set[Path] = set() self.streaming = os.environ.get("BLURT_STREAMING", "1") != "0" self._client = TranscriptionClient() self._scheduler = TranscriptionScheduler(self._client.transcribe, workers=CHUNK_WORKERS) self._recovery = RecoveryStore(SPOOL_DIR) self._live_lock = threading.RLock() self._delivery_lock = threading.Lock() self._live_sessions: list[RecordingSession] = [] self._recording_session: RecordingSession | None = None self._retrying = False self._retry_thread: threading.Thread | None = None self._quitting = False self._meter_stop = threading.Event() self._meter_frame = None self._meter_level = 0.0 self._meter_icons = [self._create_recording_image(i / 11) for i in range(12)] spooled = self._spooled_files() if spooled: logging.warning( "%d clip(s) left in the spool %s by an earlier failure; " "retry them from the tray menu", len(spooled), SPOOL_DIR, ) 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], progress: float | None = None ) -> Image.Image: img = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) if progress is None: draw.ellipse([8, 8, 56, 56], fill=color) return img # Dim disc for the whole clip, full colour for the finished fraction, # filling clockwise from 12 o'clock (Pillow measures from 3 o'clock). dim = tuple(round(c * 0.45) for c in color) draw.ellipse([8, 8, 56, 56], fill=dim) progress = min(1.0, max(0.0, progress)) if progress > 0: draw.pieslice([8, 8, 56, 56], -90, -90 + 360 * progress, fill=color) return img def _update_icon(self): if not self.icon: return with self._icon_lock: progress = None # One read of each counter: another thread's finally clause can zero # both between two reads, and a fraction over zero raises. done, total = self.chunks_done, self.chunks_total if self.recording: color = (220, 40, 40) # red state = "Recording..." active = self._recording_session if active is not None: state = f"Recording · {active.done}/{active.total} chunks processed" elif self._live_sessions: color = (40, 100, 220) sessions = tuple(self._live_sessions) done = sum(s.done for s in sessions) total = sum(s.total for s in sessions) state = f"Finishing {done}/{total} chunks..." progress = done / total if total else None elif self.pending_transcriptions > 0: color = (40, 100, 220) # blue if total > 1: state = f"Transcribing {done}/{total}..." progress = done / total else: state = "Transcribing..." else: color = (120, 120, 120) # gray state = "Ready" self.icon.icon = ( self._meter_icons[round(self._meter_level * 11)] if self.recording else self._create_icon_image(color, progress) ) self.icon.title = f"{APP_NAME} - {state}" self.icon.update_menu() def _audio_callback(self, indata, frames, time_info, status): if hasattr(self, "_meter_stop"): # Keep only a small, owned snapshot. Level calculation and tray # drawing happen outside PortAudio's callback. self._meter_frame = ( time.monotonic(), indata[::max(1, frames // 512)].copy(), ) active = self._recording_session if active is not None: if status: active.report_input_status(str(status)) active.feed(indata) return if status: logging.warning("sounddevice input status: %s", status) self.audio_frames.append(indata.copy()) def _create_recording_image(self, level: float) -> Image.Image: color = (round(160 + 80 * level), round(28 + 32 * level), 40) img = self._create_icon_image(color) draw = ImageDraw.Draw(img) for x, scale in ((21, 0.6), (30, 1.0), (39, 0.75)): height = round(4 + 30 * level * scale) draw.rounded_rectangle( (x, 32 - height // 2, x + 4, 32 + height // 2), radius=2, fill=(255, 235, 235), ) return img @staticmethod def _microphone_level(samples: np.ndarray) -> float: mono = samples.mean(axis=1) if samples.ndim == 2 else samples if not len(mono): return 0.0 centered = mono - mono.mean() rms = float(np.sqrt(np.mean(centered * centered))) dbfs = 20 * np.log10(max(rms, 1e-6)) return float(np.clip((dbfs + 60) / 42, 0, 1)) def _animate_microphone(self): previous = None while not self._meter_stop.wait(0.1): snapshot = self._meter_frame target = 0.0 if self.recording and snapshot is not None: captured_at, samples = snapshot if time.monotonic() - captured_at < 0.3: target = self._microphone_level(samples) # Fast attack, gentle release: speech is obvious without strobing. self._meter_level = max(target, self._meter_level * 0.65) if self.recording else 0.0 step = round(self._meter_level * 11) if not self.recording: previous = None continue if step == previous: continue try: with self._icon_lock: if self.recording and self.icon and not self._quitting: self.icon.icon = self._meter_icons[step] previous = step # Deliberately leave menu/title updates to state transitions. except Exception: logging.exception("Could not update microphone activity icon") return 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 _require_api_key(self, action: str) -> bool: if self._current_api_key(): return True provider = self._provider_for_model(self.transcription_model) key_hint = PROVIDER_KEY_HINTS.get(provider, f"set the {provider} API key") logging.error( "%s blocked: model=%s provider=%s has no API key; " "%s or select a model with an available key", action, self.transcription_model, provider, key_hint, ) self.platform.notify("Error", f"No {provider} API key; {key_hint}.") return False def _start_recording(self): if not self._require_api_key("Recording"): return self._meter_frame = None self._meter_level = 0.0 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 "", 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 "", ) 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 "", ) 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 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 "", ) 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"], ) sent = (conditioned * 32767).astype(np.int16) dump_capture("sent", sent) return sent def _transcribe( self, audio_data: np.ndarray, spool_path: Path | None = None, collect: list[str] | None = None, ): """Transcribe one clip. With `collect`, the text is appended there instead of being handed to the user: a retry run copies everything it recovered in one go rather than overwriting the clipboard per clip.""" ranges = split_at_silence(audio_data) detail = ( f"Using {get_model_label(self.transcription_model)} " f"({self.transcription_language})" ) if len(ranges) > 1: detail = f"{detail} in {len(ranges)} chunks" self.platform.notify("Transcribing", detail) with self.lock: self.chunks_total += len(ranges) self._update_icon() try: if len(ranges) == 1: 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: text = self._request_transcript( wav_bytes, len(audio_data) / SAMPLE_RATE ) finally: self._chunk_finished() self._hand_over(text, collect) self._discard_spool(spool_path) else: texts, errors = self._transcribe_chunks(audio_data, ranges) if errors: self._deliver_partial( audio_data, ranges, texts, errors, spool_path, collect ) else: self._hand_over(" ".join(text.strip() for text in texts), collect) self._discard_spool(spool_path) except TranscriptionError as e: self._report_failure(str(e), spool_path) except requests.RequestException as e: logging.exception("API request failed") status = rejected_status(e) if status is None: self._report_failure(f"API request failed: {e}", spool_path) else: logging.error( "Request rejected with HTTP %d: %s", status, response_snippet(e) ) self._discard_spool(spool_path, f"Request rejected with HTTP {status}") self.platform.notify( "Error", f"API request failed: {e} — audio NOT kept, the request was " f"rejected (HTTP {status}).", ) except Exception as e: logging.exception("Unexpected transcription failure") self._report_failure(f"Unexpected transcription failure: {e}", spool_path) finally: with self.lock: self.pending_transcriptions -= 1 if self.pending_transcriptions == 0: self.chunks_total = self.chunks_done = 0 if spool_path is not None: self._inflight_spool.discard(spool_path) self._update_icon() def _chunk_finished(self): with self.lock: self.chunks_done += 1 try: self._update_icon() except Exception: # A tray backend that cannot draw must not turn a transcribed chunk # into a failed one. logging.exception("Could not update the tray icon after a finished chunk") def _hand_over(self, text: str, collect: list[str] | None): """Give the transcript to the user, or to the retry run collecting them.""" if collect is None: self._deliver(text) else: collect.append(text) def _deliver(self, text: str): 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") def _transcribe_chunks( self, audio_data: np.ndarray, ranges: list[tuple[int, int]] ) -> tuple[list[str | None], dict[int, Exception]]: """Upload every chunk in parallel; text per chunk, plus what went wrong for each missing one. The clip is already recorded, so latency is all that is left to save, and one dead chunk must not cost the other nine. The exception itself is kept, not just its message: whether the audio is worth spooling depends on why the request failed.""" texts: list[str | None] = [None] * len(ranges) errors: dict[int, Exception] = {} def upload(index: int) -> str: start, end = ranges[index] chunk = audio_data[start:end] buffer = io.BytesIO() wavfile.write(buffer, SAMPLE_RATE, chunk) wav_bytes = buffer.getvalue() logging.info( "Encoded chunk %d of %d in memory: %d bytes, %.1fs", index + 1, len(ranges), len(wav_bytes), len(chunk) / SAMPLE_RATE, ) try: return self._request_transcript(wav_bytes, len(chunk) / SAMPLE_RATE) finally: self._chunk_finished() with ThreadPoolExecutor(max_workers=CHUNK_WORKERS) as pool: futures = [pool.submit(upload, index) for index in range(len(ranges))] for index, future in enumerate(futures): try: texts[index] = future.result() except Exception as e: errors[index] = e logging.exception( "Chunk %d of %d failed (%.1fs at %.1fs into the clip)", index + 1, len(ranges), (ranges[index][1] - ranges[index][0]) / SAMPLE_RATE, ranges[index][0] / SAMPLE_RATE, ) return texts, errors def _deliver_partial( self, audio_data: np.ndarray, ranges: list[tuple[int, int]], texts: list[str | None], errors: dict[int, Exception], spool_path: Path | None, collect: list[str] | None = None, ): """Hand over what came back and keep only the audio that did not. The transcript has holes in it, so it goes to the clipboard for the user to read — never to an agent, which cannot see what is missing.""" for index in sorted(errors): logging.error( "Chunk %d of %d has no transcript: %s", index + 1, len(ranges), error_text(errors[index]), ) if len(errors) == len(ranges) and spool_path is not None and spool_path.exists(): logging.error("Every chunk failed; keeping the whole clip at %s", spool_path) kept_note = "audio kept, retry from the tray menu" else: stem = spool_path.stem if spool_path is not None else spool_stem() kept = unwritten = 0 for index in sorted(errors): status = rejected_status(errors[index]) if status is not None: logging.error( "Not keeping chunk %d: rejected with HTTP %d, so re-uploading " "it can only be rejected again: %s", index + 1, status, response_snippet(errors[index]), ) continue start, end = ranges[index] if spool_chunk(audio_data[start:end], stem, index + 1) is None: unwritten += 1 else: kept += 1 if unwritten == 0: # Every chunk worth keeping is in the spool under its own name; # the whole clip would only make the retry re-upload good audio. self._discard_spool(spool_path) kept_note = ( f"{kept} of them kept, retry from the tray menu" if kept else "audio NOT kept, the request was rejected" ) elif spool_path is not None and spool_path.exists(): logging.error( "Keeping the whole clip at %s: %d failed chunk(s) could not be " "spooled on their own", spool_path, unwritten, ) kept_note = "whole clip kept, retry from the tray menu" else: logging.error( "%d failed chunk(s) could not be spooled and there is no whole " "clip to fall back on; their audio is gone", unwritten, ) kept_note = f"audio of {unwritten} of them could NOT be kept" if len(errors) == len(ranges): # Nothing came back, so there is no partial transcript to hand over. self.platform.notify( "Error", f"All {len(ranges)} chunks failed: {error_text(errors[min(errors)])} — " f"{kept_note}.", ) return pieces: list[str] = [] for text in texts: if text: pieces.append(text.strip()) elif not pieces or pieces[-1] != "[…]": pieces.append("[…]") partial = " ".join(pieces) message = f"{len(errors)} of {len(ranges)} chunks failed — {kept_note}" if collect is not None: collect.append(partial) logging.info( "Collected partial transcription for the retry run: %d characters", len(partial), ) else: pyperclip.copy(partial) if partial.lower().startswith(AGENT_PREFIX): message = ( f"{message}. Copied instead of dispatched: the transcript has holes" ) logging.warning("Not dispatching a partial transcript; copied it instead") logging.info( "Copied partial transcription to clipboard: %d characters", len(partial) ) self.platform.notify("Partial", message) def _request_transcript(self, wav_bytes: bytes, seconds: float) -> str: """Upload one WAV and return its text, raising if the API gave us none.""" timeout = transcription_timeout(seconds) logging.info( "Starting transcription: %.1fs of audio, connect_timeout=%.0fs read_timeout=%.0fs", seconds, timeout[0], timeout[1], ) if self._provider_for_model(self.transcription_model) == "openrouter": result = self._transcribe_openrouter(wav_bytes, timeout) else: result = self._transcribe_openai(wav_bytes, timeout) 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) raise TranscriptionError(f"Transcription failed: {error_msg}") return text def _report_failure(self, message: str, spool_path: Path | None): if spool_path is not None and spool_path.exists(): logging.error("Keeping audio at %s after failure: %s", spool_path, message) message = f"{message} — audio kept, retry from the tray menu." self.platform.notify("Error", message) def _discard_spool(self, spool_path: Path | None, reason: str = "Transcript delivered"): if spool_path is None: return try: spool_path.unlink(missing_ok=True) logging.info("%s; removed spooled clip %s", reason, spool_path) except OSError: logging.exception("Could not remove spooled clip %s", spool_path) def _spooled_files(self) -> list[Path]: """Spooled clips, oldest first (their names are timestamps).""" try: return sorted(SPOOL_DIR.glob("*.wav")) except OSError: logging.exception("Could not list the spool %s", SPOOL_DIR) return [] def _retryable_files(self) -> list[Path]: """Spooled clips a retry would actually take on: not the one that is being uploaded right now, or the menu offers to retry a clip whose first attempt has not even finished. A stale copy of the in-flight set is fine here — the worker checks it again under the lock.""" inflight = set(self._inflight_spool) return [path for path in self._spooled_files() if path not in inflight] def _retry_spooled(self, icon=None, item=None): if self.streaming: with self._live_lock: if self._retrying or self._quitting: return self._retrying = True self._retry_thread = threading.Thread(target=self._retry_sessions, daemon=True, name="RetrySessions") self._retry_thread.start() return if not self._require_api_key("Retry"): return threading.Thread(target=self._retry_spooled_worker, daemon=True, name="RetrySpool").start() def _retry_count(self): if self.streaming: return len(self._recovery.discover()) + len(self._retryable_files()) return len(self._retryable_files()) def _retry_sessions(self): collected = [] recovered = [] attempted = [] try: entries = [(r.created_at, r, None) for r in self._recovery.discover()] entries.extend((p.stat().st_mtime, None, p) for p in self._retryable_files()) for _, record, path in sorted(entries, key=lambda entry: entry[0]): if self._quitting: break try: config = self._transcription_config(record.config if record else None) if not config.api_key: self.platform.notify("Error", f"Missing {config.provider} key for saved recording.") continue if record is not None: active = self._recovery.resume(record, config, self._scheduler) else: active = RecordingSession.from_wav(path, config, SPOOL_DIR, self._scheduler) attempted.append(active) result = active.join() if result and result.text: collected.append(result.text) recovered.append(active) elif result and result.complete: active.acknowledge_delivery() # recovered silence needs no clipboard except Exception: logging.exception("Could not recover recording %s", record or path) if collected and not self._quitting: with self._delivery_lock: pyperclip.copy("\n\n".join(collected)) for active in recovered: active.acknowledge_delivery() self.platform.notify("Copied", f"Recovered {len(collected)} recording(s).") elif not self._quitting: self.platform.notify("Retry", "No transcripts recovered; audio remains saved.") except Exception: logging.exception("Session retry failed; recovery data retained") if not self._quitting: self.platform.notify("Error", "Retry failed; audio remains saved.") finally: for active in attempted: active.release_for_retry() with self._live_lock: self._retrying = False if not self._quitting: self._update_icon() def _retry_spooled_worker(self): """Re-upload spooled clips one at a time: they failed once already, and a burst of parallel requests is the least likely thing to fix that. Their transcripts are collected and copied together at the end — one clipboard holds one thing, and a per-clip copy would leave only the last clip.""" collected: list[str] = [] for path in self._spooled_files(): with self.lock: if path in self._inflight_spool: logging.info("Not retrying %s: it is being transcribed right now", path) continue self._inflight_spool.add(path) audio_data = load_spooled_capture(path) if audio_data is None: with self.lock: self._inflight_spool.discard(path) continue with self.lock: self.pending_transcriptions += 1 self._update_icon() logging.info("Retrying spooled clip %s", path) self._transcribe(audio_data, path, collected) if not collected: return pyperclip.copy("\n\n".join(collected)) # Never dispatched: a retried clip may be one part of a longer recording, # and an agent prefix on it would send a truncated task. logging.info( "Copied %d retried transcript(s) to the clipboard; retries are copied, " "never dispatched", len(collected), ) self.platform.notify("Copied", f"Copied {len(collected)} retried transcript(s)") def _transcribe_openai(self, wav_bytes: bytes, timeout: tuple[float, float]) -> 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=timeout, ) self._log_transcription_response(response) response.raise_for_status() return response.json() def _transcribe_openrouter(self, wav_bytes: bytes, timeout: tuple[float, float]) -> 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=timeout, ) 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): if self.streaming: self._toggle_streaming() return with self.lock: if not self.recording: self._start_recording() else: audio_data = self._stop_recording() if audio_data is not None: spool_path = spool_capture(audio_data) if spool_path is not None: self._inflight_spool.add(spool_path) self.pending_transcriptions += 1 self._update_icon() t = threading.Thread( target=self._transcribe, args=(audio_data, spool_path), daemon=True, ) t.start() else: self.platform.notify("Error", "No audio recorded.") def _transcription_config(self, metadata=None): metadata = metadata or {} model = metadata.get("model", self.transcription_model) provider = metadata.get("provider", self._provider_for_model(model)) return TranscriptionConfig( model=model, language=metadata.get("language", self.transcription_language), provider=provider, api_key=self.api_keys.get(provider, ""), ) def _session_update(self, session): if not self._quitting: if (session is self._recording_session and session.result is not None and session.result.errors and not getattr(session, "capture_warned", False)): session.capture_warned = True self.platform.notify("Capture interrupted", "Stop recording; available audio is kept for retry.") self._update_icon() def _session_complete(self, session): # Sessions may finish out of order. Deliver in recording order, and keep # delivery outside the microphone/start-stop lock. with self._delivery_lock: while not self._quitting: with self._live_lock: if not self._live_sessions or self._live_sessions[0].result is None: break if self._live_sessions[0] is self._recording_session: # An input failure can finish the producer before the # user stops the microphone. Never deliver mid-recording. break ready = self._live_sessions.pop(0) result = ready.result try: if result.text: if result.complete: self._deliver(result.text) else: pyperclip.copy(result.text) self.platform.notify("Partial", "Copied with gaps; audio kept for retry.") ready.acknowledge_delivery() stopped_at = getattr(ready, "stopped_at", None) if stopped_at is not None: logging.info( "Session delivered: id=%s stop_to_delivery=%.3fs complete=%s", result.session_id, time.monotonic() - stopped_at, result.complete, ) elif result.errors: ready.release_for_retry() self.platform.notify("Error", "Transcription failed; audio kept for retry.") else: ready.acknowledge_delivery() self.platform.notify("Recording", "No speech detected.") except Exception: logging.exception("Session delivery failed; recovery data retained") ready.release_for_retry() self.platform.notify("Error", "Could not deliver transcript; kept for retry.") self._session_update(session) def _toggle_streaming(self): with self.lock: if self._quitting: return if self.recording: self._stop_streaming() return if not self._require_api_key("Recording"): return self._meter_frame = None self._meter_level = 0.0 active = None try: self.capture_rate, self.capture_channels = input_stream_settings(self.device_index) active = RecordingSession( self.capture_rate, self.capture_channels, self._transcription_config(), SPOOL_DIR, self._scheduler, on_update=self._session_update, on_complete=self._session_complete, ) with self._live_lock: self._live_sessions.append(active) self._recording_session = active 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() self.recording = True logging.info( "Streaming capture started: session=%s device=%s rate=%d channels=%d", active.session_id, self.device_name or "", self.capture_rate, self.capture_channels, ) except Exception as error: logging.exception("Could not start streaming capture") if self.stream is not None: self.stream.close() self.stream = None self._recording_session = None if active is not None: active.stop() self.platform.notify("Error", f"Could not start recording: {error}") return self._update_icon() self.platform.notify("Recording", "Transcribing as you speak. Press hotkey to stop.") def _stop_streaming(self): # PortAudio must finish its last callback before the session receives EOF. if self.stream is not None: self.stream.stop() self.stream.close() self.stream = None active = self._recording_session self._recording_session = None self.recording = False if active is not None: active.stopped_at = time.monotonic() active.stop() if active.result is not None: self._session_complete(active) self._update_icon() 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( lambda item: f"Retry failed transcriptions ({self._retry_count()})", self._retry_spooled, enabled=lambda item: not self._retrying and self._retry_count() > 0, ), pystray.MenuItem("Show Logs", self._show_logs), *awake_items, pystray.Menu.SEPARATOR, pystray.MenuItem("Quit", self._on_quit), ) def _on_quit(self, icon, item): self._quitting = True self._meter_stop.set() if self.recording: if self.streaming: self._stop_streaming() else: self._stop_recording() if self._awake_keeper and self._awake_keeper.is_active: self._awake_keeper.stop() self.platform.cleanup_toggle_trigger() # A non-daemon coordinator keeps Python alive after the tray event loop # exits, until capture checkpoints and pending responses are durable. threading.Thread(target=self._finish_shutdown, name="BlurtShutdown", daemon=False).start() icon.stop() def _finish_shutdown(self): if self.streaming: # Finish checkpointing and uploads without delivering into an app # the user has closed. Undelivered text remains recoverable. for active in tuple(self._live_sessions): active.join() active.release_for_retry() if self._retry_thread is not None: self._retry_thread.join() self._scheduler.shutdown(wait=True) self._client.close() def _setup(self, icon): icon.visible = True threading.Thread(target=self._animate_microphone, name="MicrophoneMeter", daemon=True).start() 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()