4d10653790
Toggle voice-to-text tool with Linux and Windows support. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
662 lines
22 KiB
Python
662 lines
22 KiB
Python
import logging
|
|
import os
|
|
import shlex
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from logging.handlers import RotatingFileHandler
|
|
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.io import wavfile
|
|
|
|
OPENAI_TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcriptions"
|
|
DEFAULT_TRANSCRIPTION_MODEL = "whisper-1"
|
|
DEFAULT_TRANSCRIPTION_LANGUAGE = "en"
|
|
TRANSCRIPTION_MODELS = [
|
|
("whisper-1", "OpenAI: whisper-1"),
|
|
("gpt-4o-mini-transcribe", "OpenAI: gpt-4o-mini-transcribe"),
|
|
("gpt-4o-transcribe", "OpenAI: gpt-4o-transcribe"),
|
|
]
|
|
TRANSCRIPTION_LANGUAGES = [
|
|
("en", "English"),
|
|
("de", "German"),
|
|
]
|
|
SAMPLE_RATE = 16000
|
|
CHANNELS = 1
|
|
DTYPE = "int16"
|
|
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"),
|
|
]
|
|
|
|
|
|
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_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
|
|
|
|
for service_name, username in KEYRING_CREDENTIALS:
|
|
try:
|
|
stored_key = keyring.get_password(service_name, username)
|
|
except Exception:
|
|
logging.exception(
|
|
"Could not read OpenAI API key from keyring entry %s/%s",
|
|
service_name,
|
|
username,
|
|
)
|
|
continue
|
|
if stored_key:
|
|
logging.info(
|
|
"Loaded OpenAI API key from keyring entry %s/%s",
|
|
service_name,
|
|
username,
|
|
)
|
|
return stored_key.strip()
|
|
|
|
return load_config_value("OPENAI_API_KEY", "api_key.txt")
|
|
|
|
|
|
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 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()
|
|
|
|
|
|
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.lock = threading.Lock()
|
|
self.icon: pystray.Icon | None = None
|
|
self.device_index: int | None = None
|
|
self.input_devices = self._get_input_devices()
|
|
self.transcription_model = load_transcription_model()
|
|
self.transcription_language = load_transcription_language()
|
|
self.api_key = load_api_key()
|
|
self.pending_transcriptions = 0
|
|
logging.info(
|
|
"BlurtApp initialized with %d input devices, model=%s, language=%s",
|
|
len(self.input_devices),
|
|
self.transcription_model,
|
|
self.transcription_language,
|
|
)
|
|
|
|
def _get_input_devices(self) -> list[dict]:
|
|
devices = sd.query_devices()
|
|
input_devices = [
|
|
{"index": i, "name": d["name"]}
|
|
for i, d in enumerate(devices)
|
|
if d["max_input_channels"] > 0
|
|
]
|
|
for dev in input_devices:
|
|
logging.info("Input device %s: %s", dev["index"], dev["name"])
|
|
return input_devices
|
|
|
|
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:
|
|
print(f"sounddevice: {status}", file=sys.stderr)
|
|
self.audio_frames.append(indata.copy())
|
|
|
|
def _start_recording(self):
|
|
if not self.api_key:
|
|
self.platform.notify("Error", "No API key. Put your key in api_key.txt")
|
|
return
|
|
self.audio_frames = []
|
|
try:
|
|
self.stream = sd.InputStream(
|
|
device=self.device_index,
|
|
samplerate=SAMPLE_RATE,
|
|
channels=CHANNELS,
|
|
dtype=DTYPE,
|
|
callback=self._audio_callback,
|
|
)
|
|
self.stream.start()
|
|
logging.info("Started recording with device_index=%s", self.device_index)
|
|
except Exception as e:
|
|
logging.exception("Could not start recording")
|
|
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
|
|
audio_data = np.concatenate(self.audio_frames, axis=0)
|
|
self.audio_frames = []
|
|
logging.info(
|
|
"Stopped recording: samples=%d shape=%s dtype=%s",
|
|
len(audio_data),
|
|
audio_data.shape,
|
|
audio_data.dtype,
|
|
)
|
|
return audio_data
|
|
|
|
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))
|
|
|
|
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
|
tmp_path = Path(tmp.name)
|
|
tmp.close()
|
|
wavfile.write(str(tmp_path), SAMPLE_RATE, audio_data)
|
|
logging.info("Wrote temporary WAV: %s (%d bytes)", tmp_path, tmp_path.stat().st_size)
|
|
|
|
try:
|
|
result = self._transcribe_openai(tmp_path)
|
|
text = result.get("text", "")
|
|
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()
|
|
try:
|
|
tmp_path.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
def _transcribe_openai(self, tmp_path: Path) -> dict:
|
|
with open(tmp_path, "rb") as f:
|
|
response = requests.post(
|
|
OPENAI_TRANSCRIPTIONS_URL,
|
|
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
files={"file": ("blurt.wav", f, "audio/wav")},
|
|
data={
|
|
"model": self.transcription_model,
|
|
"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 _select_model(self, model: str):
|
|
def handler(icon, item):
|
|
self.transcription_model = model
|
|
self.api_key = load_api_key()
|
|
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_language_selected(self, language: str):
|
|
def check(item):
|
|
return self.transcription_language == language
|
|
return check
|
|
|
|
def _select_device(self, device_index):
|
|
def handler(icon, item):
|
|
self.device_index = device_index
|
|
self.icon.update_menu()
|
|
return handler
|
|
|
|
def _is_device_selected(self, device_index):
|
|
def check(item):
|
|
return self.device_index == device_index
|
|
return check
|
|
|
|
def _build_menu(self):
|
|
device_items = []
|
|
for dev in self.input_devices:
|
|
device_items.append(
|
|
pystray.MenuItem(
|
|
dev["name"],
|
|
self._select_device(dev["index"]),
|
|
checked=self._is_device_selected(dev["index"]),
|
|
radio=True,
|
|
)
|
|
)
|
|
|
|
model_items = []
|
|
for model, label in TRANSCRIPTION_MODELS:
|
|
model_items.append(
|
|
pystray.MenuItem(
|
|
label,
|
|
self._select_model(model),
|
|
checked=self._is_model_selected(model),
|
|
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,
|
|
)
|
|
)
|
|
|
|
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("Language", pystray.Menu(*language_items)),
|
|
pystray.MenuItem("Show Logs", self._show_logs),
|
|
pystray.Menu.SEPARATOR,
|
|
pystray.MenuItem("Quit", self._on_quit),
|
|
)
|
|
|
|
def _on_quit(self, icon, item):
|
|
if self.recording:
|
|
self._stop_recording()
|
|
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 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
|
|
|
|
platform = get_platform()
|
|
app = BlurtApp(platform)
|
|
app.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|