diff options
| -rw-r--r-- | .gitignore | 8 | ||||
| -rw-r--r-- | README.md | 82 | ||||
| -rw-r--r-- | SPEC.md | 491 | ||||
| -rw-r--r-- | pyproject.toml | 21 | ||||
| -rw-r--r-- | src/parley/__init__.py | 3 | ||||
| -rw-r--r-- | src/parley/cli.py | 74 | ||||
| -rw-r--r-- | src/parley/config.py | 71 | ||||
| -rw-r--r-- | src/parley/errors.py | 17 | ||||
| -rw-r--r-- | src/parley/persistence.py | 51 | ||||
| -rw-r--r-- | src/parley/recording.py | 97 | ||||
| -rw-r--r-- | src/parley/transcription.py | 59 | ||||
| -rw-r--r-- | tests/test_persistence.py | 39 | ||||
| -rw-r--r-- | tests/test_recording.py | 65 | ||||
| -rw-r--r-- | tests/test_transcription.py | 29 |
14 files changed, 1107 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c31767 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +.venv/ +build/ +dist/ +*.egg-info/ +.pytest_cache/ +.coverage diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb167d0 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Parley + +Parley is a planned local GNOME/Wayland dictation tool built around the Parakeet transcription runtime. It will provide a per-user D-Bus daemon, global shortcut, GNOME top-bar extension, transcript history, and tiered text insertion through IBus, clipboard, or an opt-in portal paste backend. + +See [SPEC.md](SPEC.md) for the architecture, delivery phases, insertion experiments, and acceptance criteria. + +## Status + +Phase 1 is underway. The repository now contains a terminal-independent Python core for FFmpeg recording, JSONL transcription, and atomic transcript persistence, plus a standalone terminal frontend. The daemon, D-Bus API, clipboard integration, and GNOME extension are not implemented yet. + +## Development environment + +The initial target is Arch Linux with GNOME 50 on Wayland. Expected host dependencies include: + +- Python 3 and PyGObject +- GNOME Shell and `gnome-extensions` +- IBus +- PipeWire/WirePlumber with PulseAudio compatibility +- FFmpeg +- systemd user services +- XDG Desktop Portal and GNOME's portal backend +- libei/EIS development bindings for the portal-paste experiment + +The existing runtime assets are currently located at: + +```text +../transcribe-parakeet/bin/transcribe-cli +../transcribe-parakeet/models/parakeet-unified-en-0.6b-Q8_0.gguf +``` + +That binary presently requires host CUDA 13 and Intel MKL libraries. Do not copy the large model into this Git repository; development configuration should reference its existing path. + +## Current developer usage + +Create an environment and install the development checkout: + +```bash +cd ../parley +python -m venv .venv +. .venv/bin/activate +python -m pip install -e . +``` + +Run the standalone frontend (Enter stops and transcribes; Escape cancels): + +```bash +parley +``` + +By default it uses the runtime and model in `../transcribe-parakeet` and saves under `$XDG_DATA_HOME/parley/transcripts` (normally `~/.local/share/parley/transcripts`). Development paths can be overridden with: + +- `PARLEY_FFMPEG` +- `PARLEY_TRANSCRIBE_CLI` +- `PARLEY_MODEL` +- `PARLEY_TRANSCRIPT_DIR` +- `PARLEY_DEVICE` +- `PARLEY_LANGUAGE` + +Run tests without desktop, microphone, or model dependencies: + +```bash +python -m unittest discover -s tests +``` + +Commands such as `parleyctl` and daemon startup will be documented when Phase 2 lands. + +## Planned first milestone + +1. Extract recording, transcription, and persistence from the TTY frontend. +2. Preserve a standalone CLI using the shared core. +3. Add automated tests that do not require GNOME, a microphone, or the model. +4. Implement the user daemon, D-Bus interface, and `parleyctl toggle` workflow. + +## Design principles + +- Local-only transcription +- Never lose a successful transcript because insertion failed +- Keep model/audio work outside GNOME Shell +- Respect Wayland's security model +- Use IBus only if the passthrough/activation feasibility tests prove reliable +- Keep clipboard-only operation as the safe fallback +- Make synthetic portal paste explicit and revocable @@ -0,0 +1,491 @@ +# Parley specification + +## 1. Purpose + +Parley is a local-first dictation tool for GNOME on Wayland. It turns microphone recordings into text with the existing Parakeet transcription runtime and inserts the result at the focused application's caret when the desktop permits it. + +The normal interaction is: + +1. Place the caret in an editable field. +2. Press a global shortcut or click the top-bar microphone. +3. Speak. +4. Press the shortcut or click again. +5. Parley transcribes locally. +6. Parley inserts through the configured insertion backend. +7. Parley also saves the transcript and copies it to the clipboard. + +The initial target environment is: + +- Arch Linux +- GNOME Shell 50 on Wayland +- IBus 1.5.34 +- PipeWire/WirePlumber with PulseAudio compatibility +- Python 3 with PyGObject +- The existing `transcribe-cli` binary and Parakeet Unified EN model + +## 2. Goals + +- Feel native on GNOME: top-bar status, global shortcut, notifications, and clear recording/transcribing states. +- Work in as many desktop applications as Wayland and application input-method support allow. +- Keep recording and transcription entirely local. +- Preserve every successful transcript even when insertion fails. +- Keep GNOME Shell responsive and isolated from model, audio, and subprocess failures. +- Provide useful operation before the Shell extension is complete. +- Keep insertion backends replaceable behind one interface. +- Continue to offer a standalone CLI frontend to the shared transcription core. + +## 3. Non-goals and platform limits + +- Parley cannot guarantee insertion into literally every application. Password fields, lock/login screens, games, custom-rendered controls, unsupported XWayland clients, and applications without input-method support may reject insertion. +- Parley will not bypass the lock screen or cross user/session boundaries. +- Continuous live captioning and partial streaming text are not required for v1. +- Flatpak packaging is not a v1 requirement. A Flatpak would require a separate portal-oriented audio and filesystem design. +- Cloud transcription is out of scope. +- The Shell extension will not load the model, capture audio, or run transcription. + +## 4. Existing transcription pipeline + +The source prototype lives in the sibling `transcribe-parakeet` project. It currently: + +- records the default input with FFmpeg's `pulse` input at mono 16 kHz PCM; +- invokes a bundled `transcribe-cli` executable; +- uses `parakeet-unified-en-0.6b-Q8_0.gguf` (approximately 698 MiB); +- reads JSONL batch output; +- saves uniquely named text files; and +- uses terminal Enter/Escape handling to stop or cancel. + +The runtime binary currently links to CUDA 13 and Intel MKL libraries. Packaging must either declare those host dependencies or later supply a more portable build. + +## 5. Architecture + +```text +GNOME Shell extension toggle CLI / custom shortcut + | | + +------------- D-Bus ----------+ + | + v + Parley per-user daemon + - state machine + - audio recording + - transcription + - transcript history + - clipboard/notifications + - insertion policy + | + +--------------+----------------+ + | | | + v v v + IBus RemoteDesktop/ Clipboard + libei paste +``` + +### 5.1 Shared core + +Terminal-independent Python modules own: + +- recording lifecycle; +- transcription invocation and JSON parsing; +- transcript persistence; +- configuration; +- errors and cancellation. + +The existing CLI behavior becomes one frontend to this core. No reusable core function may read raw terminal keys or require a TTY. + +### 5.2 Per-user daemon + +A long-running Python/GObject process owns mutable state and serializes operations. It runs as a systemd user service and is activated on demand where practical. + +Only one recording/transcription operation may run at a time. Long-running work and subprocess waits must not block the GLib main loop. + +Provisional D-Bus identity: + +- bus name: `org.parley.Transcription1` +- object path: `/org/parley/Transcription1` +- interface: `org.parley.Transcription1` + +The name can be finalized before public packaging. + +Minimum methods: + +- `StartRecording()` +- `StopAndTranscribe()` +- `Toggle()` +- `Cancel()` +- `CopyLastTranscript()` +- `InsertLastTranscript()` +- `OpenTranscriptFolder()` + +Minimum read-only properties: + +- `State`: `idle`, `recording`, `transcribing`, `inserting`, or `error` +- `LastTranscript` +- `LastTranscriptPath` +- `LastError` +- `InsertionMode` +- `AutoInsert` + +Minimum signals: + +- `StateChanged(state)` +- `TranscriptReady(text, path)` +- `Error(code, message)` +- `InsertionFinished(backend, success, message)` + +Exact D-Bus signatures and error names will be versioned in an interface XML file during implementation. + +### 5.3 State machine + +Normal flow: + +```text +idle -> recording -> transcribing -> inserting -> idle +``` + +If automatic insertion is disabled or unavailable: + +```text +idle -> recording -> transcribing -> idle +``` + +Rules: + +- `Toggle` starts from `idle` and stops from `recording`. +- A toggle while transcribing or inserting must not start a second operation. +- `Cancel` during recording discards temporary audio. +- Cancellation during transcription should terminate the child process cleanly, escalating only when necessary. +- Errors transition through `error`, emit details, and leave the daemon recoverable without a restart. +- Temporary files are removed after completion or cancellation. +- A successful transcript is persisted before insertion is attempted. + +### 5.4 systemd user service + +The daemon is managed by a user unit. The unit must: + +- restart only on genuine crashes, with rate limiting; +- inherit the graphical session environment needed for D-Bus, PipeWire, IBus, and portals; +- stop child recording/transcription processes on shutdown; and +- log structured, actionable messages to the user journal without logging microphone audio. + +### 5.5 Thin clients + +A small command-line client calls the D-Bus service. It supports at least: + +```text +parleyctl toggle +parleyctl start +parleyctl stop +parleyctl cancel +parleyctl status +parleyctl copy +parleyctl insert +``` + +This client is used by the early GNOME custom keyboard shortcut and remains a fallback if the Shell extension is disabled by an upgrade. + +## 6. Recording and transcription + +### 6.1 Recording v1 + +Keep the proven FFmpeg path through `pipewire-pulse`: + +- input: `pulse`, device `default`; +- mono; +- 16 kHz; +- signed 16-bit PCM WAV; +- configurable maximum duration; +- controlled shutdown that produces a valid WAV; +- clear handling for missing/default-device changes. + +Direct PipeWire capture may be evaluated later but is not required merely for architectural purity. + +### 6.2 Transcription v1 + +Invoke the existing binary with the current model and options unless testing justifies a change: + +- quiet output; +- language `en`; +- no timestamps; +- 30 ms stream chunks; +- JSONL batch output. + +Binary and model paths are configuration, not hard-coded repository assumptions. Development may point at the sibling prototype's assets. Distribution should not duplicate the model unnecessarily. + +### 6.3 Persistence + +- Save every successful transcript before insertion. +- Use collision-safe names containing a local timestamp. +- Store user data under an XDG-appropriate data directory by default; support a configurable transcript directory. +- Write UTF-8 text with a final newline. +- Keep `LastTranscript` and its path available over D-Bus. +- A history UI is optional for v1, but the storage layout must not prevent one. + +### 6.4 Performance + +The initial implementation may launch `transcribe-cli` once per utterance. Reloading the approximately 700 MiB model is expected to be the largest latency cost. + +After correctness is established, evaluate a persistent worker or a library/server API that keeps the model loaded. The daemon and D-Bus contracts must not depend on one-shot subprocesses so this can change internally. + +## 7. Insertion strategy + +All insertion implementations conform to one daemon-side abstraction, conceptually: + +```python +insert_text(text, context) -> InsertionResult +``` + +An `InsertionResult` identifies the backend, success/failure, and a user-safe explanation. Backend selection is configurable and must degrade predictably. + +Regardless of selected backend, a successful transcript is saved and copied to the clipboard before automatic insertion is attempted. + +### 7.1 Primary candidate: IBus `commit_text` + +Implement a minimal IBus dictation engine. When it owns the focused input context, it commits the transcript as semantic text. + +Benefits: + +- follows the desktop input-method path rather than simulating hardware; +- correct Unicode and multiline handling; +- no keyboard-layout translation; +- fewer focus races than clipboard plus a synthetic paste shortcut; +- no remote-control permission prompt. + +The critical assumption is that the engine must be active for the focused input context. Phase 3 must test both activation models: + +1. **Persistent passthrough engine:** Parley's engine remains selected and returns `False` for ordinary key events so normal typing and shortcuts continue to applications. +2. **Temporary activation:** save the current engine, switch to Parley, commit text, and restore the previous engine. + +The persistent mode is preferred only if it does not interfere with typing, shortcuts, compose behavior, keyboard layouts, or other input methods. Temporary activation is acceptable only if switching and focus are reliable. If neither is robust, IBus will not be the default. + +When IBus exposes input purpose/content type, Parley must refuse automatic insertion into password and PIN fields and retain the result on the clipboard. + +### 7.2 Safe fallback: clipboard only + +Copy the transcript and notify the user to press Ctrl+V. This requires one user action but does not synthesize input and should always remain available. + +### 7.3 Compatibility fallback: RemoteDesktop portal plus libei + +Use the XDG Desktop Portal RemoteDesktop API to request keyboard control and libei/EIS to synthesize a paste shortcut after placing the transcript on the clipboard. + +Requirements: + +- request only the capability needed; +- support GNOME restore tokens and persist them securely when returned; +- handle missing, expired, denied, or revoked permission without losing text; +- clearly expose when a remote-control session is active; +- do not assume persistence is guaranteed on every portal backend or forever; +- recreate the session when required; and +- fall back to clipboard-only on any failure. + +This avoids root/uinput configuration and is preferred over `ydotool` for synthetic paste. It still has focus races, depends on the target accepting Ctrl+V, and cannot insert arbitrary Unicode directly—it pastes clipboard contents. Portal-based synthetic paste cannot reliably identify every sensitive field, so it must not claim universal password-field detection. + +### 7.4 Last resort: `ydotool` + +An opt-in debugging/compatibility backend may synthesize Ctrl+V through `ydotool`. It is never the default because it requires access to `uinput`, may involve a privileged helper or device rules, weakens the Wayland security boundary, and retains synthetic-input focus races. + +### 7.5 Backend policy + +The insertion bake-off determines the shipped default. Intended preference order is: + +1. IBus, if reliable; +2. clipboard only as the universally safe fallback; +3. RemoteDesktop/libei as an explicitly enabled compatibility mode; +4. `ydotool` as an explicitly enabled last resort. + +The UI may offer `ibus`, `clipboard`, `portal-paste`, and `ydotool-paste` modes. An `auto` mode may use IBus and degrade to clipboard, but it must not unexpectedly open a remote-control permission dialog or start synthetic input without prior user choice. + +## 8. GNOME integration + +### 8.1 Early global shortcut + +Before the extension exists, install/document a GNOME custom keyboard shortcut that runs `parleyctl toggle`. This provides an end-to-end workflow early: + +```text +hotkey -> record -> hotkey -> transcribe -> save/copy -> manual paste +``` + +Installation must not overwrite an existing conflicting shortcut without confirmation. The shortcut command remains supported after the extension ships. + +### 8.2 GNOME Shell extension + +A GNOME Shell 50 extension provides: + +- a top-bar microphone indicator; +- idle, recording, transcribing, inserting, and error presentation; +- click to start/stop; +- cancel action; +- copy and insert-last actions; +- open-transcript-folder action; +- automatic insertion toggle; +- insertion mode display/selection or a settings link; +- notifications; and +- an extension-managed global shortcut. + +The extension is a D-Bus client only. It must use documented GNOME Shell extension APIs wherever available, declare compatible Shell versions explicitly, and tolerate daemon unavailability. + +The extension shortcut replaces or disables the development custom shortcut to prevent duplicate activation, while `parleyctl` remains usable as a recovery path. + +## 9. Notifications and clipboard + +- Copy every successful transcript to the clipboard. +- Notify on transcription completion when no visible UI feedback is available. +- Notify when insertion falls back to manual paste. +- Surface concise errors while retaining details in the journal. +- Avoid repeated noisy notifications when the top-bar state already makes progress obvious. +- Use a registered desktop application identity for production notifications; `notify-send` is acceptable during the prototype phase. + +## 10. Configuration + +Configuration should use GSettings once schemas exist. Development may use a documented config file or environment variables until then. + +Planned settings: + +- microphone/device; +- model and runtime paths; +- language; +- transcript directory; +- maximum recording duration; +- automatic insertion enabled; +- insertion backend; +- notification and sound preferences; +- shortcut; +- optional punctuation/formatting behavior. + +Secrets and portal restore tokens must not be exposed over D-Bus or written to logs. Restore-token storage should follow portal guidance; if it is not itself sensitive, it must still be treated as user-private state. + +## 11. Security and privacy + +- Audio and text remain local unless the user explicitly copies or pastes them into another service/application. +- Temporary audio is deleted after transcription, cancellation, or error unless a future diagnostic option explicitly retains it. +- The recording state must always be visibly indicated. +- Do not log transcript contents by default. +- Refuse IBus auto-insertion into known password/PIN purposes. +- Never promise detection of all sensitive fields, especially with synthetic paste backends. +- Portal control is opt-in and revocable. +- `ydotool` support is opt-in and documents its elevated input capability. +- D-Bus methods are available only on the user's session bus; no system-wide service is needed. + +## 12. Delivery phases + +### Phase 1 — Refactor + +- Create the terminal-independent core. +- Implement start/stop/cancel recording operations. +- Extract transcription and persistence. +- Preserve the current CLI as a frontend. +- Add unit tests around state-independent parsing, paths, and errors. + +Exit criterion: CLI behavior works through the shared core without core TTY dependencies. + +### Phase 2 — Daemon, D-Bus, and early hotkey + +- Implement the GLib daemon and state machine. +- Add the systemd user unit. +- Add `parleyctl`. +- Save and copy successful transcripts. +- Add prototype notifications. +- Configure a GNOME custom shortcut for `parleyctl toggle`. + +Exit criterion: hotkey-driven recording/transcription reliably ends with a saved transcript on the clipboard. + +### Phase 3 — Insertion bake-off + +Build two deliberately small prototypes: + +- IBus `commit_text`, testing persistent passthrough and temporary activation; +- RemoteDesktop portal/libei paste, including permission denial and restore-token reuse. + +Test both against: + +- GNOME Text Editor or another GTK 4 editor; +- Firefox; +- Chromium; +- LibreOffice; +- GNOME Console/Terminal; +- VS Code or another Electron application; +- a Qt application; +- representative XWayland applications; +- password/PIN fields; and +- multiple keyboard layouts if available. + +For IBus passthrough, explicitly test ordinary typing, modifiers, application shortcuts, compose/dead keys, layout switching, and interaction with any real IM engines. For temporary activation, test caret/focus retention and engine restoration. + +For portal paste, test first authorization, restored sessions, logout/login, permission revocation, focus changes, multiline/Unicode clipboard content, and applications that remap or reject Ctrl+V. + +Exit criterion: record results, choose the default backend, and document unsupported applications. If IBus passthrough or activation is flaky, use clipboard as the safe default and retain portal paste as opt-in. + +### Phase 4 — GNOME Shell extension + +- Implement the top-bar indicator and menu. +- Synchronize state over D-Bus. +- Add notifications and the extension shortcut. +- Keep `parleyctl` and the custom-shortcut setup available as recovery tools. + +Exit criterion: all daemon states and failures are represented without blocking or destabilizing GNOME Shell. + +### Phase 5 — Production insertion backends + +- Turn successful prototypes into selectable backends. +- Implement deterministic fallback behavior. +- Add sensitive-field protections where information is available. +- Ensure every failed insertion leaves text saved and copied. + +### Phase 6 — UX, performance, and packaging + +- Evaluate a persistent loaded model. +- Add microphone selection and maximum duration UI. +- Add optional push-to-talk behavior where reliable key-release handling is available. +- Add punctuation/formatting commands. +- Package the daemon, D-Bus interface, systemd unit, desktop metadata, GSettings schema, IBus component, CLI, and extension. +- Document CUDA/MKL requirements or provide a portable runtime build. + +## 13. Testing requirements + +### Automated + +- state-machine transitions and invalid calls; +- cancellation and child-process cleanup; +- JSONL parsing, including malformed/no-result/error rows; +- collision-safe transcript paths and atomic persistence; +- D-Bus methods, properties, signals, and typed errors; +- insertion fallback selection; +- configuration migration/defaults; +- no transcript loss on insertion failure. + +Runtime and desktop dependencies should be abstracted so core tests do not require a microphone, GNOME Shell, IBus, or the 698 MiB model. + +### Manual integration + +- repeated start/stop/cancel cycles; +- microphone removal and unavailable input; +- daemon restart and Shell extension restart; +- GNOME logout/login; +- GPU/runtime failures; +- long recordings and maximum-duration cutoff; +- the Phase 3 application matrix; +- focus changes while transcribing; +- clipboard ownership after daemon exit/restart; +- extension disabled after a GNOME upgrade. + +## 14. Acceptance criteria for the first useful release + +- A keyboard shortcut starts and stops recording. +- Recording and transcription do not block GNOME Shell. +- Every successful transcript is saved and copied. +- The selected insertion backend inserts in the supported application matrix or reports a clear fallback. +- Cancellation and errors leave the service ready for another recording. +- A top-bar indicator accurately shows recording and processing states. +- The tool works without network access. +- Disabling or breaking the Shell extension does not remove CLI/hotkey access to the daemon. + +## 15. Open decisions + +These must be resolved by prototypes or packaging work rather than assumption: + +- whether IBus persistent passthrough or temporary activation is reliable enough to be default; +- exact compatibility of IBus and portal paste across the target application's real versions; +- GNOME portal restore-token lifetime and user experience across login sessions; +- final reverse-DNS application/D-Bus identifier; +- persistent model API/process design; +- portable versus host-specific CUDA/MKL distribution; +- direct PipeWire capture versus keeping FFmpeg/PulseAudio compatibility; +- whether push-to-talk can use stable public GNOME APIs on Shell 50. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1e2079a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "parley-dictation" +version = "0.1.0.dev0" +description = "Local-first dictation for GNOME" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "GPL-3.0-or-later" } +authors = [{ name = "Parley contributors" }] + +[project.scripts] +parley = "parley.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/parley/__init__.py b/src/parley/__init__.py new file mode 100644 index 0000000..8340b53 --- /dev/null +++ b/src/parley/__init__.py @@ -0,0 +1,3 @@ +"""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 new file mode 100644 index 0000000..735d532 --- /dev/null +++ b/src/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/src/parley/config.py b/src/parley/config.py new file mode 100644 index 0000000..88640b6 --- /dev/null +++ b/src/parley/config.py @@ -0,0 +1,71 @@ +"""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 + + @classmethod + def from_environment(cls) -> "Config": + prototype = _prototype_root() + ffmpeg = os.environ.get("PARLEY_FFMPEG") or shutil.which("ffmpeg") or "ffmpeg" + return cls( + ffmpeg=Path(ffmpeg).expanduser(), + transcribe_cli=Path( + os.environ.get("PARLEY_TRANSCRIBE_CLI", prototype / "bin/transcribe-cli") + ).expanduser(), + model=Path( + os.environ.get( + "PARLEY_MODEL", + prototype / "models/parakeet-unified-en-0.6b-Q8_0.gguf", + ) + ).expanduser(), + transcript_dir=Path( + os.environ.get( + "PARLEY_TRANSCRIPT_DIR", _default_data_home() / "parley/transcripts" + ) + ).expanduser(), + device=os.environ.get("PARLEY_DEVICE", "default"), + language=os.environ.get("PARLEY_LANGUAGE", "en"), + ) + + 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}") diff --git a/src/parley/errors.py b/src/parley/errors.py new file mode 100644 index 0000000..7862463 --- /dev/null +++ b/src/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/src/parley/persistence.py b/src/parley/persistence.py new file mode 100644 index 0000000..c5bab79 --- /dev/null +++ b/src/parley/persistence.py @@ -0,0 +1,51 @@ +"""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 new file mode 100644 index 0000000..5ee3ec2 --- /dev/null +++ b/src/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/src/parley/transcription.py b/src/parley/transcription.py new file mode 100644 index 0000000..eea0497 --- /dev/null +++ b/src/parley/transcription.py @@ -0,0 +1,59 @@ +"""Invoke transcribe-cli and parse its JSONL batch output.""" + +import json +from pathlib import Path +import subprocess + +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) -> None: + self.config = config + + 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: + result = subprocess.run(command, text=True, capture_output=True) + except OSError as error: + raise TranscriptionError(f"could not start transcription: {error}") from error + if result.returncode != 0: + detail = result.stderr.strip() or "transcription runtime failed" + raise TranscriptionError(detail) + return parse_jsonl(result.stdout) diff --git a/tests/test_persistence.py b/tests/test_persistence.py new file mode 100644 index 0000000..eecb378 --- /dev/null +++ b/tests/test_persistence.py @@ -0,0 +1,39 @@ +from datetime import datetime, timezone +from pathlib import Path +import tempfile +import unittest + +from parley.persistence import save_transcript + + +class PersistenceTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.directory = Path(self.temporary.name) / "transcripts" + self.now = datetime(2026, 7, 24, 12, 34, 56, 123456, timezone.utc) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_writes_utf8_with_one_final_newline(self) -> None: + path = save_transcript("héllo\n\n", self.directory, clock=lambda: self.now) + self.assertEqual(path.read_text(encoding="utf-8"), "héllo\n") + self.assertEqual( + path.name, "transcript_2026-07-24_12-34-56_123456+0000.txt" + ) + + def test_uses_counter_on_collision(self) -> None: + first = save_transcript("one", self.directory, clock=lambda: self.now) + second = save_transcript("two", self.directory, clock=lambda: self.now) + self.assertNotEqual(first, second) + self.assertEqual(second.stem[-2:], "_1") + self.assertEqual(first.read_text(), "one\n") + self.assertEqual(second.read_text(), "two\n") + + def test_leaves_no_temporary_file(self) -> None: + save_transcript("text", self.directory, clock=lambda: self.now) + self.assertFalse(any(path.suffix == ".tmp" for path in self.directory.iterdir())) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_recording.py b/tests/test_recording.py new file mode 100644 index 0000000..8183c0b --- /dev/null +++ b/tests/test_recording.py @@ -0,0 +1,65 @@ +from pathlib import Path +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from parley.config import Config +from parley.errors import RecordingError +from parley.recording import Recorder + + +class RecorderTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + root = Path(self.temporary.name) + self.output = root / "work" / "audio.wav" + self.config = Config( + ffmpeg=Path("/usr/bin/ffmpeg"), + transcribe_cli=root / "transcribe-cli", + model=root / "model.gguf", + transcript_dir=root / "transcripts", + ) + + def tearDown(self) -> None: + self.temporary.cleanup() + + @patch("parley.recording.subprocess.Popen") + def test_start_and_stop(self, popen: MagicMock) -> None: + process = popen.return_value + process.poll.return_value = None + process.communicate.return_value = (b"", b"") + process.returncode = 0 + recorder = Recorder(self.config) + + recorder.start(self.output) + self.output.write_bytes(b"RIFF") + result = recorder.stop() + + self.assertEqual(result, self.output) + self.assertFalse(recorder.is_recording) + process.communicate.assert_called_once_with(input=b"q\n", timeout=5.0) + command = popen.call_args.args[0] + self.assertIn("pulse", command) + self.assertIn("16000", command) + + @patch("parley.recording.subprocess.Popen") + def test_cancel_discards_output(self, popen: MagicMock) -> None: + process = popen.return_value + process.communicate.return_value = (b"", b"") + process.returncode = 0 + recorder = Recorder(self.config) + recorder.start(self.output) + self.output.write_bytes(b"partial") + + recorder.cancel() + + self.assertFalse(self.output.exists()) + self.assertFalse(recorder.is_recording) + + def test_stop_without_start_is_an_error(self) -> None: + with self.assertRaisesRegex(RecordingError, "no recording"): + Recorder(self.config).stop() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transcription.py b/tests/test_transcription.py new file mode 100644 index 0000000..916aac2 --- /dev/null +++ b/tests/test_transcription.py @@ -0,0 +1,29 @@ +import unittest + +from parley.errors import TranscriptionError +from parley.transcription import parse_jsonl + + +class ParseJsonlTests(unittest.TestCase): + def test_skips_header_and_malformed_lines(self) -> None: + output = '\n'.join([ + 'runtime diagnostic', + '{"type":"batch_header","count":1}', + '{"file":"recording.wav","text":"hello world"}', + ]) + self.assertEqual(parse_jsonl(output), "hello world") + + def test_reports_error_row(self) -> None: + with self.assertRaisesRegex(TranscriptionError, "decoder failed"): + parse_jsonl('{"file":"x.wav","error":"decoder failed","text":""}') + + def test_rejects_output_without_result(self) -> None: + with self.assertRaisesRegex(TranscriptionError, "no transcript"): + parse_jsonl('{"type":"batch_header"}\nnot json') + + def test_accepts_empty_transcript(self) -> None: + self.assertEqual(parse_jsonl('{"text":""}'), "") + + +if __name__ == "__main__": + unittest.main() |
