diff options
| author | Yuval Adam <_@yuv.al> | 2026-07-24 13:23:44 +0200 |
|---|---|---|
| committer | Yuval Adam <_@yuv.al> | 2026-07-24 13:23:44 +0200 |
| commit | 70781bce8d2c18c8292a89653b6183d2bb210441 (patch) | |
| tree | 8057692c9a5bdab5d926b60c1221cd58a2a4a178 /src | |
| parent | 7bcb41306a944160a58393c50593f25767649f59 (diff) | |
Run source checkout without PYTHONPATH
Diffstat (limited to 'src')
| -rw-r--r-- | src/parley/__init__.py | 3 | ||||
| -rw-r--r-- | src/parley/cli.py | 74 | ||||
| -rw-r--r-- | src/parley/config.py | 87 | ||||
| -rw-r--r-- | src/parley/controller.py | 171 | ||||
| -rw-r--r-- | src/parley/ctl.py | 78 | ||||
| -rw-r--r-- | src/parley/daemon.py | 261 | ||||
| -rw-r--r-- | src/parley/desktop.py | 59 | ||||
| -rw-r--r-- | src/parley/errors.py | 17 | ||||
| -rw-r--r-- | src/parley/ibus_engine.py | 204 | ||||
| -rw-r--r-- | src/parley/interfaces/__init__.py | 1 | ||||
| -rw-r--r-- | src/parley/interfaces/org.parley.Transcription1.xml | 36 | ||||
| -rw-r--r-- | src/parley/persistence.py | 49 | ||||
| -rw-r--r-- | src/parley/recording.py | 97 | ||||
| -rw-r--r-- | src/parley/state.py | 62 | ||||
| -rw-r--r-- | src/parley/transcription.py | 88 |
15 files changed, 0 insertions, 1287 deletions
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 @@ -<?xml version="1.0" encoding="UTF-8"?> -<node> - <interface name="org.parley.Transcription1"> - <method name="StartRecording"/> - <method name="StopAndTranscribe"/> - <method name="Toggle"/> - <method name="Cancel"/> - <method name="CopyLastTranscript"/> - <method name="InsertLastTranscript"/> - <method name="OpenTranscriptFolder"/> - - <property name="State" type="s" access="read"/> - <property name="LastTranscript" type="s" access="read"/> - <property name="LastTranscriptPath" type="s" access="read"/> - <property name="LastError" type="s" access="read"/> - <property name="InsertionMode" type="s" access="read"/> - <property name="AutoInsert" type="b" access="read"/> - - <signal name="StateChanged"> - <arg name="state" type="s"/> - </signal> - <signal name="TranscriptReady"> - <arg name="text" type="s"/> - <arg name="path" type="s"/> - </signal> - <signal name="Error"> - <arg name="code" type="s"/> - <arg name="message" type="s"/> - </signal> - <signal name="InsertionFinished"> - <arg name="backend" type="s"/> - <arg name="success" type="b"/> - <arg name="message" type="s"/> - </signal> - </interface> -</node> diff --git a/src/parley/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 '<empty>'}") - - -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() |
