Files
blurt/blurt.py
T

2043 lines
79 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 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",
),
(
"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 "<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 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 "<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,
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 "<system default>",
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()