A long clip used to die on a fixed 30 s timeout and the audio went with it. Now every conditioned clip is written to spool/ before upload and only removed once its text is on the clipboard; failures keep it, and a tray item retries spooled clips (copied together, never dispatched). Requests the API rejects outright (4xx other than 408/429) are not kept. Timeouts scale with the audio (30 s + 30 s/min, capped at 5 min). Clips over 5 min are cut inside pauses into ~4 min chunks uploaded three at a time and stitched in order; failed chunks are spooled individually and the partial transcript is copied with a gap marker. The tray disc fills clockwise as chunks return. mic_check.py passes the new timeout tuple. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016rLFNPygDpRkRwPQakdmYh
1742 lines
66 KiB
Python
1742 lines
66 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
|
|
|
|
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()
|
|
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..."
|
|
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._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 status:
|
|
logging.warning("sounddevice input status: %s", status)
|
|
self.audio_frames.append(indata.copy())
|
|
|
|
def _provider_for_model(self, model: str) -> str:
|
|
for model_id, _, provider in TRANSCRIPTION_MODELS:
|
|
if model_id == model:
|
|
return provider
|
|
return "openai"
|
|
|
|
def _available_model_or_fallback(self, model: str) -> str:
|
|
provider = self._provider_for_model(model)
|
|
if self.api_keys.get(provider):
|
|
return model
|
|
|
|
for fallback_model, _, fallback_provider in TRANSCRIPTION_MODELS:
|
|
if self.api_keys.get(fallback_provider):
|
|
logging.warning(
|
|
"Model %s requires a %s API key, which is unavailable; "
|
|
"using available model %s for this session",
|
|
model,
|
|
provider,
|
|
fallback_model,
|
|
)
|
|
return fallback_model
|
|
|
|
logging.error(
|
|
"Model %s requires a %s API key, but no API keys are available for any provider",
|
|
model,
|
|
provider,
|
|
)
|
|
return model
|
|
|
|
def _current_api_key(self) -> str:
|
|
return self.api_keys.get(self._provider_for_model(self.transcription_model), "")
|
|
|
|
def _api_key_label(self) -> str:
|
|
provider = self._provider_for_model(self.transcription_model)
|
|
provider_label = "OpenRouter" if provider == "openrouter" else "OpenAI"
|
|
key = self._current_api_key()
|
|
if not key:
|
|
return f"API key: {provider_label} (missing)"
|
|
return f"API key: {provider_label} (…{key[-4:]})"
|
|
|
|
def _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.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 not self._require_api_key("Retry"):
|
|
return
|
|
threading.Thread(target=self._retry_spooled_worker, daemon=True, name="RetrySpool").start()
|
|
|
|
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):
|
|
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 _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 ({len(self._retryable_files())})",
|
|
self._retry_spooled,
|
|
enabled=lambda item: len(self._retryable_files()) > 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):
|
|
if self.recording:
|
|
self._stop_recording()
|
|
if self._awake_keeper and self._awake_keeper.is_active:
|
|
self._awake_keeper.stop()
|
|
self.platform.cleanup_toggle_trigger()
|
|
icon.stop()
|
|
|
|
def _setup(self, icon):
|
|
icon.visible = True
|
|
|
|
def run(self):
|
|
self.platform.setup_toggle_trigger(self)
|
|
logging.info("Starting tray icon")
|
|
self.icon = pystray.Icon(
|
|
APP_NAME,
|
|
icon=self._create_icon_image((120, 120, 120)),
|
|
title=f"{APP_NAME} - Ready",
|
|
menu=self._build_menu(),
|
|
)
|
|
self.icon.run(setup=self._setup)
|
|
|
|
|
|
def _silence_benign_gtk_warnings():
|
|
"""Drop a harmless, repeating Gdk-CRITICAL emitted by Gtk.StatusIcon.
|
|
|
|
pystray's GTK backend (the one qtile's XEmbed Systray widget needs) uses the
|
|
deprecated Gtk.StatusIcon, which logs this assertion on icon/menu updates:
|
|
gdk_window_thaw_toplevel_updates: assertion
|
|
'window->update_and_descendants_freeze_count > 0' failed
|
|
It is cosmetic. Suppress only that message and re-emit every other Gdk log so
|
|
genuine problems still surface.
|
|
"""
|
|
try:
|
|
from gi.repository import GLib
|
|
except Exception:
|
|
return
|
|
|
|
benign = "gdk_window_thaw_toplevel_updates"
|
|
|
|
def handler(domain, level, message, _user_data):
|
|
if benign not in message:
|
|
print(f"{domain}: {message}", file=sys.stderr)
|
|
|
|
GLib.log_set_handler(
|
|
"Gdk",
|
|
GLib.LogLevelFlags.LEVEL_CRITICAL
|
|
| GLib.LogLevelFlags.LEVEL_WARNING
|
|
| GLib.LogLevelFlags.FLAG_FATAL
|
|
| GLib.LogLevelFlags.FLAG_RECURSION,
|
|
handler,
|
|
None,
|
|
)
|
|
|
|
|
|
def main():
|
|
setup_logging()
|
|
logging.info("Starting %s argv=%s platform=%s log_file=%s", APP_NAME, sys.argv, sys.platform, LOG_FILE)
|
|
if len(sys.argv) > 1 and sys.argv[1] == "toggle":
|
|
send_toggle()
|
|
return
|
|
|
|
_silence_benign_gtk_warnings()
|
|
platform = get_platform()
|
|
app = BlurtApp(platform)
|
|
app.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|