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