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