diff options
| author | Yuval Adam <_@yuv.al> | 2026-07-24 12:24:07 +0200 |
|---|---|---|
| committer | Yuval Adam <_@yuv.al> | 2026-07-24 12:24:07 +0200 |
| commit | 7f99c1957f29d57c006a11a02226ecab030a1d4c (patch) | |
| tree | b9fc555d6bfd603847eb066ed71acbe5d21c09b3 | |
| parent | df594d42ad1206fcb246bedb1945949527d7e039 (diff) | |
Add D-Bus daemon and Arch user service packaging
| -rw-r--r-- | .python-version | 1 | ||||
| -rw-r--r-- | README.md | 49 | ||||
| -rw-r--r-- | data/dbus/org.parley.Transcription1.service | 4 | ||||
| -rw-r--r-- | data/systemd/parley.service | 16 | ||||
| -rw-r--r-- | packaging/arch/PKGBUILD | 49 | ||||
| -rw-r--r-- | pyproject.toml | 11 | ||||
| -rw-r--r-- | src/parley/config.py | 22 | ||||
| -rw-r--r-- | src/parley/controller.py | 173 | ||||
| -rw-r--r-- | src/parley/ctl.py | 78 | ||||
| -rw-r--r-- | src/parley/daemon.py | 212 | ||||
| -rw-r--r-- | src/parley/desktop.py | 53 | ||||
| -rw-r--r-- | src/parley/interfaces/__init__.py | 1 | ||||
| -rw-r--r-- | src/parley/interfaces/org.parley.Transcription1.xml | 36 | ||||
| -rw-r--r-- | src/parley/state.py | 62 | ||||
| -rw-r--r-- | src/parley/transcription.py | 35 | ||||
| -rw-r--r-- | tests/test_controller.py | 88 | ||||
| -rw-r--r-- | tests/test_state.py | 41 | ||||
| -rw-r--r-- | uv.lock | 12 |
18 files changed, 918 insertions, 25 deletions
diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 @@ -6,7 +6,7 @@ See [SPEC.md](SPEC.md) for the architecture, delivery phases, insertion experime ## Status -Phase 1 is underway. The repository now contains a terminal-independent Python core for FFmpeg recording, JSONL transcription, and atomic transcript persistence, plus a standalone terminal frontend. The daemon, D-Bus API, clipboard integration, and GNOME extension are not implemented yet. +Phase 1's core is implemented and Phase 2 is underway. The repository contains the standalone frontend, an asynchronous per-user D-Bus daemon, `parleyctl`, GDK clipboard copying, prototype notifications, and service definitions. The custom shortcut installer, insertion experiments, and GNOME extension are not implemented yet. ## Development environment @@ -32,22 +32,23 @@ That binary presently requires host CUDA 13 and Intel MKL libraries. Do not copy ## Current developer usage -Create an environment and install the development checkout: +Set up the development checkout with `uv` and Arch's current system Python (Python 3.14). PyGObject is supplied by Arch, so the uv environment must inherit system site packages: ```bash cd ../parley -python -m venv .venv -. .venv/bin/activate -python -m pip install -e . +uv venv --python /usr/bin/python --system-site-packages +uv pip install -e . ``` +Use `uv run` for local Python commands. + Run the standalone frontend (Enter stops and transcribes; Escape cancels): ```bash -parley +uv run parley ``` -By default it uses the runtime and model in `../transcribe-parakeet` and saves under `$XDG_DATA_HOME/parley/transcripts` (normally `~/.local/share/parley/transcripts`). Development paths can be overridden with: +By default it finds `transcribe-cli` on `PATH` and the model at `$XDG_DATA_HOME/parley/models/parakeet-unified-en-0.6b-Q8_0.gguf`. During development, each falls back to the corresponding asset in `../transcribe-parakeet`. Transcripts are saved under `$XDG_DATA_HOME/parley/transcripts` (normally `~/.local/share/parley/transcripts`). Paths can be overridden with: - `PARLEY_FFMPEG` - `PARLEY_TRANSCRIBE_CLI` @@ -59,10 +60,40 @@ By default it uses the runtime and model in `../transcribe-parakeet` and saves u Run tests without desktop, microphone, or model dependencies: ```bash -python -m unittest discover -s tests +uv run python -m unittest discover -s tests +``` + +Run the daemon in a terminal for development: + +```bash +uv run parleyd ``` -Commands such as `parleyctl` and daemon startup will be documented when Phase 2 lands. +In another terminal, control it over the session bus: + +```bash +uv run parleyctl status +uv run parleyctl toggle # start recording +uv run parleyctl toggle # stop and transcribe +uv run parleyctl cancel +uv run parleyctl copy +``` + +## Arch Linux package + +The local PKGBUILD installs `parleyd` and the other executables under `/usr/bin`, the systemd **user** unit under `/usr/lib/systemd/user`, and the session D-Bus service under `/usr/share/dbus-1/services`. It builds from the latest committed state of this checkout: + +```bash +git status # commit the source you want packaged +cd packaging/arch +makepkg -si +systemctl --user daemon-reload +systemctl --user enable --now parley.service +``` + +The package deliberately does not bundle the runtime or model. Put `transcribe-cli` on `PATH` (for example at `~/.local/bin/transcribe-cli`) and the model at `~/.local/share/parley/models/parakeet-unified-en-0.6b-Q8_0.gguf`, or set the documented environment overrides. User-manager environment overrides can be placed in `~/.config/environment.d/parley.conf`; log in again after changing them. + +The daemon uses GDK 4 for clipboard ownership and `notify-send` for prototype notifications. ## Planned first milestone diff --git a/data/dbus/org.parley.Transcription1.service b/data/dbus/org.parley.Transcription1.service new file mode 100644 index 0000000..cc5d2cf --- /dev/null +++ b/data/dbus/org.parley.Transcription1.service @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=org.parley.Transcription1 +Exec=/bin/false +SystemdService=parley.service diff --git a/data/systemd/parley.service b/data/systemd/parley.service new file mode 100644 index 0000000..4178f66 --- /dev/null +++ b/data/systemd/parley.service @@ -0,0 +1,16 @@ +[Unit] +Description=Parley local dictation daemon +StartLimitIntervalSec=60 +StartLimitBurst=3 + +[Service] +Type=dbus +BusName=org.parley.Transcription1 +ExecStart=/usr/bin/parleyd +Restart=on-failure +RestartSec=2 +KillMode=control-group +TimeoutStopSec=15 + +[Install] +WantedBy=default.target diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..8718009 --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,49 @@ +# Maintainer: Parley contributors +pkgname=parley +pkgver=0.1.0.dev0 +pkgrel=1 +pkgdesc='Local-first dictation tool for GNOME on Wayland' +arch=('any') +license=('GPL-3.0-or-later') +depends=( + 'ffmpeg' + 'gtk4' + 'libnotify' + 'python' + 'python-gobject' + 'xdg-utils' +) +makedepends=( + 'git' + 'python-build' + 'python-installer' + 'python-setuptools' + 'python-wheel' +) +optdepends=( + 'transcribe-cli: Parakeet transcription runtime (may also be installed manually)' +) +source=("$pkgname::git+file://${startdir}/../..") +sha256sums=('SKIP') + +build() { + cd "$srcdir/$pkgname" + python -m build --wheel --no-isolation +} + +check() { + cd "$srcdir/$pkgname" + PYTHONPATH=src python -m unittest discover -s tests -v +} + +package() { + cd "$srcdir/$pkgname" + python -m installer --destdir="$pkgdir" dist/*.whl + + install -Dm644 data/systemd/parley.service \ + "$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 \ + "$pkgdir/usr/share/dbus-1/interfaces/org.parley.Transcription1.xml" +} diff --git a/pyproject.toml b/pyproject.toml index 1e2079a..cf91954 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] @@ -7,12 +7,17 @@ name = "parley-dictation" version = "0.1.0.dev0" description = "Local-first dictation for GNOME" readme = "README.md" -requires-python = ">=3.11" -license = { text = "GPL-3.0-or-later" } +requires-python = ">=3.14" +license = "GPL-3.0-or-later" authors = [{ name = "Parley contributors" }] [project.scripts] parley = "parley.cli:main" +parleyd = "parley.daemon:main" +parleyctl = "parley.ctl:main" + +[tool.setuptools.package-data] +"parley.interfaces" = ["*.xml"] [tool.setuptools.packages.find] where = ["src"] diff --git a/src/parley/config.py b/src/parley/config.py index 88640b6..9dd953e 100644 --- a/src/parley/config.py +++ b/src/parley/config.py @@ -35,21 +35,27 @@ class Config: @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", prototype / "bin/transcribe-cli") - ).expanduser(), - model=Path( - os.environ.get( - "PARLEY_MODEL", - prototype / "models/parakeet-unified-en-0.6b-Q8_0.gguf", - ) + 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", _default_data_home() / "parley/transcripts" + "PARLEY_TRANSCRIPT_DIR", data_home / "parley/transcripts" ) ).expanduser(), device=os.environ.get("PARLEY_DEVICE", "default"), diff --git a/src/parley/controller.py b/src/parley/controller.py new file mode 100644 index 0000000..58091cf --- /dev/null +++ b/src/parley/controller.py @@ -0,0 +1,173 @@ +"""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 new file mode 100644 index 0000000..8ae7450 --- /dev/null +++ b/src/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/src/parley/daemon.py b/src/parley/daemon.py new file mode 100644 index 0000000..09fb566 --- /dev/null +++ b/src/parley/daemon.py @@ -0,0 +1,212 @@ +"""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 .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] + self.loop = GLib.MainLoop() + self.connection: Gio.DBusConnection | None = None + self.registration_id = 0 + self.controller = Controller( + config or Config.from_environment(), + 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() + 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( + 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", "clipboard"), + "AutoInsert": GLib.Variant("b", False), + } + 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: + try: + self._copy_last() + except ParleyError as error: + self._emit("InsertionFinished", GLib.Variant("(sbs)", ("clipboard", False, str(error)))) + raise + message = "copied; press Ctrl+V to insert" + self._emit( + "InsertionFinished", GLib.Variant("(sbs)", ("clipboard", 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: + 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)), + ) + try: + copy_text(text) + except ParleyError as error: + self.controller.last_error = str(error) + self._on_error("clipboard-failed", str(error)) + notify("Transcription saved", f"Clipboard failed: {error}") + else: + 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)) + notify("Parley error", message) + + 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 new file mode 100644 index 0000000..f95e537 --- /dev/null +++ b/src/parley/desktop.py @@ -0,0 +1,53 @@ +"""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 with GDK, retaining ownership in the long-running daemon.""" + try: + import gi + + gi.require_version("Gdk", "4.0") + from gi.repository import Gdk + + display = Gdk.Display.get_default() or Gdk.Display.open(None) + if display is None: + raise DesktopIntegrationError("could not connect to the graphical display") + display.get_clipboard().set(text) + except DesktopIntegrationError: + raise + except Exception as error: + raise DesktopIntegrationError(f"GDK clipboard is unavailable: {error}") from error + + +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/interfaces/__init__.py b/src/parley/interfaces/__init__.py new file mode 100644 index 0000000..bded51d --- /dev/null +++ b/src/parley/interfaces/__init__.py @@ -0,0 +1 @@ +"""Packaged D-Bus interface definitions.""" diff --git a/src/parley/interfaces/org.parley.Transcription1.xml b/src/parley/interfaces/org.parley.Transcription1.xml new file mode 100644 index 0000000..12b7960 --- /dev/null +++ b/src/parley/interfaces/org.parley.Transcription1.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8"?> +<node> + <interface name="org.parley.Transcription1"> + <method name="StartRecording"/> + <method name="StopAndTranscribe"/> + <method name="Toggle"/> + <method name="Cancel"/> + <method name="CopyLastTranscript"/> + <method name="InsertLastTranscript"/> + <method name="OpenTranscriptFolder"/> + + <property name="State" type="s" access="read"/> + <property name="LastTranscript" type="s" access="read"/> + <property name="LastTranscriptPath" type="s" access="read"/> + <property name="LastError" type="s" access="read"/> + <property name="InsertionMode" type="s" access="read"/> + <property name="AutoInsert" type="b" access="read"/> + + <signal name="StateChanged"> + <arg name="state" type="s"/> + </signal> + <signal name="TranscriptReady"> + <arg name="text" type="s"/> + <arg name="path" type="s"/> + </signal> + <signal name="Error"> + <arg name="code" type="s"/> + <arg name="message" type="s"/> + </signal> + <signal name="InsertionFinished"> + <arg name="backend" type="s"/> + <arg name="success" type="b"/> + <arg name="message" type="s"/> + </signal> + </interface> +</node> diff --git a/src/parley/state.py b/src/parley/state.py new file mode 100644 index 0000000..8ec7a35 --- /dev/null +++ b/src/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/src/parley/transcription.py b/src/parley/transcription.py index eea0497..87ce66a 100644 --- a/src/parley/transcription.py +++ b/src/parley/transcription.py @@ -3,6 +3,7 @@ import json from pathlib import Path import subprocess +import threading from .config import Config from .errors import TranscriptionError @@ -28,8 +29,11 @@ def parse_jsonl(output: str) -> str: class Transcriber: - def __init__(self, config: Config) -> None: + 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" @@ -50,10 +54,31 @@ class Transcriber: "--batch-jsonl", ] try: - result = subprocess.run(command, text=True, capture_output=True) + 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 - if result.returncode != 0: - detail = result.stderr.strip() or "transcription runtime failed" + 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(result.stdout) + 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/tests/test_controller.py b/tests/test_controller.py new file mode 100644 index 0000000..61c291e --- /dev/null +++ b/tests/test_controller.py @@ -0,0 +1,88 @@ +from pathlib import Path +import tempfile +import threading +import unittest + +from parley.config import Config +from parley.controller import Controller +from parley.state import State + + +class FakeRecorder: + def __init__(self) -> None: + self.path = None + self.cancelled = False + + def start(self, path: Path) -> None: + self.path = path + path.write_bytes(b"RIFF") + + def stop(self) -> Path: + return self.path + + def cancel(self) -> None: + self.cancelled = True + if self.path: + self.path.unlink(missing_ok=True) + + +class FakeTranscriber: + def transcribe(self, wav_path: Path, workspace: Path) -> str: + return "hello from controller" + + def cancel(self) -> None: + pass + + +class ControllerTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + root = Path(self.temporary.name) + ffmpeg = root / "ffmpeg" + runtime = root / "transcribe-cli" + model = root / "model.gguf" + for path in (ffmpeg, runtime): + path.write_text("#!/bin/sh\n") + path.chmod(0o755) + model.touch() + self.config = Config(ffmpeg, runtime, model, root / "transcripts") + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_successful_operation_persists_before_callback(self) -> None: + ready = threading.Event() + received = [] + controller = Controller( + self.config, + on_transcript=lambda text, path: (received.append((text, path)), ready.set()), + ) + controller.recorder = FakeRecorder() + controller.transcriber = FakeTranscriber() + try: + controller.start() + self.assertEqual(controller.state, State.RECORDING) + controller.stop_and_transcribe() + self.assertTrue(ready.wait(2), "transcription callback was not delivered") + self.assertEqual(controller.state, State.IDLE) + text, path = received[0] + self.assertEqual(text, "hello from controller") + self.assertEqual(path.read_text(), "hello from controller\n") + finally: + controller.close() + + def test_cancel_recording_discards_operation(self) -> None: + controller = Controller(self.config) + recorder = FakeRecorder() + controller.recorder = recorder + try: + controller.start() + controller.cancel() + self.assertEqual(controller.state, State.IDLE) + self.assertTrue(recorder.cancelled) + finally: + controller.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..9fd223c --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,41 @@ +import unittest + +from parley.state import InvalidStateError, State, StateMachine + + +class StateMachineTests(unittest.TestCase): + def test_record_transcribe_complete(self) -> None: + machine = StateMachine() + self.assertEqual(machine.start_recording(), State.RECORDING) + self.assertEqual(machine.begin_transcription(), State.TRANSCRIBING) + self.assertEqual(machine.complete(), State.IDLE) + + def test_insertion_flow(self) -> None: + machine = StateMachine() + machine.start_recording() + machine.begin_transcription() + self.assertEqual(machine.begin_insertion(), State.INSERTING) + self.assertEqual(machine.complete(), State.IDLE) + + def test_rejects_second_recording(self) -> None: + machine = StateMachine() + machine.start_recording() + with self.assertRaisesRegex(InvalidStateError, "current state is recording"): + machine.start_recording() + + def test_cancel_recording_and_transcription(self) -> None: + machine = StateMachine() + machine.start_recording() + self.assertEqual(machine.cancel(), State.IDLE) + machine.start_recording() + machine.begin_transcription() + self.assertEqual(machine.cancel(), State.IDLE) + + def test_error_is_recoverable(self) -> None: + machine = StateMachine() + machine.fail() + self.assertEqual(machine.start_recording(), State.RECORDING) + + +if __name__ == "__main__": + unittest.main() @@ -0,0 +1,12 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[[package]] +name = "parley-dictation" +version = "0.1.0.dev0" +source = { editable = "." } |
