Speed up transcription with a pluggable provider backend
Transcription: - Provider-pluggable backend: each model routes to OpenAI (multipart upload) or OpenRouter (JSON + base64 body). Add OpenRouter's whisper-large-v3-turbo (Groq-served, ~216x real-time) to the tray menu for far lower latency than whisper-1. - Load API keys env -> keyring -> file for both providers via a shared helper. - Encode the WAV in memory and reuse a warm requests.Session (no temp file). Other improvements: - blurt-toggle signals the running app directly via SIGUSR1 instead of paying ~0.5s to import the full stack through "uv run blurt toggle". - Remove the redundant dead blurt shell script. - Suppress the benign Gdk-CRITICAL warning from pystray's GtkStatusIcon. - gitignore openrouter_key.txt. Transcription code is platform-agnostic; the Windows/Linux platform layer is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,5 +7,6 @@ __pycache__/
|
|||||||
|
|
||||||
# Blurt runtime config / secrets (written by blurt.py at runtime)
|
# Blurt runtime config / secrets (written by blurt.py at runtime)
|
||||||
api_key.txt
|
api_key.txt
|
||||||
|
openrouter_key.txt
|
||||||
model.txt
|
model.txt
|
||||||
language.txt
|
language.txt
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PID_FILE="/tmp/blurt.pid"
|
|
||||||
AUDIO_FILE="/tmp/blurt.wav"
|
|
||||||
API_URL="https://api.openai.com/v1/audio/transcriptions"
|
|
||||||
|
|
||||||
notify() {
|
|
||||||
notify-send "blurt" "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
get_api_key() {
|
|
||||||
keyring get openai-api-key felixm 2>/dev/null || {
|
|
||||||
notify "Error: Could not get API key from keyring"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
start_recording() {
|
|
||||||
notify "Recording..."
|
|
||||||
pw-record --format=s16 --rate=16000 --channels=1 "$AUDIO_FILE" &
|
|
||||||
echo $! > "$PID_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
stop_recording() {
|
|
||||||
local pid="$1"
|
|
||||||
kill -INT "$pid" 2>/dev/null || true
|
|
||||||
wait "$pid" 2>/dev/null || true
|
|
||||||
rm -f "$PID_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
transcribe() {
|
|
||||||
notify "Transcribing..."
|
|
||||||
|
|
||||||
local api_key
|
|
||||||
api_key=$(get_api_key)
|
|
||||||
|
|
||||||
local response
|
|
||||||
response=$(curl -s -X POST "$API_URL" \
|
|
||||||
-H "Authorization: Bearer $api_key" \
|
|
||||||
-F "file=@$AUDIO_FILE" \
|
|
||||||
-F "model=whisper-1")
|
|
||||||
|
|
||||||
local text
|
|
||||||
text=$(echo "$response" | jq -r '.text // empty')
|
|
||||||
|
|
||||||
if [[ -z "$text" ]]; then
|
|
||||||
local error
|
|
||||||
error=$(echo "$response" | jq -r '.error.message // "Unknown error"')
|
|
||||||
notify "Error: $error"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -n "$text" | xclip -selection clipboard
|
|
||||||
|
|
||||||
# If active window is kitty, insert text directly
|
|
||||||
local window_class
|
|
||||||
window_class=$(xdotool getactivewindow getwindowclassname 2>/dev/null || echo "")
|
|
||||||
if [[ "$window_class" == *"kitty"* ]]; then
|
|
||||||
xdotool type --clearmodifiers -- "$text"
|
|
||||||
fi
|
|
||||||
|
|
||||||
local preview="${text:0:50}"
|
|
||||||
[[ ${#text} -gt 50 ]] && preview="${preview}..."
|
|
||||||
notify "Copied: $preview"
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
if [[ -f "$PID_FILE" ]]; then
|
|
||||||
local pid
|
|
||||||
pid=$(cat "$PID_FILE")
|
|
||||||
|
|
||||||
if kill -0 "$pid" 2>/dev/null; then
|
|
||||||
stop_recording "$pid"
|
|
||||||
transcribe
|
|
||||||
else
|
|
||||||
rm -f "$PID_FILE"
|
|
||||||
start_recording
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
start_recording
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
main
|
|
||||||
+15
-1
@@ -1,2 +1,16 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
cd /home/felixm/wrk/blurt && uv run blurt toggle
|
# Toggle Blurt recording by signaling the running tray app directly.
|
||||||
|
#
|
||||||
|
# A direct SIGUSR1 avoids the ~0.5s cost of "uv run blurt toggle", which
|
||||||
|
# imported the full numpy/scipy/sounddevice/pystray stack just to send one
|
||||||
|
# signal. The PID file is written by blurt.py's setup_toggle_trigger().
|
||||||
|
|
||||||
|
PID_FILE="${TMPDIR:-/tmp}/blurt.pid"
|
||||||
|
|
||||||
|
pid=$(cat "$PID_FILE" 2>/dev/null)
|
||||||
|
if [ -n "$pid" ] && kill -USR1 "$pid" 2>/dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
notify-send blurt "Blurt is not running" 2>/dev/null
|
||||||
|
exit 1
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import base64
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
@@ -21,13 +23,20 @@ from PIL import Image, ImageDraw
|
|||||||
from scipy.io import wavfile
|
from scipy.io import wavfile
|
||||||
|
|
||||||
OPENAI_TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcriptions"
|
OPENAI_TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcriptions"
|
||||||
|
OPENROUTER_TRANSCRIPTIONS_URL = "https://openrouter.ai/api/v1/audio/transcriptions"
|
||||||
DEFAULT_TRANSCRIPTION_MODEL = "whisper-1"
|
DEFAULT_TRANSCRIPTION_MODEL = "whisper-1"
|
||||||
DEFAULT_TRANSCRIPTION_LANGUAGE = "en"
|
DEFAULT_TRANSCRIPTION_LANGUAGE = "en"
|
||||||
|
# (model_id, menu_label, provider). The provider selects both the API endpoint
|
||||||
|
# and the request format: "openai" speaks multipart file upload; "openrouter"
|
||||||
|
# speaks JSON with base64-encoded audio. OpenRouter's whisper-large-v3-turbo is
|
||||||
|
# served by Groq (216x real-time, ~12% WER) and reuses your OpenRouter key.
|
||||||
TRANSCRIPTION_MODELS = [
|
TRANSCRIPTION_MODELS = [
|
||||||
("whisper-1", "OpenAI: whisper-1"),
|
("whisper-1", "OpenAI: whisper-1", "openai"),
|
||||||
("gpt-4o-mini-transcribe", "OpenAI: gpt-4o-mini-transcribe"),
|
("gpt-4o-mini-transcribe", "OpenAI: gpt-4o-mini-transcribe", "openai"),
|
||||||
("gpt-4o-transcribe", "OpenAI: gpt-4o-transcribe"),
|
("gpt-4o-transcribe", "OpenAI: gpt-4o-transcribe", "openai"),
|
||||||
|
("openai/whisper-large-v3-turbo", "OpenRouter: whisper-large-v3-turbo (Groq, fast)", "openrouter"),
|
||||||
]
|
]
|
||||||
|
PROVIDER_KEY_FILES = {"openai": "api_key.txt", "openrouter": "openrouter_key.txt"}
|
||||||
TRANSCRIPTION_LANGUAGES = [
|
TRANSCRIPTION_LANGUAGES = [
|
||||||
("en", "English"),
|
("en", "English"),
|
||||||
("de", "German"),
|
("de", "German"),
|
||||||
@@ -44,6 +53,9 @@ KEYRING_CREDENTIALS = [
|
|||||||
("openai-api-key", "felixm"),
|
("openai-api-key", "felixm"),
|
||||||
("blurt", "openai-api-key"),
|
("blurt", "openai-api-key"),
|
||||||
]
|
]
|
||||||
|
OPENROUTER_KEYRING_CREDENTIALS = [
|
||||||
|
("openrouter-api-key", "felixm"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging():
|
||||||
@@ -97,40 +109,57 @@ def load_config_value(env_name: str, file_name: str, default: str = "") -> str:
|
|||||||
return default
|
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:
|
def load_api_key() -> str:
|
||||||
env_key = os.environ.get("OPENAI_API_KEY", "").strip()
|
env_key = os.environ.get("OPENAI_API_KEY", "").strip()
|
||||||
if env_key:
|
if env_key:
|
||||||
logging.info("Loaded OPENAI_API_KEY from environment")
|
logging.info("Loaded OPENAI_API_KEY from environment")
|
||||||
return env_key
|
return env_key
|
||||||
|
|
||||||
for service_name, username in KEYRING_CREDENTIALS:
|
key = _load_key_from_keyring(KEYRING_CREDENTIALS, "OpenAI")
|
||||||
try:
|
if key:
|
||||||
stored_key = keyring.get_password(service_name, username)
|
return key
|
||||||
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")
|
return load_config_value("OPENAI_API_KEY", "api_key.txt")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return load_config_value("OPENROUTER_API_KEY", "openrouter_key.txt")
|
||||||
|
|
||||||
|
|
||||||
def load_transcription_model() -> str:
|
def load_transcription_model() -> str:
|
||||||
model = load_config_value(
|
model = load_config_value(
|
||||||
"BLURT_TRANSCRIPTION_MODEL",
|
"BLURT_TRANSCRIPTION_MODEL",
|
||||||
"model.txt",
|
"model.txt",
|
||||||
DEFAULT_TRANSCRIPTION_MODEL,
|
DEFAULT_TRANSCRIPTION_MODEL,
|
||||||
)
|
)
|
||||||
valid_models = {model_id for model_id, _ in TRANSCRIPTION_MODELS}
|
valid_models = {model_id for model_id, _, _ in TRANSCRIPTION_MODELS}
|
||||||
if model not in valid_models:
|
if model not in valid_models:
|
||||||
logging.warning("Unknown transcription model %s, using %s", model, DEFAULT_TRANSCRIPTION_MODEL)
|
logging.warning("Unknown transcription model %s, using %s", model, DEFAULT_TRANSCRIPTION_MODEL)
|
||||||
return DEFAULT_TRANSCRIPTION_MODEL
|
return DEFAULT_TRANSCRIPTION_MODEL
|
||||||
@@ -167,7 +196,7 @@ def save_transcription_language(language: str):
|
|||||||
|
|
||||||
|
|
||||||
def get_model_label(model: str) -> str:
|
def get_model_label(model: str) -> str:
|
||||||
for model_id, label in TRANSCRIPTION_MODELS:
|
for model_id, label, _ in TRANSCRIPTION_MODELS:
|
||||||
if model_id == model:
|
if model_id == model:
|
||||||
return label
|
return label
|
||||||
return model
|
return model
|
||||||
@@ -334,7 +363,11 @@ class BlurtApp:
|
|||||||
self.input_devices = self._get_input_devices()
|
self.input_devices = self._get_input_devices()
|
||||||
self.transcription_model = load_transcription_model()
|
self.transcription_model = load_transcription_model()
|
||||||
self.transcription_language = load_transcription_language()
|
self.transcription_language = load_transcription_language()
|
||||||
self.api_key = load_api_key()
|
self.api_keys = {
|
||||||
|
"openai": load_api_key(),
|
||||||
|
"openrouter": load_openrouter_key(),
|
||||||
|
}
|
||||||
|
self.session = requests.Session()
|
||||||
self.pending_transcriptions = 0
|
self.pending_transcriptions = 0
|
||||||
logging.info(
|
logging.info(
|
||||||
"BlurtApp initialized with %d input devices, model=%s, language=%s",
|
"BlurtApp initialized with %d input devices, model=%s, language=%s",
|
||||||
@@ -375,14 +408,25 @@ class BlurtApp:
|
|||||||
self.icon.title = f"{APP_NAME} - {state}"
|
self.icon.title = f"{APP_NAME} - {state}"
|
||||||
self.icon.update_menu()
|
self.icon.update_menu()
|
||||||
|
|
||||||
|
def _provider_for_model(self, model: str) -> str:
|
||||||
|
for model_id, _, provider in TRANSCRIPTION_MODELS:
|
||||||
|
if model_id == model:
|
||||||
|
return provider
|
||||||
|
return "openai"
|
||||||
|
|
||||||
|
def _current_api_key(self) -> str:
|
||||||
|
return self.api_keys.get(self._provider_for_model(self.transcription_model), "")
|
||||||
|
|
||||||
def _audio_callback(self, indata, frames, time_info, status):
|
def _audio_callback(self, indata, frames, time_info, status):
|
||||||
if status:
|
if status:
|
||||||
print(f"sounddevice: {status}", file=sys.stderr)
|
print(f"sounddevice: {status}", file=sys.stderr)
|
||||||
self.audio_frames.append(indata.copy())
|
self.audio_frames.append(indata.copy())
|
||||||
|
|
||||||
def _start_recording(self):
|
def _start_recording(self):
|
||||||
if not self.api_key:
|
if not self._current_api_key():
|
||||||
self.platform.notify("Error", "No API key. Put your key in api_key.txt")
|
provider = self._provider_for_model(self.transcription_model)
|
||||||
|
key_file = PROVIDER_KEY_FILES.get(provider, "api_key.txt")
|
||||||
|
self.platform.notify("Error", f"No {provider} API key. Put your key in {key_file}")
|
||||||
return
|
return
|
||||||
self.audio_frames = []
|
self.audio_frames = []
|
||||||
try:
|
try:
|
||||||
@@ -430,14 +474,16 @@ class BlurtApp:
|
|||||||
)
|
)
|
||||||
logging.info("Starting transcription: samples=%d", len(audio_data))
|
logging.info("Starting transcription: samples=%d", len(audio_data))
|
||||||
|
|
||||||
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
buffer = io.BytesIO()
|
||||||
tmp_path = Path(tmp.name)
|
wavfile.write(buffer, SAMPLE_RATE, audio_data)
|
||||||
tmp.close()
|
wav_bytes = buffer.getvalue()
|
||||||
wavfile.write(str(tmp_path), SAMPLE_RATE, audio_data)
|
logging.info("Encoded WAV in memory: %d bytes", len(wav_bytes))
|
||||||
logging.info("Wrote temporary WAV: %s (%d bytes)", tmp_path, tmp_path.stat().st_size)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self._transcribe_openai(tmp_path)
|
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", "")
|
text = result.get("text", "")
|
||||||
logging.info("Transcription text length: %d", len(text))
|
logging.info("Transcription text length: %d", len(text))
|
||||||
if "usage" in result:
|
if "usage" in result:
|
||||||
@@ -479,23 +525,36 @@ class BlurtApp:
|
|||||||
finally:
|
finally:
|
||||||
self.pending_transcriptions -= 1
|
self.pending_transcriptions -= 1
|
||||||
self._update_icon()
|
self._update_icon()
|
||||||
try:
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _transcribe_openai(self, tmp_path: Path) -> dict:
|
def _transcribe_openai(self, wav_bytes: bytes) -> dict:
|
||||||
with open(tmp_path, "rb") as f:
|
response = self.session.post(
|
||||||
response = requests.post(
|
OPENAI_TRANSCRIPTIONS_URL,
|
||||||
OPENAI_TRANSCRIPTIONS_URL,
|
headers={"Authorization": f"Bearer {self.api_keys['openai']}"},
|
||||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
files={"file": ("blurt.wav", wav_bytes, "audio/wav")},
|
||||||
files={"file": ("blurt.wav", f, "audio/wav")},
|
data={
|
||||||
data={
|
"model": self.transcription_model,
|
||||||
"model": self.transcription_model,
|
"language": self.transcription_language,
|
||||||
"language": self.transcription_language,
|
},
|
||||||
},
|
timeout=30,
|
||||||
timeout=30,
|
)
|
||||||
)
|
self._log_transcription_response(response)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def _transcribe_openrouter(self, wav_bytes: bytes) -> dict:
|
||||||
|
# OpenRouter's transcription endpoint takes a JSON body with base64-encoded
|
||||||
|
# audio (not OpenAI's multipart upload). The response still has a "text" field.
|
||||||
|
audio_b64 = base64.b64encode(wav_bytes).decode("ascii")
|
||||||
|
response = self.session.post(
|
||||||
|
OPENROUTER_TRANSCRIPTIONS_URL,
|
||||||
|
headers={"Authorization": f"Bearer {self.api_keys['openrouter']}"},
|
||||||
|
json={
|
||||||
|
"model": self.transcription_model,
|
||||||
|
"input_audio": {"data": audio_b64, "format": "wav"},
|
||||||
|
"language": self.transcription_language,
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
self._log_transcription_response(response)
|
self._log_transcription_response(response)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
@@ -540,7 +599,10 @@ class BlurtApp:
|
|||||||
def _select_model(self, model: str):
|
def _select_model(self, model: str):
|
||||||
def handler(icon, item):
|
def handler(icon, item):
|
||||||
self.transcription_model = model
|
self.transcription_model = model
|
||||||
self.api_key = load_api_key()
|
self.api_keys = {
|
||||||
|
"openai": load_api_key(),
|
||||||
|
"openrouter": load_openrouter_key(),
|
||||||
|
}
|
||||||
save_transcription_model(model)
|
save_transcription_model(model)
|
||||||
self.icon.update_menu()
|
self.icon.update_menu()
|
||||||
self.platform.notify("Model Selected", get_model_label(model))
|
self.platform.notify("Model Selected", get_model_label(model))
|
||||||
@@ -588,7 +650,7 @@ class BlurtApp:
|
|||||||
)
|
)
|
||||||
|
|
||||||
model_items = []
|
model_items = []
|
||||||
for model, label in TRANSCRIPTION_MODELS:
|
for model, label, _ in TRANSCRIPTION_MODELS:
|
||||||
model_items.append(
|
model_items.append(
|
||||||
pystray.MenuItem(
|
pystray.MenuItem(
|
||||||
label,
|
label,
|
||||||
@@ -645,6 +707,38 @@ class BlurtApp:
|
|||||||
self.icon.run(setup=self._setup)
|
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():
|
def main():
|
||||||
setup_logging()
|
setup_logging()
|
||||||
logging.info("Starting %s argv=%s platform=%s log_file=%s", APP_NAME, sys.argv, sys.platform, LOG_FILE)
|
logging.info("Starting %s argv=%s platform=%s log_file=%s", APP_NAME, sys.argv, sys.platform, LOG_FILE)
|
||||||
@@ -652,6 +746,7 @@ def main():
|
|||||||
send_toggle()
|
send_toggle()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
_silence_benign_gtk_warnings()
|
||||||
platform = get_platform()
|
platform = get_platform()
|
||||||
app = BlurtApp(platform)
|
app = BlurtApp(platform)
|
||||||
app.run()
|
app.run()
|
||||||
|
|||||||
Reference in New Issue
Block a user