From 70781bce8d2c18c8292a89653b6183d2bb210441 Mon Sep 17 00:00:00 2001 From: Yuval Adam <_@yuv.al> Date: Fri, 24 Jul 2026 13:23:44 +0200 Subject: Run source checkout without PYTHONPATH --- README.md | 2 +- packaging/arch/PKGBUILD | 4 +- parley/__init__.py | 3 + parley/cli.py | 74 ++++++ parley/config.py | 87 +++++++ parley/controller.py | 171 ++++++++++++++ parley/ctl.py | 78 ++++++ parley/daemon.py | 261 +++++++++++++++++++++ parley/desktop.py | 59 +++++ parley/errors.py | 17 ++ parley/ibus_engine.py | 204 ++++++++++++++++ parley/interfaces/__init__.py | 1 + parley/interfaces/org.parley.Transcription1.xml | 36 +++ parley/persistence.py | 49 ++++ parley/recording.py | 97 ++++++++ parley/state.py | 62 +++++ parley/transcription.py | 88 +++++++ pyproject.toml | 9 +- scripts/install-dev | 2 +- scripts/install-extension | 2 +- scripts/parley | 4 +- scripts/parleyctl | 4 +- scripts/parleyd | 4 +- src/parley/__init__.py | 3 - src/parley/cli.py | 74 ------ src/parley/config.py | 87 ------- src/parley/controller.py | 171 -------------- src/parley/ctl.py | 78 ------ src/parley/daemon.py | 261 --------------------- src/parley/desktop.py | 59 ----- src/parley/errors.py | 17 -- src/parley/ibus_engine.py | 204 ---------------- src/parley/interfaces/__init__.py | 1 - .../interfaces/org.parley.Transcription1.xml | 36 --- src/parley/persistence.py | 49 ---- src/parley/recording.py | 97 -------- src/parley/state.py | 62 ----- src/parley/transcription.py | 88 ------- 38 files changed, 1303 insertions(+), 1302 deletions(-) create mode 100644 parley/__init__.py create mode 100644 parley/cli.py create mode 100644 parley/config.py create mode 100644 parley/controller.py create mode 100644 parley/ctl.py create mode 100644 parley/daemon.py create mode 100644 parley/desktop.py create mode 100644 parley/errors.py create mode 100644 parley/ibus_engine.py create mode 100644 parley/interfaces/__init__.py create mode 100644 parley/interfaces/org.parley.Transcription1.xml create mode 100644 parley/persistence.py create mode 100644 parley/recording.py create mode 100644 parley/state.py create mode 100644 parley/transcription.py delete mode 100644 src/parley/__init__.py delete mode 100644 src/parley/cli.py delete mode 100644 src/parley/config.py delete mode 100644 src/parley/controller.py delete mode 100644 src/parley/ctl.py delete mode 100644 src/parley/daemon.py delete mode 100644 src/parley/desktop.py delete mode 100644 src/parley/errors.py delete mode 100644 src/parley/ibus_engine.py delete mode 100644 src/parley/interfaces/__init__.py delete mode 100644 src/parley/interfaces/org.parley.Transcription1.xml delete mode 100644 src/parley/persistence.py delete mode 100644 src/parley/recording.py delete mode 100644 src/parley/state.py delete mode 100644 src/parley/transcription.py diff --git a/README.md b/README.md index a4ee32d..c113776 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Parley runs directly with Arch's system Python and PyGObject bindings. No virtua ```bash ./scripts/install-dev -PYTHONPATH=src /usr/bin/python -m unittest discover -s tests +python -m unittest discover -s tests ``` Restart and inspect the daemon with: diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 983ddc4..3613166 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -35,7 +35,7 @@ build() { check() { cd "$srcdir/$pkgname" - PYTHONPATH=src python -m unittest discover -s tests -v + python -m unittest discover -s tests -v } package() { @@ -46,7 +46,7 @@ package() { "$pkgdir/usr/lib/systemd/user/parley.service" install -Dm644 data/dbus/org.parley.Transcription1.service \ "$pkgdir/usr/share/dbus-1/services/org.parley.Transcription1.service" - install -Dm644 src/parley/interfaces/org.parley.Transcription1.xml \ + install -Dm644 parley/interfaces/org.parley.Transcription1.xml \ "$pkgdir/usr/share/dbus-1/interfaces/org.parley.Transcription1.xml" local extension_dir="$pkgdir/usr/share/gnome-shell/extensions/parley@org.parley" diff --git a/parley/__init__.py b/parley/__init__.py new file mode 100644 index 0000000..8340b53 --- /dev/null +++ b/parley/__init__.py @@ -0,0 +1,3 @@ +"""Terminal-independent recording and transcription core for Parley.""" + +__version__ = "0.1.0.dev0" diff --git a/parley/cli.py b/parley/cli.py new file mode 100644 index 0000000..735d532 --- /dev/null +++ b/parley/cli.py @@ -0,0 +1,74 @@ +"""Standalone terminal frontend for the shared Parley core.""" + +import sys +import tempfile +import termios +import tty +from pathlib import Path + +from .config import Config +from .errors import ParleyError +from .persistence import save_transcript +from .recording import Recorder +from .transcription import Transcriber + + +def wait_for_stop() -> bool: + """Wait for Enter/Escape and return True when cancellation was requested.""" + if not sys.stdin.isatty(): + return input() == "\x1b" + descriptor = sys.stdin.fileno() + previous_settings = termios.tcgetattr(descriptor) + try: + tty.setcbreak(descriptor) + while True: + key = sys.stdin.read(1) + if key in ("\r", "\n", ""): + return False + if key == "\x1b": + return True + finally: + termios.tcsetattr(descriptor, termios.TCSADRAIN, previous_settings) + + +def run_once(config: Config) -> tuple[str, Path]: + config.validate() + recorder = Recorder(config) + with tempfile.TemporaryDirectory(prefix="parley-") as temporary: + workspace = Path(temporary) + recorder.start(workspace / "recording.wav") + print( + "Recording; press Enter to transcribe or Escape to cancel.", + file=sys.stderr, + flush=True, + ) + try: + cancelled = wait_for_stop() + except BaseException: + recorder.cancel() + raise + if cancelled: + recorder.cancel() + raise KeyboardInterrupt + wav_path = recorder.stop() + transcript = Transcriber(config).transcribe(wav_path, workspace) + path = save_transcript(transcript, config.transcript_dir) + return transcript, path + + +def main() -> int: + try: + transcript, path = run_once(Config.from_environment()) + print(transcript) + print(f"Saved to {path}", file=sys.stderr) + return 0 + except KeyboardInterrupt: + print("Cancelled.", file=sys.stderr) + return 130 + except (OSError, ParleyError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/parley/config.py b/parley/config.py new file mode 100644 index 0000000..971b6da --- /dev/null +++ b/parley/config.py @@ -0,0 +1,87 @@ +"""Development configuration for Parley. + +Environment variables are intentionally temporary; GSettings will replace them once +schemas are introduced. +""" + +from dataclasses import dataclass +import os +from pathlib import Path +import shutil + +from .errors import ConfigurationError + + +def _default_data_home() -> Path: + value = os.environ.get("XDG_DATA_HOME") + return Path(value).expanduser() if value else Path.home() / ".local" / "share" + + +def _prototype_root() -> Path: + # The source checkout layout documented in README.md. + return Path(__file__).resolve().parents[2] / "transcribe-parakeet" + + +@dataclass(frozen=True, slots=True) +class Config: + ffmpeg: Path + transcribe_cli: Path + model: Path + transcript_dir: Path + device: str = "default" + language: str = "en" + sample_rate: int = 16_000 + auto_insert: bool = False + insertion_mode: str = "clipboard" + notifications_enabled: bool = False + + @classmethod + def from_environment(cls) -> "Config": + prototype = _prototype_root() + data_home = _default_data_home() + ffmpeg = os.environ.get("PARLEY_FFMPEG") or shutil.which("ffmpeg") or "ffmpeg" + installed_model = data_home / "parley/models/parakeet-unified-en-0.6b-Q8_0.gguf" + default_model = ( + installed_model + if installed_model.is_file() + else prototype / "models/parakeet-unified-en-0.6b-Q8_0.gguf" + ) + return cls( + ffmpeg=Path(ffmpeg).expanduser(), + transcribe_cli=Path( + os.environ.get("PARLEY_TRANSCRIBE_CLI") + or shutil.which("transcribe-cli") + or prototype / "bin/transcribe-cli" + ).expanduser(), + model=Path(os.environ.get("PARLEY_MODEL") or default_model).expanduser(), + transcript_dir=Path( + os.environ.get( + "PARLEY_TRANSCRIPT_DIR", data_home / "parley/transcripts" + ) + ).expanduser(), + device=os.environ.get("PARLEY_DEVICE", "default"), + language=os.environ.get("PARLEY_LANGUAGE", "en"), + auto_insert=os.environ.get("PARLEY_AUTO_INSERT", "0").lower() + in {"1", "true", "yes", "on"}, + insertion_mode=os.environ.get("PARLEY_INSERTION_MODE", "clipboard"), + notifications_enabled=os.environ.get("PARLEY_NOTIFICATIONS", "0").lower() + in {"1", "true", "yes", "on"}, + ) + + def validate(self) -> None: + if not self.ffmpeg.is_file(): + raise ConfigurationError(f"ffmpeg not found: {self.ffmpeg}") + if not self.transcribe_cli.is_file(): + raise ConfigurationError( + f"transcription executable not found: {self.transcribe_cli}" + ) + if not os.access(self.transcribe_cli, os.X_OK): + raise ConfigurationError( + f"transcription executable is not executable: {self.transcribe_cli}" + ) + if not self.model.is_file(): + raise ConfigurationError(f"model not found: {self.model}") + if self.insertion_mode not in {"clipboard", "ibus"}: + raise ConfigurationError( + f"unsupported insertion mode: {self.insertion_mode}" + ) diff --git a/parley/controller.py b/parley/controller.py new file mode 100644 index 0000000..567544f --- /dev/null +++ b/parley/controller.py @@ -0,0 +1,171 @@ +"""Asynchronous orchestration shared by the D-Bus daemon and tests.""" + +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +import tempfile +import threading + +from .config import Config +from .persistence import save_transcript +from .recording import Recorder +from .state import State, StateMachine +from .transcription import Transcriber + + +Dispatch = Callable[[Callable[[], None]], None] + + +class Controller: + """Serialize one recording/transcription operation without blocking its caller.""" + + def __init__( + self, + config: Config, + *, + dispatch: Dispatch | None = None, + on_state: Callable[[State], None] | None = None, + on_transcript: Callable[[str, Path], None] | None = None, + on_error: Callable[[str, str], None] | None = None, + ) -> None: + self.config = config + self.machine = StateMachine() + self.recorder = Recorder(config) + self.transcriber = Transcriber(config) + self.dispatch = dispatch or (lambda callback: callback()) + self.on_state = on_state or (lambda state: None) + self.on_transcript = on_transcript or (lambda text, path: None) + self.on_error = on_error or (lambda code, message: None) + self.last_transcript = "" + self.last_transcript_path: Path | None = None + self.last_error = "" + self._workspace: tempfile.TemporaryDirectory[str] | None = None + self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="parley") + self._lock = threading.Lock() + self._generation = 0 + + @property + def state(self) -> State: + return self.machine.state + + def start(self) -> None: + self.machine.start_recording() + self.last_error = "" + try: + self.config.validate() + self._workspace = tempfile.TemporaryDirectory(prefix="parley-") + wav_path = Path(self._workspace.name) / "recording.wav" + self.recorder.start(wav_path) + except Exception as error: + self._cleanup_workspace() + self._fail("recording-start-failed", str(error)) + return + self._generation += 1 + self._emit_state() + + def stop_and_transcribe(self) -> None: + self.machine.begin_transcription() + token = self._generation + workspace = self._workspace + if workspace is None: + self._fail("transcription-failed", "recording workspace is unavailable") + return + self._emit_state() + self._executor.submit(self._transcribe_worker, token, workspace) + + def toggle(self) -> None: + if self.state in (State.IDLE, State.ERROR): + self.start() + elif self.state is State.RECORDING: + self.stop_and_transcribe() + + def cancel(self) -> None: + previous = self.state + self.machine.cancel() + self._generation += 1 + if previous is State.RECORDING: + self.recorder.cancel() + self._cleanup_workspace() + else: + self.transcriber.cancel() + self._emit_state() + + def close(self) -> None: + if self.state is State.RECORDING: + self.recorder.cancel() + elif self.state is State.TRANSCRIBING: + self.transcriber.cancel() + self._generation += 1 + self._cleanup_workspace() + self._executor.shutdown(wait=False, cancel_futures=True) + + def _transcribe_worker( + self, token: int, workspace: tempfile.TemporaryDirectory[str] + ) -> None: + try: + wav_path = self.recorder.stop() + if token != self._generation: + self.dispatch(lambda: self._discard_worker(workspace)) + return + text = self.transcriber.transcribe(wav_path, Path(workspace.name)) + if token != self._generation: + self.dispatch(lambda: self._discard_worker(workspace)) + return + path = save_transcript(text, self.config.transcript_dir) + except Exception as error: + self.dispatch( + lambda error=error: self._worker_failed(token, workspace, error) + ) + else: + self.dispatch(lambda: self._worker_succeeded(token, workspace, text, path)) + + def _worker_succeeded( + self, + token: int, + workspace: tempfile.TemporaryDirectory[str], + text: str, + path: Path, + ) -> None: + if token != self._generation: + self._cleanup_workspace(workspace) + return + self.last_transcript = text + self.last_transcript_path = path + self.machine.complete() + self._cleanup_workspace(workspace) + self.on_transcript(text, path) + self._emit_state() + + def _worker_failed( + self, + token: int, + workspace: tempfile.TemporaryDirectory[str], + error: Exception, + ) -> None: + if token != self._generation: + self._cleanup_workspace(workspace) + return + self._cleanup_workspace(workspace) + self._fail("transcription-failed", str(error)) + + def _discard_worker(self, workspace: tempfile.TemporaryDirectory[str]) -> None: + self._cleanup_workspace(workspace) + + def _fail(self, code: str, message: str) -> None: + self.machine.fail() + self.last_error = message + self.on_error(code, message) + self._emit_state() + + def _emit_state(self) -> None: + self.on_state(self.state) + + def _cleanup_workspace( + self, workspace: tempfile.TemporaryDirectory[str] | None = None + ) -> None: + with self._lock: + target = workspace or self._workspace + if target is self._workspace: + self._workspace = None + if target is not None: + target.cleanup() diff --git a/parley/ctl.py b/parley/ctl.py new file mode 100644 index 0000000..8ae7450 --- /dev/null +++ b/parley/ctl.py @@ -0,0 +1,78 @@ +"""Thin command-line client for the Parley D-Bus daemon.""" + +import argparse +import sys + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib + +from .daemon import BUS_NAME, INTERFACE, OBJECT_PATH + + +METHODS = { + "toggle": "Toggle", + "start": "StartRecording", + "stop": "StopAndTranscribe", + "cancel": "Cancel", + "copy": "CopyLastTranscript", + "insert": "InsertLastTranscript", + "open-folder": "OpenTranscriptFolder", +} + + +def _proxy() -> Gio.DBusProxy: + return Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, + Gio.DBusProxyFlags.NONE, + None, + BUS_NAME, + OBJECT_PATH, + INTERFACE, + None, + ) + + +def _status(proxy: Gio.DBusProxy) -> None: + result = proxy.call_sync( + "org.freedesktop.DBus.Properties.GetAll", + GLib.Variant("(s)", (INTERFACE,)), + Gio.DBusCallFlags.NONE, + -1, + None, + ) + properties = result.unpack()[0] + print(f"state: {properties['State']}") + print(f"insertion mode: {properties['InsertionMode']}") + print(f"auto insert: {'yes' if properties['AutoInsert'] else 'no'}") + if properties["LastTranscriptPath"]: + print(f"last transcript: {properties['LastTranscriptPath']}") + if properties["LastError"]: + print(f"last error: {properties['LastError']}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="parleyctl") + parser.add_argument("command", choices=[*METHODS, "status"]) + arguments = parser.parse_args(argv) + try: + proxy = _proxy() + if arguments.command == "status": + _status(proxy) + else: + proxy.call_sync( + METHODS[arguments.command], + None, + Gio.DBusCallFlags.NONE, + -1, + None, + ) + return 0 + except GLib.Error as error: + print(f"parleyctl: {error.message}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/parley/daemon.py b/parley/daemon.py new file mode 100644 index 0000000..de76bcd --- /dev/null +++ b/parley/daemon.py @@ -0,0 +1,261 @@ +"""Per-user GObject/D-Bus daemon for Parley.""" + +from importlib.resources import files +import signal +import sys + +import gi + +gi.require_version("Gio", "2.0") +gi.require_version("GLib", "2.0") +gi.require_version("GLibUnix", "2.0") +from gi.repository import Gio, GLib, GLibUnix + +from .config import Config +from .controller import Controller +from .desktop import copy_text, notify, open_folder +from .errors import ParleyError +from .ibus_engine import IBusIntegration +from .state import State + + +BUS_NAME = "org.parley.Transcription1" +OBJECT_PATH = "/org/parley/Transcription1" +INTERFACE = BUS_NAME + + +class Daemon: + def __init__(self, config: Config | None = None) -> None: + xml = ( + files("parley.interfaces") + .joinpath("org.parley.Transcription1.xml") + .read_text(encoding="utf-8") + ) + self.node_info = Gio.DBusNodeInfo.new_for_xml(xml) + self.interface_info = self.node_info.interfaces[0] + resolved_config = config or Config.from_environment() + self.loop = GLib.MainLoop() + self.ibus = IBusIntegration( + activate_on_connect=resolved_config.auto_insert + and resolved_config.insertion_mode == "ibus" + ) + self.connection: Gio.DBusConnection | None = None + self.registration_id = 0 + self.controller = Controller( + resolved_config, + dispatch=lambda callback: GLib.idle_add(callback), + on_state=self._on_state, + on_transcript=self._on_transcript, + on_error=self._on_error, + ) + + def run(self) -> int: + owner_id = Gio.bus_own_name( + Gio.BusType.SESSION, + BUS_NAME, + Gio.BusNameOwnerFlags.NONE, + self._on_bus_acquired, + self._on_name_acquired, + self._on_name_lost, + ) + GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, self._quit) + GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, self._quit) + try: + self.loop.run() + finally: + self.controller.close() + self.ibus.close() + if self.connection is not None and self.registration_id: + self.connection.unregister_object(self.registration_id) + Gio.bus_unown_name(owner_id) + return 0 + + def _on_bus_acquired(self, connection: Gio.DBusConnection, name: str) -> None: + self.connection = connection + self.registration_id = connection.register_object_with_closures2( + OBJECT_PATH, + self.interface_info, + self._method_call, + self._get_property, + None, + ) + + def _on_name_acquired(self, connection: Gio.DBusConnection, name: str) -> None: + print(f"Parley daemon owns {name}", file=sys.stderr) + + def _on_name_lost(self, connection: Gio.DBusConnection | None, name: str) -> None: + print(f"error: could not own D-Bus name {name}", file=sys.stderr) + self.loop.quit() + + def _method_call( + self, + connection: Gio.DBusConnection, + sender: str, + object_path: str, + interface_name: str, + method_name: str, + parameters: GLib.Variant, + invocation: Gio.DBusMethodInvocation, + ) -> None: + try: + methods = { + "StartRecording": self.controller.start, + "StopAndTranscribe": self.controller.stop_and_transcribe, + "Toggle": self.controller.toggle, + "Cancel": self.controller.cancel, + "CopyLastTranscript": self._copy_last, + "InsertLastTranscript": self._insert_last, + "OpenTranscriptFolder": self._open_transcript_folder, + } + method = methods.get(method_name) + if method is None: + invocation.return_dbus_error( + f"{INTERFACE}.Error.UnknownMethod", f"unknown method: {method_name}" + ) + return + method() + invocation.return_value(None) + except (OSError, ParleyError) as error: + invocation.return_dbus_error(f"{INTERFACE}.Error.Failed", str(error)) + except Exception as error: + print(f"unexpected method failure: {error}", file=sys.stderr) + invocation.return_dbus_error( + f"{INTERFACE}.Error.Internal", "internal daemon error; see user journal" + ) + + def _get_property( + self, + connection: Gio.DBusConnection, + sender: str, + object_path: str, + interface_name: str, + property_name: str, + ) -> GLib.Variant: + values = { + "State": GLib.Variant("s", self.controller.state.value), + "LastTranscript": GLib.Variant("s", self.controller.last_transcript), + "LastTranscriptPath": GLib.Variant( + "s", + str(self.controller.last_transcript_path or ""), + ), + "LastError": GLib.Variant("s", self.controller.last_error), + "InsertionMode": GLib.Variant("s", self.controller.config.insertion_mode), + "AutoInsert": GLib.Variant("b", self.controller.config.auto_insert), + } + return values[property_name] + + def _copy_last(self) -> None: + if not self.controller.last_transcript_path: + raise ParleyError("no transcript is available") + copy_text(self.controller.last_transcript) + + def _insert_last(self) -> None: + if not self.controller.last_transcript_path: + raise ParleyError("no transcript is available") + text = self.controller.last_transcript + backend = self.controller.config.insertion_mode + if backend == "ibus": + try: + message = self.ibus.commit(text) + except ParleyError as error: + copy_text(text) + self._emit( + "InsertionFinished", + GLib.Variant("(sbs)", ("ibus", False, f"{error}; copied instead")), + ) + return + else: + copy_text(text) + message = "copied; press Ctrl+V to insert" + self._emit("InsertionFinished", GLib.Variant("(sbs)", (backend, True, message))) + + def _open_transcript_folder(self) -> None: + directory = self.controller.config.transcript_dir + directory.mkdir(parents=True, exist_ok=True) + open_folder(directory) + + def _on_state(self, state: State) -> None: + config = self.controller.config + self.ibus.set_processing( + state is State.TRANSCRIBING + and config.auto_insert + and config.insertion_mode == "ibus" + ) + self._emit("StateChanged", GLib.Variant("(s)", (state.value,))) + self._properties_changed("State", GLib.Variant("s", state.value)) + + def _on_transcript(self, text, path) -> None: + self._emit("TranscriptReady", GLib.Variant("(ss)", (text, str(path)))) + self._properties_changed( + "LastTranscript", + GLib.Variant("s", text), + "LastTranscriptPath", + GLib.Variant("s", str(path)), + ) + clipboard_error = None + try: + copy_text(text) + except ParleyError as error: + clipboard_error = error + self.controller.last_error = str(error) + self._on_error("clipboard-failed", str(error)) + + config = self.controller.config + if config.auto_insert and config.insertion_mode == "ibus": + try: + message = self.ibus.commit(text) + except ParleyError as error: + self._emit( + "InsertionFinished", + GLib.Variant("(sbs)", ("ibus", False, str(error))), + ) + fallback = ( + "Copied; press Ctrl+V" + if clipboard_error is None + else "Transcript saved" + ) + self._notify("Automatic insertion unavailable", fallback) + else: + self._emit( + "InsertionFinished", + GLib.Variant("(sbs)", ("ibus", True, message)), + ) + self._notify("Transcription ready", "Inserted through IBus") + elif clipboard_error is None: + self._notify("Transcription ready", "Copied to the clipboard") + + def _on_error(self, code: str, message: str) -> None: + self._emit("Error", GLib.Variant("(ss)", (code, message))) + self._properties_changed("LastError", GLib.Variant("s", message)) + self._notify("Parley error", message) + + def _notify(self, summary: str, body: str) -> None: + if self.controller.config.notifications_enabled: + notify(summary, body) + + def _properties_changed(self, *items) -> None: + changed = {items[index]: items[index + 1] for index in range(0, len(items), 2)} + if self.connection is not None: + self.connection.emit_signal( + None, + OBJECT_PATH, + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + GLib.Variant("(sa{sv}as)", (INTERFACE, changed, [])), + ) + + def _emit(self, name: str, parameters: GLib.Variant) -> None: + if self.connection is not None: + self.connection.emit_signal(None, OBJECT_PATH, INTERFACE, name, parameters) + + def _quit(self) -> bool: + self.loop.quit() + return GLib.SOURCE_REMOVE + + +def main() -> int: + return Daemon().run() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/parley/desktop.py b/parley/desktop.py new file mode 100644 index 0000000..3802985 --- /dev/null +++ b/parley/desktop.py @@ -0,0 +1,59 @@ +"""Small desktop integrations used by the prototype daemon.""" + +from pathlib import Path +import shutil +import subprocess + +from .errors import ParleyError + + +class DesktopIntegrationError(ParleyError): + pass + + +def copy_text(text: str) -> None: + """Copy text through XWayland's clipboard bridge on GNOME.""" + executable = shutil.which("xclip") + if executable is None: + raise DesktopIntegrationError( + "xclip is not installed; transcript was saved but not copied" + ) + try: + result = subprocess.run( + [executable, "-selection", "clipboard", "-in"], + input=text, + text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise DesktopIntegrationError(f"could not copy transcript: {error}") from error + if result.returncode != 0: + raise DesktopIntegrationError("xclip could not access the clipboard") + + +def notify(summary: str, body: str) -> None: + executable = shutil.which("notify-send") + if executable is not None: + subprocess.Popen( + [executable, "--app-name", "Parley", summary, body], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def open_folder(path: Path) -> None: + executable = shutil.which("xdg-open") + if executable is None: + raise DesktopIntegrationError("xdg-open is not installed") + try: + subprocess.Popen( + [executable, str(path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except OSError as error: + raise DesktopIntegrationError( + f"could not open transcript folder: {error}" + ) from error diff --git a/parley/errors.py b/parley/errors.py new file mode 100644 index 0000000..7862463 --- /dev/null +++ b/parley/errors.py @@ -0,0 +1,17 @@ +"""Errors exposed by the Parley core.""" + + +class ParleyError(Exception): + """Base class for expected, user-actionable Parley failures.""" + + +class ConfigurationError(ParleyError): + """A required executable, model, or setting is invalid.""" + + +class RecordingError(ParleyError): + """Audio capture could not start or finish successfully.""" + + +class TranscriptionError(ParleyError): + """The transcription runtime failed or returned no result.""" diff --git a/parley/ibus_engine.py b/parley/ibus_engine.py new file mode 100644 index 0000000..1f80868 --- /dev/null +++ b/parley/ibus_engine.py @@ -0,0 +1,204 @@ +"""Persistent passthrough IBus engine hosted by the Parley daemon.""" + +import gi + +gi.require_version("GLib", "2.0") +gi.require_version("IBus", "1.0") +from gi.repository import GLib, IBus + +from .errors import ParleyError + + +class IBusInsertionError(ParleyError): + pass + + +class ParleyIBusEngine(IBus.Engine): + """Pass ordinary keys through and accept explicit semantic text commits.""" + + focused: "ParleyIBusEngine | None" = None + integration: "IBusIntegration | None" = None + + def do_process_key_event(self, keyval: int, keycode: int, state: int) -> bool: + return False + + def do_focus_in(self) -> None: + ParleyIBusEngine.focused = self + if ParleyIBusEngine.integration is not None: + ParleyIBusEngine.integration.refresh_preedit() + + def do_focus_out(self) -> None: + if ParleyIBusEngine.focused is self: + self.hide_preedit_text() + ParleyIBusEngine.focused = None + + def commit(self, text: str) -> str: + self.commit_text(IBus.Text.new_from_string(text)) + return "text committed through IBus" + + +class IBusIntegration: + """Register Parley's engine and reconnect after a rare IBus restart.""" + + SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") + + def __init__(self, *, activate_on_connect: bool = False) -> None: + IBus.init() + self.activate_on_connect = activate_on_connect + self._processing = False + self._spinner_frame = 0 + self._spinner_source = 0 + ParleyIBusEngine.integration = self + self.bus: IBus.Bus | None = None + self.factory: IBus.Factory | None = None + self.component: IBus.Component | None = None + self._disconnect_signal = 0 + self._reconnect_source = 0 + self._activation_source = 0 + self._closed = False + self._connect() + + @property + def available(self) -> bool: + return self.bus is not None and self.bus.is_connected() + + def commit(self, text: str) -> str: + self.set_processing(False) + if not self.available: + raise IBusInsertionError("IBus is unavailable") + engine = ParleyIBusEngine.focused + if engine is None: + raise IBusInsertionError("Parley does not own the focused input context") + return engine.commit(text) + + def set_processing(self, processing: bool) -> None: + if processing == self._processing: + return + self._processing = processing + if processing: + self._spinner_frame = 0 + self.refresh_preedit() + if not self._spinner_source: + self._spinner_source = GLib.timeout_add(120, self._advance_spinner) + else: + if self._spinner_source: + GLib.Source.remove(self._spinner_source) + self._spinner_source = 0 + engine = ParleyIBusEngine.focused + if engine is not None: + engine.hide_preedit_text() + + def refresh_preedit(self) -> None: + engine = ParleyIBusEngine.focused + if not self._processing or engine is None: + return + message = f"{self.SPINNER_FRAMES[self._spinner_frame]} Transcribing…" + engine.update_preedit_text_with_mode( + IBus.Text.new_from_string(message), + len(message), + True, + IBus.PreeditFocusMode.CLEAR, + ) + + def _advance_spinner(self) -> bool: + if not self._processing: + self._spinner_source = 0 + return GLib.SOURCE_REMOVE + self._spinner_frame = (self._spinner_frame + 1) % len(self.SPINNER_FRAMES) + self.refresh_preedit() + return GLib.SOURCE_CONTINUE + + def close(self) -> None: + self.set_processing(False) + self._closed = True + if ParleyIBusEngine.integration is self: + ParleyIBusEngine.integration = None + if self._reconnect_source: + GLib.Source.remove(self._reconnect_source) + self._reconnect_source = 0 + if self._activation_source: + GLib.Source.remove(self._activation_source) + self._activation_source = 0 + self._clear_connection() + + def _connect(self) -> bool: + self._reconnect_source = 0 + if self._closed: + return GLib.SOURCE_REMOVE + bus = IBus.Bus.new() + if not bus.is_connected(): + self._schedule_reconnect() + return GLib.SOURCE_REMOVE + + factory = IBus.Factory.new(bus.get_connection()) + factory.add_engine("parley", ParleyIBusEngine.__gtype__) + component = IBus.Component.new( + "org.parley.IBus", + "Parley dictation passthrough engine", + "0.1.0", + "GPL-3.0-or-later", + "Parley contributors", + "", + "", + "", + ) + component.add_engine( + IBus.EngineDesc.new( + "parley", + "Parley Dictation", + "Commit local dictation as text", + "en", + "GPL-3.0-or-later", + "Parley contributors", + "audio-input-microphone-symbolic", + "default", + ) + ) + if not bus.register_component(component): + factory.destroy() + self._schedule_reconnect() + return GLib.SOURCE_REMOVE + + self.bus = bus + self.factory = factory + self.component = component + self._disconnect_signal = bus.connect("disconnected", self._on_disconnected) + if self.activate_on_connect: + self._activation_source = GLib.timeout_add(100, self._activate) + return GLib.SOURCE_REMOVE + + def _activate(self) -> bool: + self._activation_source = 0 + if self.available: + self.bus.set_global_engine_async( + "parley", 2_000, None, self._activation_finished + ) + return GLib.SOURCE_REMOVE + + def _activation_finished(self, bus: IBus.Bus, result) -> None: + try: + bus.set_global_engine_async_finish(result) + except GLib.Error: + # Insertion remains available as a clipboard fallback. A later + # explicit selection can activate the engine. + pass + + def _on_disconnected(self, bus: IBus.Bus) -> None: + self._clear_connection() + self._schedule_reconnect() + + def _schedule_reconnect(self) -> None: + if not self._closed and not self._reconnect_source: + self._reconnect_source = GLib.timeout_add_seconds(2, self._connect) + + def _clear_connection(self) -> None: + self.set_processing(False) + ParleyIBusEngine.focused = None + bus, self.bus = self.bus, None + if bus is not None and self._disconnect_signal: + bus.disconnect(self._disconnect_signal) + self._disconnect_signal = 0 + factory, self.factory = self.factory, None + if factory is not None: + factory.destroy() + self.component = None diff --git a/parley/interfaces/__init__.py b/parley/interfaces/__init__.py new file mode 100644 index 0000000..bded51d --- /dev/null +++ b/parley/interfaces/__init__.py @@ -0,0 +1 @@ +"""Packaged D-Bus interface definitions.""" diff --git a/parley/interfaces/org.parley.Transcription1.xml b/parley/interfaces/org.parley.Transcription1.xml new file mode 100644 index 0000000..12b7960 --- /dev/null +++ b/parley/interfaces/org.parley.Transcription1.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/parley/persistence.py b/parley/persistence.py new file mode 100644 index 0000000..bd004d8 --- /dev/null +++ b/parley/persistence.py @@ -0,0 +1,49 @@ +"""Collision-safe, atomic transcript persistence.""" + +from datetime import datetime +import os +from pathlib import Path +import tempfile +from typing import Callable + + +Clock = Callable[[], datetime] + + +def save_transcript(text: str, directory: Path, *, clock: Clock | None = None) -> Path: + """Atomically save UTF-8 text and return its collision-safe path.""" + directory.mkdir(parents=True, exist_ok=True) + now = (clock or (lambda: datetime.now().astimezone()))() + timestamp = now.strftime("%Y-%m-%d_%H-%M-%S_%f%z") + content = text.rstrip("\n") + "\n" + + temporary_name: str | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=directory, + prefix=".transcript-", + suffix=".tmp", + delete=False, + ) as output: + temporary_name = output.name + output.write(content) + output.flush() + os.fsync(output.fileno()) + + temporary = Path(temporary_name) + counter = 0 + while True: + suffix = f"_{counter}" if counter else "" + destination = directory / f"transcript_{timestamp}{suffix}.txt" + try: + # A hard link publishes the completed file atomically and refuses to + # replace a name won by another process. + os.link(temporary, destination) + return destination + except FileExistsError: + counter += 1 + finally: + if temporary_name is not None: + Path(temporary_name).unlink(missing_ok=True) diff --git a/parley/recording.py b/parley/recording.py new file mode 100644 index 0000000..5ee3ec2 --- /dev/null +++ b/parley/recording.py @@ -0,0 +1,97 @@ +"""FFmpeg recording lifecycle with no terminal or UI dependencies.""" + +from pathlib import Path +import subprocess + +from .config import Config +from .errors import RecordingError + + +class Recorder: + """Own one FFmpeg process and produce a valid PCM WAV on stop.""" + + def __init__(self, config: Config, *, stop_timeout: float = 5.0) -> None: + self.config = config + self.stop_timeout = stop_timeout + self._process: subprocess.Popen[bytes] | None = None + self._output_path: Path | None = None + + @property + def is_recording(self) -> bool: + return self._process is not None and self._process.poll() is None + + def start(self, output_path: Path) -> None: + if self._process is not None: + raise RecordingError("a recording is already active") + output_path.parent.mkdir(parents=True, exist_ok=True) + command = [ + str(self.config.ffmpeg), + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "pulse", + "-i", + self.config.device, + "-ac", + "1", + "-ar", + str(self.config.sample_rate), + "-c:a", + "pcm_s16le", + str(output_path), + ] + try: + self._process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + except OSError as error: + raise RecordingError(f"could not start ffmpeg: {error}") from error + self._output_path = output_path + + def stop(self) -> Path: + process = self._require_process() + output_path = self._output_path + assert output_path is not None + returncode, stderr = self._finish(process) + self._clear() + if returncode != 0: + detail = stderr.decode(errors="replace").strip() + output_path.unlink(missing_ok=True) + raise RecordingError(f"recording failed: {detail or 'ffmpeg error'}") + if not output_path.is_file(): + raise RecordingError("recording finished without producing audio") + return output_path + + def cancel(self) -> None: + if self._process is not None: + self._finish(self._process) + output_path = self._output_path + self._clear() + if output_path is not None: + output_path.unlink(missing_ok=True) + + def _require_process(self) -> subprocess.Popen[bytes]: + if self._process is None: + raise RecordingError("no recording is active") + return self._process + + def _finish(self, process: subprocess.Popen[bytes]) -> tuple[int, bytes]: + try: + _, stderr = process.communicate(input=b"q\n", timeout=self.stop_timeout) + except subprocess.TimeoutExpired: + process.terminate() + try: + _, stderr = process.communicate(timeout=self.stop_timeout) + except subprocess.TimeoutExpired: + process.kill() + _, stderr = process.communicate() + return process.returncode, stderr or b"" + + def _clear(self) -> None: + self._process = None + self._output_path = None diff --git a/parley/state.py b/parley/state.py new file mode 100644 index 0000000..8ec7a35 --- /dev/null +++ b/parley/state.py @@ -0,0 +1,62 @@ +"""The daemon's UI-independent operation state machine.""" + +from enum import StrEnum + +from .errors import ParleyError + + +class State(StrEnum): + IDLE = "idle" + RECORDING = "recording" + TRANSCRIBING = "transcribing" + INSERTING = "inserting" + ERROR = "error" + + +class InvalidStateError(ParleyError): + """An operation is not valid in the daemon's current state.""" + + +class StateMachine: + """Validate state transitions independently of D-Bus and subprocesses.""" + + def __init__(self) -> None: + self.state = State.IDLE + + def start_recording(self) -> State: + self._require(State.IDLE, State.ERROR) + return self._set(State.RECORDING) + + def begin_transcription(self) -> State: + self._require(State.RECORDING) + return self._set(State.TRANSCRIBING) + + def begin_insertion(self) -> State: + self._require(State.TRANSCRIBING) + return self._set(State.INSERTING) + + def complete(self) -> State: + self._require(State.TRANSCRIBING, State.INSERTING) + return self._set(State.IDLE) + + def cancel(self) -> State: + self._require(State.RECORDING, State.TRANSCRIBING) + return self._set(State.IDLE) + + def fail(self) -> State: + return self._set(State.ERROR) + + def reset(self) -> State: + self._require(State.ERROR) + return self._set(State.IDLE) + + def _require(self, *allowed: State) -> None: + if self.state not in allowed: + expected = ", ".join(value.value for value in allowed) + raise InvalidStateError( + f"operation requires state {expected}; current state is {self.state.value}" + ) + + def _set(self, state: State) -> State: + self.state = state + return state diff --git a/parley/transcription.py b/parley/transcription.py new file mode 100644 index 0000000..0735bb2 --- /dev/null +++ b/parley/transcription.py @@ -0,0 +1,88 @@ +"""Invoke transcribe-cli and parse its JSONL batch output.""" + +import json +from pathlib import Path +import subprocess +import threading + +from .config import Config +from .errors import TranscriptionError + + +def parse_jsonl(output: str) -> str: + """Return the first transcript row, ignoring headers and diagnostic lines.""" + for line in output.splitlines(): + try: + row = json.loads(line) + except json.JSONDecodeError, TypeError: + continue + if not isinstance(row, dict) or row.get("type") == "batch_header": + continue + if row.get("error"): + raise TranscriptionError(f"transcription failed: {row['error']}") + if "text" in row: + return str(row["text"]) + detail = output.strip() + if len(detail) > 500: + detail = detail[:500] + "…" + raise TranscriptionError(f"no transcript in runtime output: {detail or ''}") + + +class Transcriber: + def __init__(self, config: Config, *, cancel_timeout: float = 5.0) -> None: + self.config = config + self.cancel_timeout = cancel_timeout + self._lock = threading.Lock() + self._process: subprocess.Popen[str] | None = None + + def transcribe(self, wav_path: Path, workspace: Path) -> str: + batch_file = workspace / "input.txt" + batch_file.write_text(f"{wav_path}\n", encoding="utf-8") + command = [ + str(self.config.transcribe_cli), + "--quiet", + "--model", + str(self.config.model), + "--language", + self.config.language, + "--timestamps", + "none", + "--stream-chunk-ms", + "30", + "--batch", + str(batch_file), + "--batch-jsonl", + ] + try: + process = subprocess.Popen( + command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + except OSError as error: + raise TranscriptionError( + f"could not start transcription: {error}" + ) from error + with self._lock: + self._process = process + try: + stdout, stderr = process.communicate() + finally: + with self._lock: + if self._process is process: + self._process = None + if process.returncode != 0: + detail = stderr.strip() or "transcription runtime failed" + raise TranscriptionError(detail) + return parse_jsonl(stdout) + + def cancel(self) -> None: + """Terminate an active runtime, escalating if it does not exit.""" + with self._lock: + process = self._process + if process is None or process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=self.cancel_timeout) + except subprocess.TimeoutExpired: + process.kill() + process.wait() diff --git a/pyproject.toml b/pyproject.toml index f3455c5..baec27e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,12 +20,13 @@ parleyctl = "parley.ctl:main" "parley.interfaces" = ["*.xml"] [tool.setuptools.packages.find] -where = ["src"] +where = ["."] +include = ["parley*"] [tool.pytest.ini_options] testpaths = ["tests"] [tool.ruff.lint.per-file-ignores] -"src/parley/ctl.py" = ["E402"] -"src/parley/daemon.py" = ["E402"] -"src/parley/ibus_engine.py" = ["E402"] +"parley/ctl.py" = ["E402"] +"parley/daemon.py" = ["E402"] +"parley/ibus_engine.py" = ["E402"] diff --git a/scripts/install-dev b/scripts/install-dev index a2eb660..cd4a7fc 100755 --- a/scripts/install-dev +++ b/scripts/install-dev @@ -6,7 +6,7 @@ unit_dir=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user dbus_dir=${XDG_DATA_HOME:-$HOME/.local/share}/dbus-1/services cd "$root" -/usr/bin/python -c 'import gi' >/dev/null +python -c 'import gi' >/dev/null mkdir -p "$unit_dir" "$dbus_dir" sed "s|^ExecStart=.*|ExecStart=$root/scripts/parleyd|" \ diff --git a/scripts/install-extension b/scripts/install-extension index e32e9da..fd861dc 100755 --- a/scripts/install-extension +++ b/scripts/install-extension @@ -14,7 +14,7 @@ fi ln -s "$root/extension" "$destination" cd "$root" -/usr/bin/python - "$uuid" <<'PY' +python - "$uuid" <<'PY' import sys from gi.repository import Gio diff --git a/scripts/parley b/scripts/parley index 9213cbd..49e7098 100755 --- a/scripts/parley +++ b/scripts/parley @@ -1,5 +1,5 @@ #!/usr/bin/env bash set -euo pipefail root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -export PYTHONPATH="$root/src${PYTHONPATH:+:$PYTHONPATH}" -exec /usr/bin/python -m parley.cli "$@" +cd "$root" +exec python -m parley.cli "$@" diff --git a/scripts/parleyctl b/scripts/parleyctl index 2973c88..0fda0ba 100755 --- a/scripts/parleyctl +++ b/scripts/parleyctl @@ -1,5 +1,5 @@ #!/usr/bin/env bash set -euo pipefail root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -export PYTHONPATH="$root/src${PYTHONPATH:+:$PYTHONPATH}" -exec /usr/bin/python -m parley.ctl "$@" +cd "$root" +exec python -m parley.ctl "$@" diff --git a/scripts/parleyd b/scripts/parleyd index 9e66846..8584093 100755 --- a/scripts/parleyd +++ b/scripts/parleyd @@ -1,5 +1,5 @@ #!/usr/bin/env bash set -euo pipefail root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -export PYTHONPATH="$root/src${PYTHONPATH:+:$PYTHONPATH}" -exec /usr/bin/python -m parley.daemon "$@" +cd "$root" +exec python -m parley.daemon "$@" diff --git a/src/parley/__init__.py b/src/parley/__init__.py deleted file mode 100644 index 8340b53..0000000 --- a/src/parley/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Terminal-independent recording and transcription core for Parley.""" - -__version__ = "0.1.0.dev0" diff --git a/src/parley/cli.py b/src/parley/cli.py deleted file mode 100644 index 735d532..0000000 --- a/src/parley/cli.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Standalone terminal frontend for the shared Parley core.""" - -import sys -import tempfile -import termios -import tty -from pathlib import Path - -from .config import Config -from .errors import ParleyError -from .persistence import save_transcript -from .recording import Recorder -from .transcription import Transcriber - - -def wait_for_stop() -> bool: - """Wait for Enter/Escape and return True when cancellation was requested.""" - if not sys.stdin.isatty(): - return input() == "\x1b" - descriptor = sys.stdin.fileno() - previous_settings = termios.tcgetattr(descriptor) - try: - tty.setcbreak(descriptor) - while True: - key = sys.stdin.read(1) - if key in ("\r", "\n", ""): - return False - if key == "\x1b": - return True - finally: - termios.tcsetattr(descriptor, termios.TCSADRAIN, previous_settings) - - -def run_once(config: Config) -> tuple[str, Path]: - config.validate() - recorder = Recorder(config) - with tempfile.TemporaryDirectory(prefix="parley-") as temporary: - workspace = Path(temporary) - recorder.start(workspace / "recording.wav") - print( - "Recording; press Enter to transcribe or Escape to cancel.", - file=sys.stderr, - flush=True, - ) - try: - cancelled = wait_for_stop() - except BaseException: - recorder.cancel() - raise - if cancelled: - recorder.cancel() - raise KeyboardInterrupt - wav_path = recorder.stop() - transcript = Transcriber(config).transcribe(wav_path, workspace) - path = save_transcript(transcript, config.transcript_dir) - return transcript, path - - -def main() -> int: - try: - transcript, path = run_once(Config.from_environment()) - print(transcript) - print(f"Saved to {path}", file=sys.stderr) - return 0 - except KeyboardInterrupt: - print("Cancelled.", file=sys.stderr) - return 130 - except (OSError, ParleyError) as error: - print(f"error: {error}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/parley/config.py b/src/parley/config.py deleted file mode 100644 index 923812c..0000000 --- a/src/parley/config.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Development configuration for Parley. - -Environment variables are intentionally temporary; GSettings will replace them once -schemas are introduced. -""" - -from dataclasses import dataclass -import os -from pathlib import Path -import shutil - -from .errors import ConfigurationError - - -def _default_data_home() -> Path: - value = os.environ.get("XDG_DATA_HOME") - return Path(value).expanduser() if value else Path.home() / ".local" / "share" - - -def _prototype_root() -> Path: - # The source checkout layout documented in README.md. - return Path(__file__).resolve().parents[3] / "transcribe-parakeet" - - -@dataclass(frozen=True, slots=True) -class Config: - ffmpeg: Path - transcribe_cli: Path - model: Path - transcript_dir: Path - device: str = "default" - language: str = "en" - sample_rate: int = 16_000 - auto_insert: bool = False - insertion_mode: str = "clipboard" - notifications_enabled: bool = False - - @classmethod - def from_environment(cls) -> "Config": - prototype = _prototype_root() - data_home = _default_data_home() - ffmpeg = os.environ.get("PARLEY_FFMPEG") or shutil.which("ffmpeg") or "ffmpeg" - installed_model = data_home / "parley/models/parakeet-unified-en-0.6b-Q8_0.gguf" - default_model = ( - installed_model - if installed_model.is_file() - else prototype / "models/parakeet-unified-en-0.6b-Q8_0.gguf" - ) - return cls( - ffmpeg=Path(ffmpeg).expanduser(), - transcribe_cli=Path( - os.environ.get("PARLEY_TRANSCRIBE_CLI") - or shutil.which("transcribe-cli") - or prototype / "bin/transcribe-cli" - ).expanduser(), - model=Path(os.environ.get("PARLEY_MODEL") or default_model).expanduser(), - transcript_dir=Path( - os.environ.get( - "PARLEY_TRANSCRIPT_DIR", data_home / "parley/transcripts" - ) - ).expanduser(), - device=os.environ.get("PARLEY_DEVICE", "default"), - language=os.environ.get("PARLEY_LANGUAGE", "en"), - auto_insert=os.environ.get("PARLEY_AUTO_INSERT", "0").lower() - in {"1", "true", "yes", "on"}, - insertion_mode=os.environ.get("PARLEY_INSERTION_MODE", "clipboard"), - notifications_enabled=os.environ.get("PARLEY_NOTIFICATIONS", "0").lower() - in {"1", "true", "yes", "on"}, - ) - - def validate(self) -> None: - if not self.ffmpeg.is_file(): - raise ConfigurationError(f"ffmpeg not found: {self.ffmpeg}") - if not self.transcribe_cli.is_file(): - raise ConfigurationError( - f"transcription executable not found: {self.transcribe_cli}" - ) - if not os.access(self.transcribe_cli, os.X_OK): - raise ConfigurationError( - f"transcription executable is not executable: {self.transcribe_cli}" - ) - if not self.model.is_file(): - raise ConfigurationError(f"model not found: {self.model}") - if self.insertion_mode not in {"clipboard", "ibus"}: - raise ConfigurationError( - f"unsupported insertion mode: {self.insertion_mode}" - ) diff --git a/src/parley/controller.py b/src/parley/controller.py deleted file mode 100644 index 567544f..0000000 --- a/src/parley/controller.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Asynchronous orchestration shared by the D-Bus daemon and tests.""" - -from collections.abc import Callable -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path -import tempfile -import threading - -from .config import Config -from .persistence import save_transcript -from .recording import Recorder -from .state import State, StateMachine -from .transcription import Transcriber - - -Dispatch = Callable[[Callable[[], None]], None] - - -class Controller: - """Serialize one recording/transcription operation without blocking its caller.""" - - def __init__( - self, - config: Config, - *, - dispatch: Dispatch | None = None, - on_state: Callable[[State], None] | None = None, - on_transcript: Callable[[str, Path], None] | None = None, - on_error: Callable[[str, str], None] | None = None, - ) -> None: - self.config = config - self.machine = StateMachine() - self.recorder = Recorder(config) - self.transcriber = Transcriber(config) - self.dispatch = dispatch or (lambda callback: callback()) - self.on_state = on_state or (lambda state: None) - self.on_transcript = on_transcript or (lambda text, path: None) - self.on_error = on_error or (lambda code, message: None) - self.last_transcript = "" - self.last_transcript_path: Path | None = None - self.last_error = "" - self._workspace: tempfile.TemporaryDirectory[str] | None = None - self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="parley") - self._lock = threading.Lock() - self._generation = 0 - - @property - def state(self) -> State: - return self.machine.state - - def start(self) -> None: - self.machine.start_recording() - self.last_error = "" - try: - self.config.validate() - self._workspace = tempfile.TemporaryDirectory(prefix="parley-") - wav_path = Path(self._workspace.name) / "recording.wav" - self.recorder.start(wav_path) - except Exception as error: - self._cleanup_workspace() - self._fail("recording-start-failed", str(error)) - return - self._generation += 1 - self._emit_state() - - def stop_and_transcribe(self) -> None: - self.machine.begin_transcription() - token = self._generation - workspace = self._workspace - if workspace is None: - self._fail("transcription-failed", "recording workspace is unavailable") - return - self._emit_state() - self._executor.submit(self._transcribe_worker, token, workspace) - - def toggle(self) -> None: - if self.state in (State.IDLE, State.ERROR): - self.start() - elif self.state is State.RECORDING: - self.stop_and_transcribe() - - def cancel(self) -> None: - previous = self.state - self.machine.cancel() - self._generation += 1 - if previous is State.RECORDING: - self.recorder.cancel() - self._cleanup_workspace() - else: - self.transcriber.cancel() - self._emit_state() - - def close(self) -> None: - if self.state is State.RECORDING: - self.recorder.cancel() - elif self.state is State.TRANSCRIBING: - self.transcriber.cancel() - self._generation += 1 - self._cleanup_workspace() - self._executor.shutdown(wait=False, cancel_futures=True) - - def _transcribe_worker( - self, token: int, workspace: tempfile.TemporaryDirectory[str] - ) -> None: - try: - wav_path = self.recorder.stop() - if token != self._generation: - self.dispatch(lambda: self._discard_worker(workspace)) - return - text = self.transcriber.transcribe(wav_path, Path(workspace.name)) - if token != self._generation: - self.dispatch(lambda: self._discard_worker(workspace)) - return - path = save_transcript(text, self.config.transcript_dir) - except Exception as error: - self.dispatch( - lambda error=error: self._worker_failed(token, workspace, error) - ) - else: - self.dispatch(lambda: self._worker_succeeded(token, workspace, text, path)) - - def _worker_succeeded( - self, - token: int, - workspace: tempfile.TemporaryDirectory[str], - text: str, - path: Path, - ) -> None: - if token != self._generation: - self._cleanup_workspace(workspace) - return - self.last_transcript = text - self.last_transcript_path = path - self.machine.complete() - self._cleanup_workspace(workspace) - self.on_transcript(text, path) - self._emit_state() - - def _worker_failed( - self, - token: int, - workspace: tempfile.TemporaryDirectory[str], - error: Exception, - ) -> None: - if token != self._generation: - self._cleanup_workspace(workspace) - return - self._cleanup_workspace(workspace) - self._fail("transcription-failed", str(error)) - - def _discard_worker(self, workspace: tempfile.TemporaryDirectory[str]) -> None: - self._cleanup_workspace(workspace) - - def _fail(self, code: str, message: str) -> None: - self.machine.fail() - self.last_error = message - self.on_error(code, message) - self._emit_state() - - def _emit_state(self) -> None: - self.on_state(self.state) - - def _cleanup_workspace( - self, workspace: tempfile.TemporaryDirectory[str] | None = None - ) -> None: - with self._lock: - target = workspace or self._workspace - if target is self._workspace: - self._workspace = None - if target is not None: - target.cleanup() diff --git a/src/parley/ctl.py b/src/parley/ctl.py deleted file mode 100644 index 8ae7450..0000000 --- a/src/parley/ctl.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Thin command-line client for the Parley D-Bus daemon.""" - -import argparse -import sys - -import gi - -gi.require_version("Gio", "2.0") -from gi.repository import Gio, GLib - -from .daemon import BUS_NAME, INTERFACE, OBJECT_PATH - - -METHODS = { - "toggle": "Toggle", - "start": "StartRecording", - "stop": "StopAndTranscribe", - "cancel": "Cancel", - "copy": "CopyLastTranscript", - "insert": "InsertLastTranscript", - "open-folder": "OpenTranscriptFolder", -} - - -def _proxy() -> Gio.DBusProxy: - return Gio.DBusProxy.new_for_bus_sync( - Gio.BusType.SESSION, - Gio.DBusProxyFlags.NONE, - None, - BUS_NAME, - OBJECT_PATH, - INTERFACE, - None, - ) - - -def _status(proxy: Gio.DBusProxy) -> None: - result = proxy.call_sync( - "org.freedesktop.DBus.Properties.GetAll", - GLib.Variant("(s)", (INTERFACE,)), - Gio.DBusCallFlags.NONE, - -1, - None, - ) - properties = result.unpack()[0] - print(f"state: {properties['State']}") - print(f"insertion mode: {properties['InsertionMode']}") - print(f"auto insert: {'yes' if properties['AutoInsert'] else 'no'}") - if properties["LastTranscriptPath"]: - print(f"last transcript: {properties['LastTranscriptPath']}") - if properties["LastError"]: - print(f"last error: {properties['LastError']}") - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="parleyctl") - parser.add_argument("command", choices=[*METHODS, "status"]) - arguments = parser.parse_args(argv) - try: - proxy = _proxy() - if arguments.command == "status": - _status(proxy) - else: - proxy.call_sync( - METHODS[arguments.command], - None, - Gio.DBusCallFlags.NONE, - -1, - None, - ) - return 0 - except GLib.Error as error: - print(f"parleyctl: {error.message}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/parley/daemon.py b/src/parley/daemon.py deleted file mode 100644 index de76bcd..0000000 --- a/src/parley/daemon.py +++ /dev/null @@ -1,261 +0,0 @@ -"""Per-user GObject/D-Bus daemon for Parley.""" - -from importlib.resources import files -import signal -import sys - -import gi - -gi.require_version("Gio", "2.0") -gi.require_version("GLib", "2.0") -gi.require_version("GLibUnix", "2.0") -from gi.repository import Gio, GLib, GLibUnix - -from .config import Config -from .controller import Controller -from .desktop import copy_text, notify, open_folder -from .errors import ParleyError -from .ibus_engine import IBusIntegration -from .state import State - - -BUS_NAME = "org.parley.Transcription1" -OBJECT_PATH = "/org/parley/Transcription1" -INTERFACE = BUS_NAME - - -class Daemon: - def __init__(self, config: Config | None = None) -> None: - xml = ( - files("parley.interfaces") - .joinpath("org.parley.Transcription1.xml") - .read_text(encoding="utf-8") - ) - self.node_info = Gio.DBusNodeInfo.new_for_xml(xml) - self.interface_info = self.node_info.interfaces[0] - resolved_config = config or Config.from_environment() - self.loop = GLib.MainLoop() - self.ibus = IBusIntegration( - activate_on_connect=resolved_config.auto_insert - and resolved_config.insertion_mode == "ibus" - ) - self.connection: Gio.DBusConnection | None = None - self.registration_id = 0 - self.controller = Controller( - resolved_config, - dispatch=lambda callback: GLib.idle_add(callback), - on_state=self._on_state, - on_transcript=self._on_transcript, - on_error=self._on_error, - ) - - def run(self) -> int: - owner_id = Gio.bus_own_name( - Gio.BusType.SESSION, - BUS_NAME, - Gio.BusNameOwnerFlags.NONE, - self._on_bus_acquired, - self._on_name_acquired, - self._on_name_lost, - ) - GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, self._quit) - GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, self._quit) - try: - self.loop.run() - finally: - self.controller.close() - self.ibus.close() - if self.connection is not None and self.registration_id: - self.connection.unregister_object(self.registration_id) - Gio.bus_unown_name(owner_id) - return 0 - - def _on_bus_acquired(self, connection: Gio.DBusConnection, name: str) -> None: - self.connection = connection - self.registration_id = connection.register_object_with_closures2( - OBJECT_PATH, - self.interface_info, - self._method_call, - self._get_property, - None, - ) - - def _on_name_acquired(self, connection: Gio.DBusConnection, name: str) -> None: - print(f"Parley daemon owns {name}", file=sys.stderr) - - def _on_name_lost(self, connection: Gio.DBusConnection | None, name: str) -> None: - print(f"error: could not own D-Bus name {name}", file=sys.stderr) - self.loop.quit() - - def _method_call( - self, - connection: Gio.DBusConnection, - sender: str, - object_path: str, - interface_name: str, - method_name: str, - parameters: GLib.Variant, - invocation: Gio.DBusMethodInvocation, - ) -> None: - try: - methods = { - "StartRecording": self.controller.start, - "StopAndTranscribe": self.controller.stop_and_transcribe, - "Toggle": self.controller.toggle, - "Cancel": self.controller.cancel, - "CopyLastTranscript": self._copy_last, - "InsertLastTranscript": self._insert_last, - "OpenTranscriptFolder": self._open_transcript_folder, - } - method = methods.get(method_name) - if method is None: - invocation.return_dbus_error( - f"{INTERFACE}.Error.UnknownMethod", f"unknown method: {method_name}" - ) - return - method() - invocation.return_value(None) - except (OSError, ParleyError) as error: - invocation.return_dbus_error(f"{INTERFACE}.Error.Failed", str(error)) - except Exception as error: - print(f"unexpected method failure: {error}", file=sys.stderr) - invocation.return_dbus_error( - f"{INTERFACE}.Error.Internal", "internal daemon error; see user journal" - ) - - def _get_property( - self, - connection: Gio.DBusConnection, - sender: str, - object_path: str, - interface_name: str, - property_name: str, - ) -> GLib.Variant: - values = { - "State": GLib.Variant("s", self.controller.state.value), - "LastTranscript": GLib.Variant("s", self.controller.last_transcript), - "LastTranscriptPath": GLib.Variant( - "s", - str(self.controller.last_transcript_path or ""), - ), - "LastError": GLib.Variant("s", self.controller.last_error), - "InsertionMode": GLib.Variant("s", self.controller.config.insertion_mode), - "AutoInsert": GLib.Variant("b", self.controller.config.auto_insert), - } - return values[property_name] - - def _copy_last(self) -> None: - if not self.controller.last_transcript_path: - raise ParleyError("no transcript is available") - copy_text(self.controller.last_transcript) - - def _insert_last(self) -> None: - if not self.controller.last_transcript_path: - raise ParleyError("no transcript is available") - text = self.controller.last_transcript - backend = self.controller.config.insertion_mode - if backend == "ibus": - try: - message = self.ibus.commit(text) - except ParleyError as error: - copy_text(text) - self._emit( - "InsertionFinished", - GLib.Variant("(sbs)", ("ibus", False, f"{error}; copied instead")), - ) - return - else: - copy_text(text) - message = "copied; press Ctrl+V to insert" - self._emit("InsertionFinished", GLib.Variant("(sbs)", (backend, True, message))) - - def _open_transcript_folder(self) -> None: - directory = self.controller.config.transcript_dir - directory.mkdir(parents=True, exist_ok=True) - open_folder(directory) - - def _on_state(self, state: State) -> None: - config = self.controller.config - self.ibus.set_processing( - state is State.TRANSCRIBING - and config.auto_insert - and config.insertion_mode == "ibus" - ) - self._emit("StateChanged", GLib.Variant("(s)", (state.value,))) - self._properties_changed("State", GLib.Variant("s", state.value)) - - def _on_transcript(self, text, path) -> None: - self._emit("TranscriptReady", GLib.Variant("(ss)", (text, str(path)))) - self._properties_changed( - "LastTranscript", - GLib.Variant("s", text), - "LastTranscriptPath", - GLib.Variant("s", str(path)), - ) - clipboard_error = None - try: - copy_text(text) - except ParleyError as error: - clipboard_error = error - self.controller.last_error = str(error) - self._on_error("clipboard-failed", str(error)) - - config = self.controller.config - if config.auto_insert and config.insertion_mode == "ibus": - try: - message = self.ibus.commit(text) - except ParleyError as error: - self._emit( - "InsertionFinished", - GLib.Variant("(sbs)", ("ibus", False, str(error))), - ) - fallback = ( - "Copied; press Ctrl+V" - if clipboard_error is None - else "Transcript saved" - ) - self._notify("Automatic insertion unavailable", fallback) - else: - self._emit( - "InsertionFinished", - GLib.Variant("(sbs)", ("ibus", True, message)), - ) - self._notify("Transcription ready", "Inserted through IBus") - elif clipboard_error is None: - self._notify("Transcription ready", "Copied to the clipboard") - - def _on_error(self, code: str, message: str) -> None: - self._emit("Error", GLib.Variant("(ss)", (code, message))) - self._properties_changed("LastError", GLib.Variant("s", message)) - self._notify("Parley error", message) - - def _notify(self, summary: str, body: str) -> None: - if self.controller.config.notifications_enabled: - notify(summary, body) - - def _properties_changed(self, *items) -> None: - changed = {items[index]: items[index + 1] for index in range(0, len(items), 2)} - if self.connection is not None: - self.connection.emit_signal( - None, - OBJECT_PATH, - "org.freedesktop.DBus.Properties", - "PropertiesChanged", - GLib.Variant("(sa{sv}as)", (INTERFACE, changed, [])), - ) - - def _emit(self, name: str, parameters: GLib.Variant) -> None: - if self.connection is not None: - self.connection.emit_signal(None, OBJECT_PATH, INTERFACE, name, parameters) - - def _quit(self) -> bool: - self.loop.quit() - return GLib.SOURCE_REMOVE - - -def main() -> int: - return Daemon().run() - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/parley/desktop.py b/src/parley/desktop.py deleted file mode 100644 index 3802985..0000000 --- a/src/parley/desktop.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Small desktop integrations used by the prototype daemon.""" - -from pathlib import Path -import shutil -import subprocess - -from .errors import ParleyError - - -class DesktopIntegrationError(ParleyError): - pass - - -def copy_text(text: str) -> None: - """Copy text through XWayland's clipboard bridge on GNOME.""" - executable = shutil.which("xclip") - if executable is None: - raise DesktopIntegrationError( - "xclip is not installed; transcript was saved but not copied" - ) - try: - result = subprocess.run( - [executable, "-selection", "clipboard", "-in"], - input=text, - text=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=10, - ) - except (OSError, subprocess.TimeoutExpired) as error: - raise DesktopIntegrationError(f"could not copy transcript: {error}") from error - if result.returncode != 0: - raise DesktopIntegrationError("xclip could not access the clipboard") - - -def notify(summary: str, body: str) -> None: - executable = shutil.which("notify-send") - if executable is not None: - subprocess.Popen( - [executable, "--app-name", "Parley", summary, body], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - -def open_folder(path: Path) -> None: - executable = shutil.which("xdg-open") - if executable is None: - raise DesktopIntegrationError("xdg-open is not installed") - try: - subprocess.Popen( - [executable, str(path)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except OSError as error: - raise DesktopIntegrationError( - f"could not open transcript folder: {error}" - ) from error diff --git a/src/parley/errors.py b/src/parley/errors.py deleted file mode 100644 index 7862463..0000000 --- a/src/parley/errors.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Errors exposed by the Parley core.""" - - -class ParleyError(Exception): - """Base class for expected, user-actionable Parley failures.""" - - -class ConfigurationError(ParleyError): - """A required executable, model, or setting is invalid.""" - - -class RecordingError(ParleyError): - """Audio capture could not start or finish successfully.""" - - -class TranscriptionError(ParleyError): - """The transcription runtime failed or returned no result.""" diff --git a/src/parley/ibus_engine.py b/src/parley/ibus_engine.py deleted file mode 100644 index 1f80868..0000000 --- a/src/parley/ibus_engine.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Persistent passthrough IBus engine hosted by the Parley daemon.""" - -import gi - -gi.require_version("GLib", "2.0") -gi.require_version("IBus", "1.0") -from gi.repository import GLib, IBus - -from .errors import ParleyError - - -class IBusInsertionError(ParleyError): - pass - - -class ParleyIBusEngine(IBus.Engine): - """Pass ordinary keys through and accept explicit semantic text commits.""" - - focused: "ParleyIBusEngine | None" = None - integration: "IBusIntegration | None" = None - - def do_process_key_event(self, keyval: int, keycode: int, state: int) -> bool: - return False - - def do_focus_in(self) -> None: - ParleyIBusEngine.focused = self - if ParleyIBusEngine.integration is not None: - ParleyIBusEngine.integration.refresh_preedit() - - def do_focus_out(self) -> None: - if ParleyIBusEngine.focused is self: - self.hide_preedit_text() - ParleyIBusEngine.focused = None - - def commit(self, text: str) -> str: - self.commit_text(IBus.Text.new_from_string(text)) - return "text committed through IBus" - - -class IBusIntegration: - """Register Parley's engine and reconnect after a rare IBus restart.""" - - SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") - - def __init__(self, *, activate_on_connect: bool = False) -> None: - IBus.init() - self.activate_on_connect = activate_on_connect - self._processing = False - self._spinner_frame = 0 - self._spinner_source = 0 - ParleyIBusEngine.integration = self - self.bus: IBus.Bus | None = None - self.factory: IBus.Factory | None = None - self.component: IBus.Component | None = None - self._disconnect_signal = 0 - self._reconnect_source = 0 - self._activation_source = 0 - self._closed = False - self._connect() - - @property - def available(self) -> bool: - return self.bus is not None and self.bus.is_connected() - - def commit(self, text: str) -> str: - self.set_processing(False) - if not self.available: - raise IBusInsertionError("IBus is unavailable") - engine = ParleyIBusEngine.focused - if engine is None: - raise IBusInsertionError("Parley does not own the focused input context") - return engine.commit(text) - - def set_processing(self, processing: bool) -> None: - if processing == self._processing: - return - self._processing = processing - if processing: - self._spinner_frame = 0 - self.refresh_preedit() - if not self._spinner_source: - self._spinner_source = GLib.timeout_add(120, self._advance_spinner) - else: - if self._spinner_source: - GLib.Source.remove(self._spinner_source) - self._spinner_source = 0 - engine = ParleyIBusEngine.focused - if engine is not None: - engine.hide_preedit_text() - - def refresh_preedit(self) -> None: - engine = ParleyIBusEngine.focused - if not self._processing or engine is None: - return - message = f"{self.SPINNER_FRAMES[self._spinner_frame]} Transcribing…" - engine.update_preedit_text_with_mode( - IBus.Text.new_from_string(message), - len(message), - True, - IBus.PreeditFocusMode.CLEAR, - ) - - def _advance_spinner(self) -> bool: - if not self._processing: - self._spinner_source = 0 - return GLib.SOURCE_REMOVE - self._spinner_frame = (self._spinner_frame + 1) % len(self.SPINNER_FRAMES) - self.refresh_preedit() - return GLib.SOURCE_CONTINUE - - def close(self) -> None: - self.set_processing(False) - self._closed = True - if ParleyIBusEngine.integration is self: - ParleyIBusEngine.integration = None - if self._reconnect_source: - GLib.Source.remove(self._reconnect_source) - self._reconnect_source = 0 - if self._activation_source: - GLib.Source.remove(self._activation_source) - self._activation_source = 0 - self._clear_connection() - - def _connect(self) -> bool: - self._reconnect_source = 0 - if self._closed: - return GLib.SOURCE_REMOVE - bus = IBus.Bus.new() - if not bus.is_connected(): - self._schedule_reconnect() - return GLib.SOURCE_REMOVE - - factory = IBus.Factory.new(bus.get_connection()) - factory.add_engine("parley", ParleyIBusEngine.__gtype__) - component = IBus.Component.new( - "org.parley.IBus", - "Parley dictation passthrough engine", - "0.1.0", - "GPL-3.0-or-later", - "Parley contributors", - "", - "", - "", - ) - component.add_engine( - IBus.EngineDesc.new( - "parley", - "Parley Dictation", - "Commit local dictation as text", - "en", - "GPL-3.0-or-later", - "Parley contributors", - "audio-input-microphone-symbolic", - "default", - ) - ) - if not bus.register_component(component): - factory.destroy() - self._schedule_reconnect() - return GLib.SOURCE_REMOVE - - self.bus = bus - self.factory = factory - self.component = component - self._disconnect_signal = bus.connect("disconnected", self._on_disconnected) - if self.activate_on_connect: - self._activation_source = GLib.timeout_add(100, self._activate) - return GLib.SOURCE_REMOVE - - def _activate(self) -> bool: - self._activation_source = 0 - if self.available: - self.bus.set_global_engine_async( - "parley", 2_000, None, self._activation_finished - ) - return GLib.SOURCE_REMOVE - - def _activation_finished(self, bus: IBus.Bus, result) -> None: - try: - bus.set_global_engine_async_finish(result) - except GLib.Error: - # Insertion remains available as a clipboard fallback. A later - # explicit selection can activate the engine. - pass - - def _on_disconnected(self, bus: IBus.Bus) -> None: - self._clear_connection() - self._schedule_reconnect() - - def _schedule_reconnect(self) -> None: - if not self._closed and not self._reconnect_source: - self._reconnect_source = GLib.timeout_add_seconds(2, self._connect) - - def _clear_connection(self) -> None: - self.set_processing(False) - ParleyIBusEngine.focused = None - bus, self.bus = self.bus, None - if bus is not None and self._disconnect_signal: - bus.disconnect(self._disconnect_signal) - self._disconnect_signal = 0 - factory, self.factory = self.factory, None - if factory is not None: - factory.destroy() - self.component = None diff --git a/src/parley/interfaces/__init__.py b/src/parley/interfaces/__init__.py deleted file mode 100644 index bded51d..0000000 --- a/src/parley/interfaces/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Packaged D-Bus interface definitions.""" diff --git a/src/parley/interfaces/org.parley.Transcription1.xml b/src/parley/interfaces/org.parley.Transcription1.xml deleted file mode 100644 index 12b7960..0000000 --- a/src/parley/interfaces/org.parley.Transcription1.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/parley/persistence.py b/src/parley/persistence.py deleted file mode 100644 index bd004d8..0000000 --- a/src/parley/persistence.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Collision-safe, atomic transcript persistence.""" - -from datetime import datetime -import os -from pathlib import Path -import tempfile -from typing import Callable - - -Clock = Callable[[], datetime] - - -def save_transcript(text: str, directory: Path, *, clock: Clock | None = None) -> Path: - """Atomically save UTF-8 text and return its collision-safe path.""" - directory.mkdir(parents=True, exist_ok=True) - now = (clock or (lambda: datetime.now().astimezone()))() - timestamp = now.strftime("%Y-%m-%d_%H-%M-%S_%f%z") - content = text.rstrip("\n") + "\n" - - temporary_name: str | None = None - try: - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=directory, - prefix=".transcript-", - suffix=".tmp", - delete=False, - ) as output: - temporary_name = output.name - output.write(content) - output.flush() - os.fsync(output.fileno()) - - temporary = Path(temporary_name) - counter = 0 - while True: - suffix = f"_{counter}" if counter else "" - destination = directory / f"transcript_{timestamp}{suffix}.txt" - try: - # A hard link publishes the completed file atomically and refuses to - # replace a name won by another process. - os.link(temporary, destination) - return destination - except FileExistsError: - counter += 1 - finally: - if temporary_name is not None: - Path(temporary_name).unlink(missing_ok=True) diff --git a/src/parley/recording.py b/src/parley/recording.py deleted file mode 100644 index 5ee3ec2..0000000 --- a/src/parley/recording.py +++ /dev/null @@ -1,97 +0,0 @@ -"""FFmpeg recording lifecycle with no terminal or UI dependencies.""" - -from pathlib import Path -import subprocess - -from .config import Config -from .errors import RecordingError - - -class Recorder: - """Own one FFmpeg process and produce a valid PCM WAV on stop.""" - - def __init__(self, config: Config, *, stop_timeout: float = 5.0) -> None: - self.config = config - self.stop_timeout = stop_timeout - self._process: subprocess.Popen[bytes] | None = None - self._output_path: Path | None = None - - @property - def is_recording(self) -> bool: - return self._process is not None and self._process.poll() is None - - def start(self, output_path: Path) -> None: - if self._process is not None: - raise RecordingError("a recording is already active") - output_path.parent.mkdir(parents=True, exist_ok=True) - command = [ - str(self.config.ffmpeg), - "-hide_banner", - "-loglevel", - "error", - "-y", - "-f", - "pulse", - "-i", - self.config.device, - "-ac", - "1", - "-ar", - str(self.config.sample_rate), - "-c:a", - "pcm_s16le", - str(output_path), - ] - try: - self._process = subprocess.Popen( - command, - stdin=subprocess.PIPE, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - except OSError as error: - raise RecordingError(f"could not start ffmpeg: {error}") from error - self._output_path = output_path - - def stop(self) -> Path: - process = self._require_process() - output_path = self._output_path - assert output_path is not None - returncode, stderr = self._finish(process) - self._clear() - if returncode != 0: - detail = stderr.decode(errors="replace").strip() - output_path.unlink(missing_ok=True) - raise RecordingError(f"recording failed: {detail or 'ffmpeg error'}") - if not output_path.is_file(): - raise RecordingError("recording finished without producing audio") - return output_path - - def cancel(self) -> None: - if self._process is not None: - self._finish(self._process) - output_path = self._output_path - self._clear() - if output_path is not None: - output_path.unlink(missing_ok=True) - - def _require_process(self) -> subprocess.Popen[bytes]: - if self._process is None: - raise RecordingError("no recording is active") - return self._process - - def _finish(self, process: subprocess.Popen[bytes]) -> tuple[int, bytes]: - try: - _, stderr = process.communicate(input=b"q\n", timeout=self.stop_timeout) - except subprocess.TimeoutExpired: - process.terminate() - try: - _, stderr = process.communicate(timeout=self.stop_timeout) - except subprocess.TimeoutExpired: - process.kill() - _, stderr = process.communicate() - return process.returncode, stderr or b"" - - def _clear(self) -> None: - self._process = None - self._output_path = None diff --git a/src/parley/state.py b/src/parley/state.py deleted file mode 100644 index 8ec7a35..0000000 --- a/src/parley/state.py +++ /dev/null @@ -1,62 +0,0 @@ -"""The daemon's UI-independent operation state machine.""" - -from enum import StrEnum - -from .errors import ParleyError - - -class State(StrEnum): - IDLE = "idle" - RECORDING = "recording" - TRANSCRIBING = "transcribing" - INSERTING = "inserting" - ERROR = "error" - - -class InvalidStateError(ParleyError): - """An operation is not valid in the daemon's current state.""" - - -class StateMachine: - """Validate state transitions independently of D-Bus and subprocesses.""" - - def __init__(self) -> None: - self.state = State.IDLE - - def start_recording(self) -> State: - self._require(State.IDLE, State.ERROR) - return self._set(State.RECORDING) - - def begin_transcription(self) -> State: - self._require(State.RECORDING) - return self._set(State.TRANSCRIBING) - - def begin_insertion(self) -> State: - self._require(State.TRANSCRIBING) - return self._set(State.INSERTING) - - def complete(self) -> State: - self._require(State.TRANSCRIBING, State.INSERTING) - return self._set(State.IDLE) - - def cancel(self) -> State: - self._require(State.RECORDING, State.TRANSCRIBING) - return self._set(State.IDLE) - - def fail(self) -> State: - return self._set(State.ERROR) - - def reset(self) -> State: - self._require(State.ERROR) - return self._set(State.IDLE) - - def _require(self, *allowed: State) -> None: - if self.state not in allowed: - expected = ", ".join(value.value for value in allowed) - raise InvalidStateError( - f"operation requires state {expected}; current state is {self.state.value}" - ) - - def _set(self, state: State) -> State: - self.state = state - return state diff --git a/src/parley/transcription.py b/src/parley/transcription.py deleted file mode 100644 index 0735bb2..0000000 --- a/src/parley/transcription.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Invoke transcribe-cli and parse its JSONL batch output.""" - -import json -from pathlib import Path -import subprocess -import threading - -from .config import Config -from .errors import TranscriptionError - - -def parse_jsonl(output: str) -> str: - """Return the first transcript row, ignoring headers and diagnostic lines.""" - for line in output.splitlines(): - try: - row = json.loads(line) - except json.JSONDecodeError, TypeError: - continue - if not isinstance(row, dict) or row.get("type") == "batch_header": - continue - if row.get("error"): - raise TranscriptionError(f"transcription failed: {row['error']}") - if "text" in row: - return str(row["text"]) - detail = output.strip() - if len(detail) > 500: - detail = detail[:500] + "…" - raise TranscriptionError(f"no transcript in runtime output: {detail or ''}") - - -class Transcriber: - def __init__(self, config: Config, *, cancel_timeout: float = 5.0) -> None: - self.config = config - self.cancel_timeout = cancel_timeout - self._lock = threading.Lock() - self._process: subprocess.Popen[str] | None = None - - def transcribe(self, wav_path: Path, workspace: Path) -> str: - batch_file = workspace / "input.txt" - batch_file.write_text(f"{wav_path}\n", encoding="utf-8") - command = [ - str(self.config.transcribe_cli), - "--quiet", - "--model", - str(self.config.model), - "--language", - self.config.language, - "--timestamps", - "none", - "--stream-chunk-ms", - "30", - "--batch", - str(batch_file), - "--batch-jsonl", - ] - try: - process = subprocess.Popen( - command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) - except OSError as error: - raise TranscriptionError( - f"could not start transcription: {error}" - ) from error - with self._lock: - self._process = process - try: - stdout, stderr = process.communicate() - finally: - with self._lock: - if self._process is process: - self._process = None - if process.returncode != 0: - detail = stderr.strip() or "transcription runtime failed" - raise TranscriptionError(detail) - return parse_jsonl(stdout) - - def cancel(self) -> None: - """Terminate an active runtime, escalating if it does not exit.""" - with self._lock: - process = self._process - if process is None or process.poll() is not None: - return - process.terminate() - try: - process.wait(timeout=self.cancel_timeout) - except subprocess.TimeoutExpired: - process.kill() - process.wait() -- cgit v1.3.1