Switch to sounddevice recording

This commit is contained in:
Thales Maciel 2026-02-24 10:25:21 -03:00
parent afdf088d17
commit b6c0fc0793
No known key found for this signature in database
GPG key ID: 33112E6833C34679
9 changed files with 250 additions and 468 deletions

View file

@ -2,15 +2,15 @@
## Project Structure & Module Organization
- `lel.sh` is the primary entrypoint; it records audio, runs `whisper`, and prints the transcript.
- `env/` is a local Python virtual environment (optional) used to install runtime dependencies.
- There are no separate source, test, or asset directories at this time.
- `src/leld.py` is the primary entrypoint (X11 transcription daemon).
- `src/recorder.py` handles audio capture using PortAudio via `sounddevice`.
- `src/stt.py` wraps faster-whisper for transcription.
## Build, Test, and Development Commands
- `./lel.sh` streams transcription from the microphone until you press Enter.
- Example with overrides: `WHISPER_MODEL=small WHISPER_LANG=pt WHISPER_DEVICE=cuda ./lel.sh`.
- Dependencies expected on PATH: `ffmpeg` and `whisper` (the OpenAI Whisper CLI).
- Install deps: `uv sync`.
- Run daemon: `uv run python3 src/leld.py --config ~/.config/lel/config.json`.
- Open settings: `uv run python3 src/leld.py --settings --config ~/.config/lel/config.json`.
## Coding Style & Naming Conventions
@ -30,7 +30,5 @@
## Configuration Tips
- Audio input is controlled via `WHISPER_FFMPEG_IN` (default `pulse:default`), e.g., `alsa:default`.
- Streaming is on by default; set `WHISPER_STREAM=0` to transcribe after recording.
- Segment duration for streaming is `WHISPER_SEGMENT_SEC` (default `5`).
- Audio input is controlled via `WHISPER_FFMPEG_IN` (device index or name).
- Model, language, device, and extra args can be set with `WHISPER_MODEL`, `WHISPER_LANG`, `WHISPER_DEVICE`, and `WHISPER_EXTRA_ARGS`.

View file

@ -5,12 +5,13 @@ Python X11 transcription daemon that records audio, runs Whisper, logs the trans
## Requirements
- X11 (not Wayland)
- `ffmpeg`
- `sounddevice` (PortAudio)
- `soundfile` (libsndfile)
- `faster-whisper`
- `pactl` (PulseAudio utilities for mic selection)
- Tray icon deps: `gtk3`
- i3 window manager (focus metadata via i3 IPC)
- Python deps: `pillow`, `python-xlib`, `faster-whisper`, `PyGObject`, `i3ipc`
- Python deps: `pillow`, `python-xlib`, `faster-whisper`, `PyGObject`, `i3ipc`, `sounddevice`, `soundfile`
System packages (example names): `portaudio`/`libportaudio2` and `libsndfile`.
## Python Daemon
@ -39,7 +40,7 @@ Create `~/.config/lel/config.json`:
```json
{
"daemon": { "hotkey": "Cmd+m" },
"recording": { "input": "pulse:default" },
"recording": { "input": "0" },
"transcribing": { "model": "base", "device": "cpu" },
"injection": { "backend": "clipboard" },
@ -56,11 +57,14 @@ Create `~/.config/lel/config.json`:
Env overrides:
- `WHISPER_MODEL`, `WHISPER_DEVICE`
- `WHISPER_FFMPEG_IN`
- `WHISPER_FFMPEG_IN` (device index or name)
- `LEL_HOTKEY`, `LEL_INJECTION_BACKEND`
- `LEL_AI_CLEANUP_ENABLED`, `LEL_AI_CLEANUP_MODEL`, `LEL_AI_CLEANUP_TEMPERATURE`
- `LEL_AI_CLEANUP_BASE_URL`, `LEL_AI_CLEANUP_API_KEY`
Recording input can be a device index (preferred) or a substring of the device
name. Use the settings window to list available input devices.
## systemd user service
```bash

View file

@ -10,6 +10,8 @@ dependencies = [
"python-xlib",
"PyGObject",
"i3ipc",
"sounddevice",
"soundfile",
]
[tool.uv]

View file

@ -11,7 +11,7 @@ def _parse_bool(val: str) -> bool:
@dataclass
class Config:
daemon: dict = field(default_factory=lambda: {"hotkey": "Cmd+m"})
recording: dict = field(default_factory=lambda: {"input": "pulse:default"})
recording: dict = field(default_factory=lambda: {"input": ""})
transcribing: dict = field(default_factory=lambda: {"model": "base", "device": "cpu"})
injection: dict = field(default_factory=lambda: {"backend": "clipboard"})
ai_cleanup: dict = field(
@ -55,7 +55,7 @@ def load(path: str | None) -> Config:
if not isinstance(cfg.daemon, dict):
cfg.daemon = {"hotkey": "Cmd+m"}
if not isinstance(cfg.recording, dict):
cfg.recording = {"input": "pulse:default"}
cfg.recording = {"input": ""}
if not isinstance(cfg.transcribing, dict):
cfg.transcribing = {"model": "base", "device": "cpu"}
if not isinstance(cfg.injection, dict):

View file

@ -1,141 +0,0 @@
from __future__ import annotations
import json
import sqlite3
import time
from dataclasses import asdict
from pathlib import Path
from config import redacted_dict
def _default_db_path() -> Path:
return Path.home() / ".local" / "share" / "lel" / "history.db"
class HistoryStore:
def __init__(self, path: Path | None = None):
self.path = path or _default_db_path()
self.path.parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(str(self.path), check_same_thread=False)
self._init_db()
def _init_db(self):
cur = self.conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at REAL NOT NULL,
phase TEXT NOT NULL,
status TEXT NOT NULL,
config_json TEXT,
context_json TEXT
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS artifacts (
run_id INTEGER NOT NULL,
kind TEXT NOT NULL,
data_json TEXT,
file_path TEXT,
created_at REAL NOT NULL,
FOREIGN KEY(run_id) REFERENCES runs(id)
)
"""
)
self.conn.commit()
def add_run(self, phase: str, status: str, config, context: dict | None = None) -> int:
cur = self.conn.cursor()
cur.execute(
"INSERT INTO runs (created_at, phase, status, config_json, context_json) VALUES (?, ?, ?, ?, ?)",
(
time.time(),
phase,
status,
json.dumps(redacted_dict(config)) if config else None,
json.dumps(context) if context else None,
),
)
self.conn.commit()
return int(cur.lastrowid)
def add_artifact(self, run_id: int, kind: str, data: dict | None = None, file_path: str | None = None):
cur = self.conn.cursor()
cur.execute(
"INSERT INTO artifacts (run_id, kind, data_json, file_path, created_at) VALUES (?, ?, ?, ?, ?)",
(
run_id,
kind,
json.dumps(data) if data is not None else None,
file_path,
time.time(),
),
)
self.conn.commit()
def list_runs(self, phase: str | None = None, limit: int = 200) -> list[dict]:
cur = self.conn.cursor()
if phase:
cur.execute(
"SELECT id, created_at, phase, status, config_json, context_json FROM runs WHERE phase = ? ORDER BY id DESC LIMIT ?",
(phase, limit),
)
else:
cur.execute(
"SELECT id, created_at, phase, status, config_json, context_json FROM runs ORDER BY id DESC LIMIT ?",
(limit,),
)
rows = []
for row in cur.fetchall():
rows.append(
{
"id": row[0],
"created_at": row[1],
"phase": row[2],
"status": row[3],
"config": json.loads(row[4]) if row[4] else None,
"context": json.loads(row[5]) if row[5] else None,
}
)
return rows
def list_artifacts(self, run_id: int) -> list[dict]:
cur = self.conn.cursor()
cur.execute(
"SELECT kind, data_json, file_path, created_at FROM artifacts WHERE run_id = ? ORDER BY created_at ASC",
(run_id,),
)
out = []
for row in cur.fetchall():
out.append(
{
"kind": row[0],
"data": json.loads(row[1]) if row[1] else None,
"file_path": row[2],
"created_at": row[3],
}
)
return out
def prune(self, limit_per_phase: int = 1000):
cur = self.conn.cursor()
cur.execute("SELECT DISTINCT phase FROM runs")
phases = [r[0] for r in cur.fetchall()]
for phase in phases:
cur.execute("SELECT id FROM runs WHERE phase = ? ORDER BY id DESC LIMIT ?", (phase, limit_per_phase))
keep_ids = [r[0] for r in cur.fetchall()]
if not keep_ids:
continue
cur.execute(
"DELETE FROM runs WHERE phase = ? AND id NOT IN (%s)" % ",".join("?" * len(keep_ids)),
(phase, *keep_ids),
)
cur.execute(
"DELETE FROM artifacts WHERE run_id NOT IN (%s)" % ",".join("?" * len(keep_ids)),
(*keep_ids,),
)
self.conn.commit()

View file

@ -15,7 +15,6 @@ from stt import FasterWhisperSTT, STTConfig
from aiprocess import AIConfig, build_processor
from context import I3Provider
from inject import inject
from history import HistoryStore
from x11_hotkey import listen
from tray import run_tray
from settings_window import open_settings_window
@ -32,8 +31,6 @@ class State:
class Daemon:
def __init__(self, cfg: Config):
self.cfg = cfg
self.history = HistoryStore()
self.history.prune(1000)
self.lock = threading.Lock()
self.state = State.IDLE
self.proc = None
@ -75,7 +72,7 @@ class Daemon:
def _start_recording_locked(self):
try:
proc, record = start_recording(self.cfg.recording.get("input", "pulse:default"))
proc, record = start_recording(self.cfg.recording.get("input", ""))
except Exception as exc:
logging.error("record start failed: %s", exc)
return
@ -100,8 +97,6 @@ class Daemon:
self.record = record
self.state = State.RECORDING
logging.info("recording started (%s)", record.wav_path)
run_id = self.history.add_run("record", "started", self.cfg, self._context_json(self.context))
self.history.add_artifact(run_id, "audio", {"path": record.wav_path}, record.wav_path)
if self.timer:
self.timer.cancel()
self.timer = threading.Timer(300, self._timeout_stop)
@ -130,7 +125,7 @@ class Daemon:
logging.info("stopping recording (user)")
try:
stop_recording(proc)
stop_recording(proc, record)
except Exception as exc:
logging.error("record stop failed: %s", exc)
self.set_state(State.IDLE)
@ -156,9 +151,6 @@ class Daemon:
return
logging.info("stt: %s", text)
run_id = self.history.add_run("stt", "ok", self.cfg, self._context_json(self.context))
self.history.add_artifact(run_id, "input", {"wav_path": record.wav_path, "language": "en"})
self.history.add_artifact(run_id, "output", {"text": text})
ai_enabled = self.cfg.ai_cleanup.get("enabled", False)
ai_prompt_file = ""
@ -180,17 +172,6 @@ class Daemon:
)
ai_input = text
text = processor.process(ai_input) or text
run_id = self.history.add_run("ai", "ok", self.cfg, self._context_json(self.context))
self.history.add_artifact(
run_id,
"input",
{
"text": ai_input,
"model": self.cfg.ai_cleanup.get("model", ""),
"temperature": self.cfg.ai_cleanup.get("temperature", 0.0),
},
)
self.history.add_artifact(run_id, "output", {"text": text})
except Exception as exc:
logging.error("ai process failed: %s", exc)
@ -206,8 +187,6 @@ class Daemon:
return
backend = self.cfg.injection.get("backend", "clipboard")
inject(text, backend)
run_id = self.history.add_run("inject", "ok", self.cfg, self._context_json(self.context))
self.history.add_artifact(run_id, "input", {"text": text, "backend": backend})
except Exception as exc:
logging.error("output failed: %s", exc)
finally:
@ -263,7 +242,7 @@ def main():
open_settings_window(cfg, config_path)
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
from gi.repository import Gtk # type: ignore[import-not-found]
Gtk.main()
return

View file

@ -1,68 +1,92 @@
import os
import signal
import subprocess
import tempfile
import time
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable
import numpy as np
import sounddevice as sd # type: ignore[import-not-found]
import soundfile as sf # type: ignore[import-not-found]
@dataclass
class RecordResult:
wav_path: str
temp_dir: str
frames: list[np.ndarray] = field(default_factory=list)
samplerate: int = 16000
channels: int = 1
dtype: str = "int16"
def _resolve_ffmpeg_path() -> str:
appdir = os.getenv("APPDIR")
if appdir:
candidate = Path(appdir) / "usr" / "bin" / "ffmpeg"
if candidate.exists():
return str(candidate)
return "ffmpeg"
def list_input_devices() -> list[dict]:
devices = []
for idx, info in enumerate(sd.query_devices()):
if info.get("max_input_channels", 0) > 0:
devices.append({"index": idx, "name": info.get("name", "")})
return devices
def _ffmpeg_input_args(spec: str) -> list[str]:
if not spec:
spec = "pulse:default"
kind = spec
name = "default"
if ":" in spec:
kind, name = spec.split(":", 1)
return ["-f", kind, "-i", name]
def default_input_device() -> int | None:
default = sd.default.device
if isinstance(default, (tuple, list)) and default:
return default[0]
if isinstance(default, int):
return default
return None
def start_recording(ffmpeg_input: str) -> tuple[subprocess.Popen, RecordResult]:
def resolve_input_device(spec: str | int | None) -> int | None:
if spec is None:
return None
if isinstance(spec, int):
return spec
text = str(spec).strip()
if not text:
return None
if text.isdigit():
return int(text)
lowered = text.lower()
for device in list_input_devices():
name = (device.get("name") or "").lower()
if lowered in name:
return int(device["index"])
return None
def start_recording(input_spec: str | int | None) -> tuple[sd.InputStream, RecordResult]:
tmpdir = tempfile.mkdtemp(prefix="lel-")
wav = str(Path(tmpdir) / "mic.wav")
record = RecordResult(wav_path=wav, temp_dir=tmpdir)
device = resolve_input_device(input_spec)
args = ["-hide_banner", "-loglevel", "error"]
args += _ffmpeg_input_args(ffmpeg_input)
args += ["-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav]
def callback(indata, _frames, _time, _status):
record.frames.append(indata.copy())
proc = subprocess.Popen(
[_resolve_ffmpeg_path(), *args],
preexec_fn=os.setsid,
stream = sd.InputStream(
samplerate=record.samplerate,
channels=record.channels,
dtype=record.dtype,
device=device,
callback=callback,
)
return proc, RecordResult(wav_path=wav, temp_dir=tmpdir)
stream.start()
return stream, record
def stop_recording(proc: subprocess.Popen, timeout_sec: float = 5.0) -> None:
if proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGINT)
except ProcessLookupError:
return
start = time.time()
while proc.poll() is None:
if time.time() - start > timeout_sec:
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
break
time.sleep(0.05)
def stop_recording(stream: sd.InputStream, record: RecordResult) -> None:
if stream:
stream.stop()
stream.close()
_write_wav(record)
# ffmpeg returns 255 on SIGINT; treat as success
if proc.returncode not in (0, 255, None):
raise RuntimeError(f"ffmpeg exited with status {proc.returncode}")
def _write_wav(record: RecordResult) -> None:
data = _flatten_frames(record.frames)
sf.write(record.wav_path, data, record.samplerate, subtype="PCM_16")
def _flatten_frames(frames: Iterable[np.ndarray]) -> np.ndarray:
frames = list(frames)
if not frames:
return np.zeros((0, 1), dtype=np.int16)
return np.concatenate(frames, axis=0)

View file

@ -1,7 +1,6 @@
from __future__ import annotations
import json
import subprocess
import time
from dataclasses import asdict
from pathlib import Path
@ -11,10 +10,10 @@ import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk, Gtk
from gi.repository import Gdk, Gtk # type: ignore[import-not-found]
from config import Config, validate
from history import HistoryStore
from recorder import default_input_device, list_input_devices, resolve_input_device
from aiprocess import list_models
@ -22,7 +21,6 @@ class SettingsWindow:
def __init__(self, cfg: Config, config_path: Path):
self.cfg = cfg
self.config_path = config_path
self.history = HistoryStore()
self._model_cache: dict[str, list[str]] = {}
self.window = Gtk.Window(title="lel settings")
self.window.set_default_size(920, 700)
@ -56,67 +54,6 @@ class SettingsWindow:
self.window.add(vbox)
self.window.show_all()
def _refresh_history(self, *_args):
if not hasattr(self, "history_list"):
return
for row in self.history_list.get_children():
self.history_list.remove(row)
phase = self.widgets["history_phase"].get_active_text()
if phase == "all":
phase = None
runs = self.history.list_runs(phase=phase, limit=200)
for run in runs:
row = Gtk.ListBoxRow()
label = Gtk.Label(
label=f"#{run['id']} {run['phase']} {run['status']} {time.strftime('%H:%M:%S', time.localtime(run['created_at']))}"
)
label.set_xalign(0.0)
row.add(label)
row._run = run
self.history_list.add(row)
self.history_list.show_all()
def _on_history_select(self, _listbox, row):
if not row:
return
run = row._run
artifacts = self.history.list_artifacts(run["id"])
buf = self.history_detail.get_buffer()
buf.set_text(self._format_run(run, artifacts))
def _format_run(self, run: dict, artifacts: list[dict]) -> str:
lines = [f"Run #{run['id']} ({run['phase']})", f"Status: {run['status']}"]
if run.get("context"):
lines.append(f"Context: {run['context']}")
for art in artifacts:
lines.append(f"- {art['kind']}: {art.get('data') or art.get('file_path')}")
return "\n".join(lines)
def _on_history_copy(self, *_args):
row = self.history_list.get_selected_row()
if not row:
return
run = row._run
artifacts = self.history.list_artifacts(run["id"])
text = ""
for art in artifacts:
if art["kind"] == "output" and art.get("data") and art["data"].get("text"):
text = art["data"]["text"]
if text:
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(text, -1)
clipboard.store()
def _on_history_rerun(self, *_args):
row = self.history_list.get_selected_row()
if not row:
return
run = row._run
artifacts = self.history.list_artifacts(run["id"])
phase = run["phase"]
if phase == "ai":
self._open_ai_rerun(run, artifacts)
def _on_quick_run(self, *_args):
buf = self.quick_text.get_buffer()
start, end = buf.get_bounds()
@ -162,23 +99,7 @@ class SettingsWindow:
)
)
output = processor.process(output)
run_id = self.history.add_run("ai", "ok", self.cfg, None)
self.history.add_artifact(
run_id,
"input",
{
"step": idx,
"text": output,
"language": language,
"model": step["model"],
"temperature": step["temperature"],
"prompt_text": step.get("prompt_text") or "",
"base_url": step["base_url"],
},
)
self.history.add_artifact(run_id, "output", {"text": output})
self.widgets["quick_status"].set_text("Done")
self._refresh_history()
def _collect_quick_steps(self) -> list[dict]:
steps: list[dict] = []
@ -205,75 +126,10 @@ class SettingsWindow:
)
return steps
def _open_ai_rerun(self, _run: dict, artifacts: list[dict]):
input_text = ""
for art in artifacts:
if art["kind"] == "input" and art.get("data"):
input_text = art["data"].get("text", "")
dialog = Gtk.Dialog(title="Re-run AI", transient_for=self.window, flags=0)
dialog.add_button("Run", Gtk.ResponseType.OK)
dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
box = dialog.get_content_area()
textview = Gtk.TextView()
textview.get_buffer().set_text(input_text)
scroll = Gtk.ScrolledWindow()
scroll.add(textview)
scroll.set_size_request(600, 300)
box.add(scroll)
dialog.show_all()
resp = dialog.run()
if resp == Gtk.ResponseType.OK:
buf = textview.get_buffer()
start, end = buf.get_bounds()
text = buf.get_text(start, end, True)
from aiprocess import AIConfig, build_processor
processor = build_processor(
AIConfig(
model=self.cfg.ai_cleanup.get("model", ""),
temperature=self.cfg.ai_cleanup.get("temperature", 0.0),
system_prompt_file="",
base_url=self.cfg.ai_cleanup.get("base_url", ""),
api_key=self.cfg.ai_cleanup.get("api_key", ""),
timeout_sec=25,
)
)
output = processor.process(text)
run_id = self.history.add_run("ai", "ok", self.cfg, None)
self.history.add_artifact(run_id, "input", {"text": text})
self.history.add_artifact(run_id, "output", {"text": output})
self._refresh_history()
dialog.destroy()
def _build_tabs(self):
self._add_tab("Settings", self._build_settings_tab())
self._add_tab("History", self._build_history_tab())
self._add_tab("Quick Run", self._build_quick_run_tab())
def _build_settings_tab(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
box.set_border_width(8)
box.pack_start(self._section_label("Daemon"), False, False, 0)
box.pack_start(self._build_hotkeys_tab(), False, False, 0)
box.pack_start(self._section_label("Recording"), False, False, 0)
box.pack_start(self._build_recording_tab(), False, False, 0)
box.pack_start(self._section_label("Transcribing"), False, False, 0)
box.pack_start(self._build_stt_tab(), False, False, 0)
box.pack_start(self._section_label("Injection"), False, False, 0)
box.pack_start(self._build_injection_tab(), False, False, 0)
box.pack_start(self._section_label("AI Cleanup"), False, False, 0)
box.pack_start(self._build_ai_tab(), False, False, 0)
scroll = Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scroll.add(box)
return scroll
def _add_tab(self, title: str, widget: Gtk.Widget):
label = Gtk.Label(label=title)
self.notebook.append_page(widget, label)
@ -334,78 +190,48 @@ class SettingsWindow:
def _build_recording_tab(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
grid = self._grid()
self.widgets["ffmpeg_input"] = Gtk.ComboBoxText()
self.widgets["input_device"] = Gtk.ComboBoxText()
self._populate_mic_sources()
refresh_btn = Gtk.Button(label="Refresh")
refresh_btn.connect("clicked", lambda *_: self._populate_mic_sources())
mic_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
mic_row.pack_start(self.widgets["ffmpeg_input"], True, True, 0)
mic_row.pack_start(self.widgets["input_device"], True, True, 0)
mic_row.pack_start(refresh_btn, False, False, 0)
self._row(grid, 0, "Microphone", mic_row)
self._row(grid, 0, "Input Device", mic_row)
# Record timeout is fixed (300s); no UI control.
box.pack_start(grid, False, False, 0)
return box
def _selected_mic_source(self) -> str:
combo = self.widgets["ffmpeg_input"]
text = combo.get_active_text() or ""
if text.startswith("pulse:"):
return text.split(" ", 1)[0]
return self.cfg.recording.get("input", "pulse:default")
combo = self.widgets["input_device"]
text = (combo.get_active_text() or "").strip()
if ":" in text:
prefix = text.split(":", 1)[0]
if prefix.isdigit():
return prefix
return self.cfg.recording.get("input", "")
def _populate_mic_sources(self):
combo: Gtk.ComboBoxText = self.widgets["ffmpeg_input"]
combo: Gtk.ComboBoxText = self.widgets["input_device"]
combo.remove_all()
sources, default_name = self._list_pulse_sources()
self._mic_sources = sources
selected = self.cfg.recording.get("input") or "pulse:default"
devices = list_input_devices()
selected_spec = self.cfg.recording.get("input") or ""
selected_device = resolve_input_device(selected_spec)
default_device = default_input_device()
selected_index = 0
for idx, (name, desc) in enumerate(sources):
text = f"pulse:{name} ({desc})"
combo.append_text(text)
if selected.startswith(f"pulse:{name}"):
for idx, device in enumerate(devices):
label = f"{device['index']}: {device['name']}"
combo.append_text(label)
if selected_device is not None and device["index"] == selected_device:
selected_index = idx
if selected == "pulse:default" and default_name:
for idx, (name, _desc) in enumerate(sources):
if name == default_name:
selected_index = idx
break
if sources:
elif selected_device is None and default_device is not None and device["index"] == default_device:
selected_index = idx
if devices:
combo.set_active(selected_index)
else:
combo.append_text("pulse:default (default)")
combo.append_text("default (system)")
combo.set_active(0)
def _list_pulse_sources(self) -> tuple[list[tuple[str, str]], str | None]:
default_name = None
try:
proc = subprocess.run(["pactl", "list", "sources", "short"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if proc.returncode != 0:
return ([], None)
out = []
for line in proc.stdout.splitlines():
parts = line.split("\t")
if len(parts) >= 2:
name = parts[1]
desc = parts[-1] if parts[-1] else name
out.append((name, desc))
default_name = self._get_pulse_default_source()
return (out, default_name)
except Exception:
return ([], None)
def _get_pulse_default_source(self) -> str | None:
try:
proc = subprocess.run(["pactl", "info"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if proc.returncode != 0:
return None
for line in proc.stdout.splitlines():
if line.lower().startswith("default source:"):
return line.split(":", 1)[1].strip()
except Exception:
return None
return None
def _build_stt_tab(self) -> Gtk.Widget:
grid = self._grid()
models = ["tiny", "base", "small", "medium", "large", "large-v2", "large-v3"]
@ -441,47 +267,6 @@ class SettingsWindow:
# AI timeout is fixed (25s); no UI control.
return grid
def _build_history_tab(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
header = Gtk.Label(label="History")
header.set_xalign(0.0)
box.pack_start(header, False, False, 0)
filter_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self.widgets["history_phase"] = self._combo(["all", "record", "stt", "ai", "inject"], "all")
refresh_btn = Gtk.Button(label="Refresh")
refresh_btn.connect("clicked", self._refresh_history)
filter_row.pack_start(Gtk.Label(label="Phase"), False, False, 0)
filter_row.pack_start(self.widgets["history_phase"], False, False, 0)
filter_row.pack_start(refresh_btn, False, False, 0)
box.pack_start(filter_row, False, False, 0)
self.history_list = Gtk.ListBox()
self.history_list.set_selection_mode(Gtk.SelectionMode.SINGLE)
self.history_list.connect("row-selected", self._on_history_select)
box.pack_start(self.history_list, True, True, 0)
self.history_detail = Gtk.TextView()
self.history_detail.set_editable(False)
self.history_detail.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
detail_scroll = Gtk.ScrolledWindow()
detail_scroll.add(self.history_detail)
detail_scroll.set_vexpand(True)
box.pack_start(detail_scroll, True, True, 0)
action_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self.widgets["history_rerun"] = Gtk.Button(label="Re-run")
self.widgets["history_rerun"].connect("clicked", self._on_history_rerun)
self.widgets["history_copy"] = Gtk.Button(label="Copy Output")
self.widgets["history_copy"].connect("clicked", self._on_history_copy)
action_row.pack_start(self.widgets["history_rerun"], False, False, 0)
action_row.pack_start(self.widgets["history_copy"], False, False, 0)
box.pack_start(action_row, False, False, 0)
self._refresh_history()
return box
def _build_quick_run_tab(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
header = Gtk.Label(label="Bypass recording and run from text")

131
uv.lock generated
View file

@ -86,6 +86,88 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
{ url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
{ url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
{ url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
{ url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
{ url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
{ url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
{ url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
{ url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
{ url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
{ url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
{ url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "click"
version = "8.3.1"
@ -322,6 +404,8 @@ dependencies = [
{ name = "pillow" },
{ name = "pygobject" },
{ name = "python-xlib" },
{ name = "sounddevice" },
{ name = "soundfile" },
]
[package.metadata]
@ -331,6 +415,8 @@ requires-dist = [
{ name = "pillow" },
{ name = "pygobject" },
{ name = "python-xlib" },
{ name = "sounddevice" },
{ name = "soundfile" },
]
[[package]]
@ -671,6 +757,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pygobject"
version = "3.54.5"
@ -783,6 +878,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sounddevice"
version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" },
{ url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" },
{ url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" },
{ url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" },
{ url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" },
]
[[package]]
name = "soundfile"
version = "0.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156, upload-time = "2025-01-25T09:17:04.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/28/e2a36573ccbcf3d57c00626a21fe51989380636e821b341d36ccca0c1c3a/soundfile-0.13.1-py2.py3-none-any.whl", hash = "sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445", size = 25751, upload-time = "2025-01-25T09:16:44.235Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ab/73e97a5b3cc46bba7ff8650a1504348fa1863a6f9d57d7001c6b67c5f20e/soundfile-0.13.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33", size = 1142250, upload-time = "2025-01-25T09:16:47.583Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406, upload-time = "2025-01-25T09:16:49.662Z" },
{ url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729, upload-time = "2025-01-25T09:16:53.018Z" },
{ url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646, upload-time = "2025-01-25T09:16:54.872Z" },
{ url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881, upload-time = "2025-01-25T09:16:56.663Z" },
{ url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" },
]
[[package]]
name = "sympy"
version = "1.14.0"