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
---
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 -------
15 files changed, 1287 deletions(-)
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
(limited to 'src')
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