summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.python-version1
-rw-r--r--README.md49
-rw-r--r--data/dbus/org.parley.Transcription1.service4
-rw-r--r--data/systemd/parley.service16
-rw-r--r--packaging/arch/PKGBUILD49
-rw-r--r--pyproject.toml11
-rw-r--r--src/parley/config.py22
-rw-r--r--src/parley/controller.py173
-rw-r--r--src/parley/ctl.py78
-rw-r--r--src/parley/daemon.py212
-rw-r--r--src/parley/desktop.py53
-rw-r--r--src/parley/interfaces/__init__.py1
-rw-r--r--src/parley/interfaces/org.parley.Transcription1.xml36
-rw-r--r--src/parley/state.py62
-rw-r--r--src/parley/transcription.py35
-rw-r--r--tests/test_controller.py88
-rw-r--r--tests/test_state.py41
-rw-r--r--uv.lock12
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
diff --git a/README.md b/README.md
index eb167d0..09db7cd 100644
--- a/README.md
+++ b/README.md
@@ -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()
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..1e05ee2
--- /dev/null
+++ b/uv.lock
@@ -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 = "." }