diff options
| author | Yuval Adam <_@yuv.al> | 2026-07-24 14:15:05 +0200 |
|---|---|---|
| committer | Yuval Adam <_@yuv.al> | 2026-07-24 14:15:05 +0200 |
| commit | 8007c77e415130d9aab431e6b7199286bd3b7caf (patch) | |
| tree | 5d6bda05738673518616e1af99c90c6ce4d612db | |
| parent | 31fa21fdd1c6bccec73ccabdb49316c9813b0a9b (diff) | |
Preserve recordings with dated transcripts
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | SPEC.md | 10 | ||||
| -rw-r--r-- | parley/cli.py | 2 | ||||
| -rw-r--r-- | parley/controller.py | 2 | ||||
| -rw-r--r-- | parley/persistence.py | 66 | ||||
| -rw-r--r-- | tests/test_controller.py | 1 | ||||
| -rw-r--r-- | tests/test_persistence.py | 42 |
7 files changed, 81 insertions, 46 deletions
@@ -4,7 +4,7 @@ Parley turns speech into text directly in your apps. Press a shortcut to record, press it again to transcribe, and keep typing. Everything runs locally. -Built for GNOME on Wayland, Parley provides system-wide push-to-dictate, direct text insertion, and native top-bar controls. Every successful transcript is saved and copied to the clipboard before insertion is attempted. +Built for GNOME on Wayland, Parley provides system-wide push-to-dictate, direct text insertion, and native top-bar controls. Every successful transcript and its recording are saved together, and the text is copied to the clipboard before insertion is attempted. > Parley targets up-to-date Arch Linux systems running GNOME on Wayland. Other platforms are currently unsupported. @@ -15,7 +15,7 @@ Built for GNOME on Wayland, Parley provides system-wide push-to-dictate, direct - clipboard fallback when insertion is unavailable - GNOME top-bar status and controls - global shortcut and command-line control -- transcript persistence under `~/.local/share/parley/transcripts/` +- dated transcript and recording history under `~/.local/share/parley/transcripts/` ## Usage @@ -51,7 +51,7 @@ The terminal-independent Python core now: - invokes `transcribe-cli` as a cancellable child process; - uses `parakeet-unified-en-0.6b-Q8_0.gguf` (approximately 698 MiB); - parses JSONL batch output; -- persists collision-safe UTF-8 transcript files atomically; and +- persists UTF-8 transcripts and their WAV recordings together; and - exposes the same core to the standalone terminal frontend and daemon. Development can use runtime assets from the sibling `transcribe-parakeet` checkout. Installed assets default to `~/.local/bin/transcribe-cli` and `~/.local/share/parley/models/`. The current runtime binary links to CUDA 13 and Intel MKL libraries. Packaging must declare those host dependencies or later supply a more portable build. @@ -212,8 +212,8 @@ Binary and model paths are configuration, not hard-coded repository assumptions. ### 6.3 Persistence -- Save every successful transcript before insertion. -- Use collision-safe names containing a local timestamp. +- Save every successful transcript and its WAV recording before insertion. +- Group recordings by local date and save each pair as `HH-MM-SS.txt` and `HH-MM-SS.wav`. - 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. @@ -354,7 +354,7 @@ Secrets and portal restore tokens must not be exposed over D-Bus or written to l ## 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. +- Audio from successful transcriptions is retained beside its transcript. Temporary audio is deleted after cancellation or error. - The recording state must always be visibly indicated. - Do not log transcript contents by default. - Portal control is opt-in and revocable. @@ -440,7 +440,7 @@ Exit criterion: all daemon states and failures are represented without blocking - 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; +- dated transcript and WAV pair persistence; - D-Bus methods, properties, signals, and typed errors; - insertion fallback selection; - configuration migration/defaults; diff --git a/parley/cli.py b/parley/cli.py index 668527a..97c6457 100644 --- a/parley/cli.py +++ b/parley/cli.py @@ -52,7 +52,7 @@ def run_once(config: Config) -> tuple[str, Path]: raise KeyboardInterrupt wav_path = recorder.stop() transcript = Transcriber(config).transcribe(wav_path, workspace) - path = save_transcript(transcript, config.transcript_dir) + path = save_transcript(transcript, wav_path, config.transcript_dir) return transcript, path diff --git a/parley/controller.py b/parley/controller.py index 567544f..b9889ff 100644 --- a/parley/controller.py +++ b/parley/controller.py @@ -111,7 +111,7 @@ class Controller: if token != self._generation: self.dispatch(lambda: self._discard_worker(workspace)) return - path = save_transcript(text, self.config.transcript_dir) + path = save_transcript(text, wav_path, self.config.transcript_dir) except Exception as error: self.dispatch( lambda error=error: self._worker_failed(token, workspace, error) diff --git a/parley/persistence.py b/parley/persistence.py index bd004d8..fbb98bc 100644 --- a/parley/persistence.py +++ b/parley/persistence.py @@ -1,8 +1,9 @@ -"""Collision-safe, atomic transcript persistence.""" +"""Atomic transcript and recording persistence.""" from datetime import datetime import os from pathlib import Path +import shutil import tempfile from typing import Callable @@ -10,40 +11,61 @@ 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) +def save_transcript( + text: str, + recording: Path, + directory: Path, + *, + clock: Clock | None = None, +) -> Path: + """Save a transcript and its WAV recording under a shared timestamp.""" now = (clock or (lambda: datetime.now().astimezone()))() - timestamp = now.strftime("%Y-%m-%d_%H-%M-%S_%f%z") + day_directory = directory / now.strftime("%Y-%m-%d") + day_directory.mkdir(parents=True, exist_ok=True) + stem = now.strftime("%H-%M-%S") + text_path = day_directory / f"{stem}.txt" + audio_path = day_directory / f"{stem}.wav" content = text.rstrip("\n") + "\n" - temporary_name: str | None = None + text_temporary: str | None = None + audio_temporary: str | None = None try: with tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", - dir=directory, + dir=day_directory, prefix=".transcript-", suffix=".tmp", delete=False, ) as output: - temporary_name = output.name + text_temporary = 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 + with recording.open("rb") as source, tempfile.NamedTemporaryFile( + mode="wb", + dir=day_directory, + prefix=".recording-", + suffix=".tmp", + delete=False, + ) as output: + audio_temporary = output.name + shutil.copyfileobj(source, output) + output.flush() + os.fsync(output.fileno()) + + # Publish the audio first and the transcript last. The transcript acts as + # the marker that the complete pair is available. + os.link(audio_temporary, audio_path) + try: + os.link(text_temporary, text_path) + except Exception: + audio_path.unlink(missing_ok=True) + raise + return text_path finally: - if temporary_name is not None: - Path(temporary_name).unlink(missing_ok=True) + if text_temporary is not None: + Path(text_temporary).unlink(missing_ok=True) + if audio_temporary is not None: + Path(audio_temporary).unlink(missing_ok=True) diff --git a/tests/test_controller.py b/tests/test_controller.py index b57cb69..17b6e1b 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -71,6 +71,7 @@ class ControllerTests(unittest.TestCase): text, path = received[0] self.assertEqual(text, "hello from controller") self.assertEqual(path.read_text(), "hello from controller\n") + self.assertEqual(path.with_suffix(".wav").read_bytes(), b"RIFF") finally: controller.close() diff --git a/tests/test_persistence.py b/tests/test_persistence.py index d8ae257..5e86aa3 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -9,30 +9,42 @@ 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" + root = Path(self.temporary.name) + self.directory = root / "transcripts" + self.recording = root / "recording.wav" + self.recording.write_bytes(b"RIFF audio") 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) + def save(self, text: str = "text") -> Path: + return save_transcript( + text, + self.recording, + self.directory, + clock=lambda: self.now, + ) + + def test_saves_text_and_audio_under_date_and_time(self) -> None: + path = self.save("héllo\n\n") + + self.assertEqual(path, self.directory / "2026-07-24" / "12-34-56.txt") 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") + self.assertEqual(path.with_suffix(".wav").read_bytes(), b"RIFF audio") + + def test_refuses_to_replace_an_existing_pair(self) -> None: + first = self.save("one") + + with self.assertRaises(FileExistsError): + self.save("two") - 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") + self.assertEqual(first.with_suffix(".wav").read_bytes(), b"RIFF audio") - 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()) - ) + def test_leaves_no_temporary_files(self) -> None: + path = self.save() + self.assertFalse(any(item.suffix == ".tmp" for item in path.parent.iterdir())) if __name__ == "__main__": |
