Switch to sounddevice recording
This commit is contained in:
parent
afdf088d17
commit
b6c0fc0793
9 changed files with 250 additions and 468 deletions
|
|
@ -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):
|
||||
|
|
|
|||
141
src/history.py
141
src/history.py
|
|
@ -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()
|
||||
27
src/leld.py
27
src/leld.py
|
|
@ -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
|
||||
|
||||
|
|
|
|||
116
src/recorder.py
116
src/recorder.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue