summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYuval Adam <_@yuv.al>2026-07-24 14:15:05 +0200
committerYuval Adam <_@yuv.al>2026-07-24 14:15:05 +0200
commit8007c77e415130d9aab431e6b7199286bd3b7caf (patch)
tree5d6bda05738673518616e1af99c90c6ce4d612db
parent31fa21fdd1c6bccec73ccabdb49316c9813b0a9b (diff)
Preserve recordings with dated transcripts
-rw-r--r--README.md4
-rw-r--r--SPEC.md10
-rw-r--r--parley/cli.py2
-rw-r--r--parley/controller.py2
-rw-r--r--parley/persistence.py66
-rw-r--r--tests/test_controller.py1
-rw-r--r--tests/test_persistence.py42
7 files changed, 81 insertions, 46 deletions
diff --git a/README.md b/README.md
index ffcfd27..35cbba8 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/SPEC.md b/SPEC.md
index d79ef59..2252541 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -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__":