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 ## Project Structure & Module Organization
- `lel.sh` is the primary entrypoint; it records audio, runs `whisper`, and prints the transcript. - `src/leld.py` is the primary entrypoint (X11 transcription daemon).
- `env/` is a local Python virtual environment (optional) used to install runtime dependencies. - `src/recorder.py` handles audio capture using PortAudio via `sounddevice`.
- There are no separate source, test, or asset directories at this time. - `src/stt.py` wraps faster-whisper for transcription.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `./lel.sh` streams transcription from the microphone until you press Enter. - Install deps: `uv sync`.
- Example with overrides: `WHISPER_MODEL=small WHISPER_LANG=pt WHISPER_DEVICE=cuda ./lel.sh`. - Run daemon: `uv run python3 src/leld.py --config ~/.config/lel/config.json`.
- Dependencies expected on PATH: `ffmpeg` and `whisper` (the OpenAI Whisper CLI). - Open settings: `uv run python3 src/leld.py --settings --config ~/.config/lel/config.json`.
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
@ -30,7 +30,5 @@
## Configuration Tips ## Configuration Tips
- Audio input is controlled via `WHISPER_FFMPEG_IN` (default `pulse:default`), e.g., `alsa:default`. - Audio input is controlled via `WHISPER_FFMPEG_IN` (device index or name).
- Streaming is on by default; set `WHISPER_STREAM=0` to transcribe after recording.
- Segment duration for streaming is `WHISPER_SEGMENT_SEC` (default `5`).
- Model, language, device, and extra args can be set with `WHISPER_MODEL`, `WHISPER_LANG`, `WHISPER_DEVICE`, and `WHISPER_EXTRA_ARGS`. - 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 ## Requirements
- X11 (not Wayland) - X11 (not Wayland)
- `ffmpeg` - `sounddevice` (PortAudio)
- `soundfile` (libsndfile)
- `faster-whisper` - `faster-whisper`
- `pactl` (PulseAudio utilities for mic selection)
- Tray icon deps: `gtk3` - Tray icon deps: `gtk3`
- i3 window manager (focus metadata via i3 IPC) - Python deps: `pillow`, `python-xlib`, `faster-whisper`, `PyGObject`, `i3ipc`, `sounddevice`, `soundfile`
- Python deps: `pillow`, `python-xlib`, `faster-whisper`, `PyGObject`, `i3ipc`
System packages (example names): `portaudio`/`libportaudio2` and `libsndfile`.
## Python Daemon ## Python Daemon
@ -39,7 +40,7 @@ Create `~/.config/lel/config.json`:
```json ```json
{ {
"daemon": { "hotkey": "Cmd+m" }, "daemon": { "hotkey": "Cmd+m" },
"recording": { "input": "pulse:default" }, "recording": { "input": "0" },
"transcribing": { "model": "base", "device": "cpu" }, "transcribing": { "model": "base", "device": "cpu" },
"injection": { "backend": "clipboard" }, "injection": { "backend": "clipboard" },
@ -56,11 +57,14 @@ Create `~/.config/lel/config.json`:
Env overrides: Env overrides:
- `WHISPER_MODEL`, `WHISPER_DEVICE` - `WHISPER_MODEL`, `WHISPER_DEVICE`
- `WHISPER_FFMPEG_IN` - `WHISPER_FFMPEG_IN` (device index or name)
- `LEL_HOTKEY`, `LEL_INJECTION_BACKEND` - `LEL_HOTKEY`, `LEL_INJECTION_BACKEND`
- `LEL_AI_CLEANUP_ENABLED`, `LEL_AI_CLEANUP_MODEL`, `LEL_AI_CLEANUP_TEMPERATURE` - `LEL_AI_CLEANUP_ENABLED`, `LEL_AI_CLEANUP_MODEL`, `LEL_AI_CLEANUP_TEMPERATURE`
- `LEL_AI_CLEANUP_BASE_URL`, `LEL_AI_CLEANUP_API_KEY` - `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 ## systemd user service
```bash ```bash

View file

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

View file

@ -11,7 +11,7 @@ def _parse_bool(val: str) -> bool:
@dataclass @dataclass
class Config: class Config:
daemon: dict = field(default_factory=lambda: {"hotkey": "Cmd+m"}) 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"}) transcribing: dict = field(default_factory=lambda: {"model": "base", "device": "cpu"})
injection: dict = field(default_factory=lambda: {"backend": "clipboard"}) injection: dict = field(default_factory=lambda: {"backend": "clipboard"})
ai_cleanup: dict = field( ai_cleanup: dict = field(
@ -55,7 +55,7 @@ def load(path: str | None) -> Config:
if not isinstance(cfg.daemon, dict): if not isinstance(cfg.daemon, dict):
cfg.daemon = {"hotkey": "Cmd+m"} cfg.daemon = {"hotkey": "Cmd+m"}
if not isinstance(cfg.recording, dict): if not isinstance(cfg.recording, dict):
cfg.recording = {"input": "pulse:default"} cfg.recording = {"input": ""}
if not isinstance(cfg.transcribing, dict): if not isinstance(cfg.transcribing, dict):
cfg.transcribing = {"model": "base", "device": "cpu"} cfg.transcribing = {"model": "base", "device": "cpu"}
if not isinstance(cfg.injection, dict): 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 aiprocess import AIConfig, build_processor
from context import I3Provider from context import I3Provider
from inject import inject from inject import inject
from history import HistoryStore
from x11_hotkey import listen from x11_hotkey import listen
from tray import run_tray from tray import run_tray
from settings_window import open_settings_window from settings_window import open_settings_window
@ -32,8 +31,6 @@ class State:
class Daemon: class Daemon:
def __init__(self, cfg: Config): def __init__(self, cfg: Config):
self.cfg = cfg self.cfg = cfg
self.history = HistoryStore()
self.history.prune(1000)
self.lock = threading.Lock() self.lock = threading.Lock()
self.state = State.IDLE self.state = State.IDLE
self.proc = None self.proc = None
@ -75,7 +72,7 @@ class Daemon:
def _start_recording_locked(self): def _start_recording_locked(self):
try: 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: except Exception as exc:
logging.error("record start failed: %s", exc) logging.error("record start failed: %s", exc)
return return
@ -100,8 +97,6 @@ class Daemon:
self.record = record self.record = record
self.state = State.RECORDING self.state = State.RECORDING
logging.info("recording started (%s)", record.wav_path) 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: if self.timer:
self.timer.cancel() self.timer.cancel()
self.timer = threading.Timer(300, self._timeout_stop) self.timer = threading.Timer(300, self._timeout_stop)
@ -130,7 +125,7 @@ class Daemon:
logging.info("stopping recording (user)") logging.info("stopping recording (user)")
try: try:
stop_recording(proc) stop_recording(proc, record)
except Exception as exc: except Exception as exc:
logging.error("record stop failed: %s", exc) logging.error("record stop failed: %s", exc)
self.set_state(State.IDLE) self.set_state(State.IDLE)
@ -156,9 +151,6 @@ class Daemon:
return return
logging.info("stt: %s", text) 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_enabled = self.cfg.ai_cleanup.get("enabled", False)
ai_prompt_file = "" ai_prompt_file = ""
@ -180,17 +172,6 @@ class Daemon:
) )
ai_input = text ai_input = text
text = processor.process(ai_input) or 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: except Exception as exc:
logging.error("ai process failed: %s", exc) logging.error("ai process failed: %s", exc)
@ -206,8 +187,6 @@ class Daemon:
return return
backend = self.cfg.injection.get("backend", "clipboard") backend = self.cfg.injection.get("backend", "clipboard")
inject(text, backend) 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: except Exception as exc:
logging.error("output failed: %s", exc) logging.error("output failed: %s", exc)
finally: finally:
@ -263,7 +242,7 @@ def main():
open_settings_window(cfg, config_path) open_settings_window(cfg, config_path)
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk from gi.repository import Gtk # type: ignore[import-not-found]
Gtk.main() Gtk.main()
return return

View file

@ -1,68 +1,92 @@
import os
import signal
import subprocess
import tempfile import tempfile
import time from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path 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 @dataclass
class RecordResult: class RecordResult:
wav_path: str wav_path: str
temp_dir: 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: def list_input_devices() -> list[dict]:
appdir = os.getenv("APPDIR") devices = []
if appdir: for idx, info in enumerate(sd.query_devices()):
candidate = Path(appdir) / "usr" / "bin" / "ffmpeg" if info.get("max_input_channels", 0) > 0:
if candidate.exists(): devices.append({"index": idx, "name": info.get("name", "")})
return str(candidate) return devices
return "ffmpeg"
def _ffmpeg_input_args(spec: str) -> list[str]: def default_input_device() -> int | None:
if not spec: default = sd.default.device
spec = "pulse:default" if isinstance(default, (tuple, list)) and default:
kind = spec return default[0]
name = "default" if isinstance(default, int):
if ":" in spec: return default
kind, name = spec.split(":", 1) return None
return ["-f", kind, "-i", name]
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-") tmpdir = tempfile.mkdtemp(prefix="lel-")
wav = str(Path(tmpdir) / "mic.wav") 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"] def callback(indata, _frames, _time, _status):
args += _ffmpeg_input_args(ffmpeg_input) record.frames.append(indata.copy())
args += ["-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav]
proc = subprocess.Popen( stream = sd.InputStream(
[_resolve_ffmpeg_path(), *args], samplerate=record.samplerate,
preexec_fn=os.setsid, 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: def stop_recording(stream: sd.InputStream, record: RecordResult) -> None:
if proc.poll() is None: if stream:
try: stream.stop()
os.killpg(proc.pid, signal.SIGINT) stream.close()
except ProcessLookupError: _write_wav(record)
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)
# ffmpeg returns 255 on SIGINT; treat as success
if proc.returncode not in (0, 255, None): def _write_wav(record: RecordResult) -> None:
raise RuntimeError(f"ffmpeg exited with status {proc.returncode}") 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 from __future__ import annotations
import json import json
import subprocess
import time import time
from dataclasses import asdict from dataclasses import asdict
from pathlib import Path from pathlib import Path
@ -11,10 +10,10 @@ import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "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 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 from aiprocess import list_models
@ -22,7 +21,6 @@ class SettingsWindow:
def __init__(self, cfg: Config, config_path: Path): def __init__(self, cfg: Config, config_path: Path):
self.cfg = cfg self.cfg = cfg
self.config_path = config_path self.config_path = config_path
self.history = HistoryStore()
self._model_cache: dict[str, list[str]] = {} self._model_cache: dict[str, list[str]] = {}
self.window = Gtk.Window(title="lel settings") self.window = Gtk.Window(title="lel settings")
self.window.set_default_size(920, 700) self.window.set_default_size(920, 700)
@ -56,67 +54,6 @@ class SettingsWindow:
self.window.add(vbox) self.window.add(vbox)
self.window.show_all() 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): def _on_quick_run(self, *_args):
buf = self.quick_text.get_buffer() buf = self.quick_text.get_buffer()
start, end = buf.get_bounds() start, end = buf.get_bounds()
@ -162,23 +99,7 @@ class SettingsWindow:
) )
) )
output = processor.process(output) 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.widgets["quick_status"].set_text("Done")
self._refresh_history()
def _collect_quick_steps(self) -> list[dict]: def _collect_quick_steps(self) -> list[dict]:
steps: list[dict] = [] steps: list[dict] = []
@ -205,75 +126,10 @@ class SettingsWindow:
) )
return steps 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): def _build_tabs(self):
self._add_tab("Settings", self._build_settings_tab()) 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()) 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): def _add_tab(self, title: str, widget: Gtk.Widget):
label = Gtk.Label(label=title) label = Gtk.Label(label=title)
self.notebook.append_page(widget, label) self.notebook.append_page(widget, label)
@ -334,78 +190,48 @@ class SettingsWindow:
def _build_recording_tab(self) -> Gtk.Widget: def _build_recording_tab(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
grid = self._grid() grid = self._grid()
self.widgets["ffmpeg_input"] = Gtk.ComboBoxText() self.widgets["input_device"] = Gtk.ComboBoxText()
self._populate_mic_sources() self._populate_mic_sources()
refresh_btn = Gtk.Button(label="Refresh") refresh_btn = Gtk.Button(label="Refresh")
refresh_btn.connect("clicked", lambda *_: self._populate_mic_sources()) refresh_btn.connect("clicked", lambda *_: self._populate_mic_sources())
mic_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) 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) 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. # Record timeout is fixed (300s); no UI control.
box.pack_start(grid, False, False, 0) box.pack_start(grid, False, False, 0)
return box return box
def _selected_mic_source(self) -> str: def _selected_mic_source(self) -> str:
combo = self.widgets["ffmpeg_input"] combo = self.widgets["input_device"]
text = combo.get_active_text() or "" text = (combo.get_active_text() or "").strip()
if text.startswith("pulse:"): if ":" in text:
return text.split(" ", 1)[0] prefix = text.split(":", 1)[0]
return self.cfg.recording.get("input", "pulse:default") if prefix.isdigit():
return prefix
return self.cfg.recording.get("input", "")
def _populate_mic_sources(self): def _populate_mic_sources(self):
combo: Gtk.ComboBoxText = self.widgets["ffmpeg_input"] combo: Gtk.ComboBoxText = self.widgets["input_device"]
combo.remove_all() combo.remove_all()
sources, default_name = self._list_pulse_sources() devices = list_input_devices()
self._mic_sources = sources selected_spec = self.cfg.recording.get("input") or ""
selected = self.cfg.recording.get("input") or "pulse:default" selected_device = resolve_input_device(selected_spec)
default_device = default_input_device()
selected_index = 0 selected_index = 0
for idx, (name, desc) in enumerate(sources): for idx, device in enumerate(devices):
text = f"pulse:{name} ({desc})" label = f"{device['index']}: {device['name']}"
combo.append_text(text) combo.append_text(label)
if selected.startswith(f"pulse:{name}"): if selected_device is not None and device["index"] == selected_device:
selected_index = idx selected_index = idx
if selected == "pulse:default" and default_name: elif selected_device is None and default_device is not None and device["index"] == default_device:
for idx, (name, _desc) in enumerate(sources):
if name == default_name:
selected_index = idx selected_index = idx
break if devices:
if sources:
combo.set_active(selected_index) combo.set_active(selected_index)
else: else:
combo.append_text("pulse:default (default)") combo.append_text("default (system)")
combo.set_active(0) 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: def _build_stt_tab(self) -> Gtk.Widget:
grid = self._grid() grid = self._grid()
models = ["tiny", "base", "small", "medium", "large", "large-v2", "large-v3"] models = ["tiny", "base", "small", "medium", "large", "large-v2", "large-v3"]
@ -441,47 +267,6 @@ class SettingsWindow:
# AI timeout is fixed (25s); no UI control. # AI timeout is fixed (25s); no UI control.
return grid 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: def _build_quick_run_tab(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
header = Gtk.Label(label="Bypass recording and run from text") 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" }, { 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]] [[package]]
name = "click" name = "click"
version = "8.3.1" version = "8.3.1"
@ -322,6 +404,8 @@ dependencies = [
{ name = "pillow" }, { name = "pillow" },
{ name = "pygobject" }, { name = "pygobject" },
{ name = "python-xlib" }, { name = "python-xlib" },
{ name = "sounddevice" },
{ name = "soundfile" },
] ]
[package.metadata] [package.metadata]
@ -331,6 +415,8 @@ requires-dist = [
{ name = "pillow" }, { name = "pillow" },
{ name = "pygobject" }, { name = "pygobject" },
{ name = "python-xlib" }, { name = "python-xlib" },
{ name = "sounddevice" },
{ name = "soundfile" },
] ]
[[package]] [[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" }, { 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]] [[package]]
name = "pygobject" name = "pygobject"
version = "3.54.5" 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" }, { 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]] [[package]]
name = "sympy" name = "sympy"
version = "1.14.0" version = "1.14.0"