diff --git a/RECORDER.md b/RECORDER.md new file mode 100644 index 0000000..c4a48ab --- /dev/null +++ b/RECORDER.md @@ -0,0 +1,233 @@ +# Recorder Feature Plan + +## Overview + +This document describes the design and implementation plan for the recorder feature in trx-rs. The recorder captures the demodulated audio stream alongside associated metadata (FFT data, decoded signals, rig state) into a structured session on disk, with full playback and seeking support from within the application. + +## Requirements + +| ID | Description | +|----|-------------| +| REQ-REC-001 | When the user starts recording, the system shall record the currently demodulated audio stream. | +| REQ-REC-002 | When recording audio, the system shall store the recording in either FLAC or OPUS format. | +| REQ-REC-003 | While recording audio, the system shall automatically detect whether the recording should be stored in mono or stereo and select the appropriate format. | +| REQ-REC-004 | While recording is active, the system shall simultaneously record FFT data and all currently visible decoded elements, including APRS and FT8. | +| REQ-REC-005 | While recording metadata, the system shall store FFT data and decoded signal data in a structured data file format. | +| REQ-PLAY-001 | Where recorded sessions exist, the system shall allow playback of recordings from within the same application. | +| REQ-PLAY-002 | During playback, the system shall allow the user to seek to any position in the recording. | +| REQ-SYNC-001 | The system shall maintain time synchronization between the audio recording and the associated data file with at least one-second resolution. | +| REQ-REC-006 | While recording is active, the system shall allow the current cursor position to be stored. | + +--- + +## Architecture + +### New Crate: `trx-recorder` + +A new crate `src/trx-server/trx-recorder/` handles all record and playback logic. It is a library crate consumed by `trx-server`. + +``` +src/trx-server/ + trx-recorder/ + src/ + lib.rs # Public API: RecorderHandle, start_recorder_task() + session.rs # RecordingSession: file management, open/close/finalise + writer.rs # AudioWriter: PCM → FLAC or Opus encoder + data_file.rs # DataFileWriter: structured JSON Lines data track + index.rs # SeekIndex: time → byte-offset table for audio seeking + playback.rs # PlaybackEngine: file → PCM broadcast for clients + config.rs # RecorderConfig (serde, derives Default) +``` + +### Integration Points in `trx-server` + +| Source | What is tapped | How | +|--------|---------------|-----| +| `audio.rs` `pcm_tx` | Raw demodulated PCM frames | New `broadcast::Receiver>` subscriber | +| `audio.rs` spectrum broadcast | FFT/spectrum frames per `RigState.spectrum` | New subscriber on the spectrum watch channel | +| `audio.rs` decoded-message broadcast | FT8, WSPR, CW, APRS, FT4, FT2, APRS-HF frames | New `broadcast::Receiver` subscriber | +| `rig_task.rs` state watch | Frequency/mode/PTT changes | `watch::Receiver` clone | +| New `RecorderCommand` enum | Start, Stop, MarkCursor | Injected into the existing command pipeline | + +No existing code paths are modified beyond: +1. Passing a `RecorderHandle` (cheap `Arc` wrapper) into the audio and rig tasks. +2. Adding `RecorderCommand` variants to the command enum (alongside existing `SetFreq`, `SetMode`, etc.). +3. Adding a `[recorder]` section to `ServerConfig`. + +--- + +## Session Layout on Disk + +Each recording is a **session directory** named by UTC start time and opening rig state: + +``` +/ + 20260317T142301Z_14074000_USB/ + audio.flac # or audio.opus + data.jsonl # structured event log (see below) + index.bin # seek index: sorted table of (offset_ms u64, audio_byte u64) +``` + +`output_dir` defaults to `~/.local/share/trx-rs/recordings`. + +### Audio File (REQ-REC-001, REQ-REC-002, REQ-REC-003) + +- **Format**: FLAC (lossless, seekable) or Opus (compressed, needs external seek index). Configured via `recorder.format = "flac" | "opus"`. +- **Channel count**: determined at session open from `AudioConfig.channels`. If `channels == 1` → mono; if `channels == 2` → stereo. Written into the file header and recorded in the session's first data event. +- **Sample rate**: preserved from `AudioConfig.sample_rate` (default 48 000 Hz). +- **FLAC**: uses the `claxon` crate for writing, or a thin wrapper around `libflac` via `flac-sys`. Native seekable by frame. +- **Opus**: uses the `opus` crate (already a workspace dependency via `trx-ft8`). Seek index (`index.bin`) provides byte → time mapping. + +### Data File (REQ-REC-004, REQ-REC-005) + +`data.jsonl` — one JSON object per line, each with a required `offset_ms` field giving the millisecond offset from session start (satisfies REQ-SYNC-001 at ≥1 s resolution): + +```jsonl +{"offset_ms":0,"type":"session_start","freq_hz":14074000,"mode":"USB","channels":1,"sample_rate":48000,"format":"flac"} +{"offset_ms":1000,"type":"rig_state","freq_hz":14074000,"mode":"USB","ptt":false} +{"offset_ms":2000,"type":"fft","bins_db":[-90.1,-88.4,...]} +{"offset_ms":3412,"type":"ft8","snr_db":-12,"dt_s":0.3,"freq_hz":14074350,"message":"CQ W5XYZ EN34"} +{"offset_ms":4100,"type":"aprs","from":"W5XYZ-9","to":"APRS","path":"WIDE1-1","info":"!3351.00N/09722.00W-"} +{"offset_ms":5000,"type":"cursor","label":"interesting QSO"} +{"offset_ms":61000,"type":"session_end"} +``` + +Supported `type` values: + +| Type | Source | Cadence | +|------|--------|---------| +| `session_start` | recorder | once, at open | +| `session_end` | recorder | once, at close | +| `rig_state` | `watch::Receiver` change | on change | +| `fft` | spectrum data from `RigState.spectrum` | ≤1 Hz (configurable, default 1 s) | +| `ft8` / `ft4` / `ft2` / `wspr` | `DecodedMessage` broadcast | on decode event | +| `aprs` / `aprs_hf` | `DecodedMessage` broadcast | on decode event | +| `cw` | `DecodedMessage` broadcast | on decode event | +| `cursor` | `RecorderCommand::MarkCursor { label }` | on user request | + +### Seek Index (REQ-PLAY-002) + +`index.bin` is a flat binary table of 16-byte records written every `index_interval_ms` (default 1 000 ms): + +``` +[offset_ms: u64 LE][audio_byte_offset: u64 LE] ... +``` + +At playback seek time, binary search on `offset_ms` locates the nearest audio frame boundary, enabling random-access playback without full file scan. + +--- + +## RecorderConfig + +Added to `ServerConfig` under `[recorder]`: + +```toml +[recorder] +enabled = false +output_dir = "~/.local/share/trx-rs/recordings" +format = "flac" # "flac" | "opus" +opus_bitrate_bps = 32000 +fft_record_interval_ms = 1000 +index_interval_ms = 1000 +max_session_duration_s = 3600 # auto-split at 1 h; 0 = unlimited +``` + +--- + +## Command API + +New variants added to the existing command enum (handled in `rig_task.rs`): + +```rust +StartRecording, +StopRecording, +MarkCursor { label: String }, +``` + +These are exposed via: +- **HTTP frontend**: `POST /api/recorder/start`, `POST /api/recorder/stop`, `POST /api/recorder/cursor` +- **http-json frontend**: same commands as JSON messages + +--- + +## Playback Engine (REQ-PLAY-001, REQ-PLAY-002) + +`PlaybackEngine` opens a session directory and: + +1. Reads `audio.flac` or `audio.opus` and decodes PCM frames in real time. +2. Publishes decoded PCM frames onto a `broadcast::Sender>` — the **same channel type** as the live `pcm_tx`, so existing decoder tasks and audio-streaming clients receive playback data transparently. +3. Replays `data.jsonl` events on their original `offset_ms` timestamps, injecting them into the `DecodedMessage` broadcast so the HTTP frontend displays historic decodes during playback. +4. For seek: binary-searches `index.bin` to find the audio byte offset, then replays data events from the same point. + +The playback state machine has two modes, switched by a new `RigState.playback` field: + +```rust +pub enum PlaybackState { + Live, + Playing { session: String, offset_ms: u64 }, + Paused { session: String, offset_ms: u64 }, +} +``` + +While `PlaybackState` is not `Live`, the server suppresses live hardware polling and PCM capture to avoid mixing live and playback audio. + +--- + +## Time Synchronisation (REQ-SYNC-001) + +All timestamps use a single `session_epoch: std::time::Instant` captured at `StartRecording`. Every PCM frame, every data event, and every seek-index entry is stamped as `(Instant::now() - session_epoch).as_millis() as u64`. This gives sub-millisecond internal precision; the requirement of ≥1 s resolution is met by orders of magnitude. + +Wall-clock UTC is embedded only in `session_start` (`wall_clock_utc`) and in the session directory name, providing absolute time anchoring without depending on system clock monotonicity for sync. + +--- + +## Implementation Phases + +### Phase 1 — Audio recording (REQ-REC-001, REQ-REC-002, REQ-REC-003) + +1. Add `trx-recorder` crate skeleton; `RecorderConfig`; `RecorderHandle`. +2. Implement `AudioWriter` with FLAC output (lossless path first). +3. Subscribe `AudioWriter` to `pcm_tx` in `audio.rs`; open session on `StartRecording` command. +4. Auto-detect channel count from `AudioConfig.channels`. +5. Write Opus variant behind a feature flag; test both. + +### Phase 2 — Metadata recording (REQ-REC-004, REQ-REC-005, REQ-SYNC-001) + +1. Implement `DataFileWriter`; define full event schema. +2. Subscribe to `DecodedMessage` broadcast; fan-in all decoder types. +3. Subscribe to state watch; emit `rig_state` events on freq/mode change. +4. Emit `fft` events at configured interval from spectrum data. +5. Write `SeekIndex` in parallel with audio. + +### Phase 3 — Cursor (REQ-REC-006) + +1. Add `MarkCursor` command + HTTP endpoint. +2. Write `cursor` event to `data.jsonl` with current `offset_ms`. + +### Phase 4 — Playback (REQ-PLAY-001, REQ-PLAY-002) + +1. Implement `PlaybackEngine`; FLAC decode + PCM broadcast. +2. Add `PlaybackState` to `RigState`; suppress live capture during playback. +3. Implement seek via `index.bin` binary search. +4. Replay `data.jsonl` events; feed into `DecodedMessage` broadcast. +5. Expose start/stop/seek endpoints in `trx-frontend-http`. + +--- + +## Dependencies to Add + +| Crate | Use | Already present? | +|-------|-----|-----------------| +| `claxon` or `flac` | FLAC encode/decode | No | +| `opus` | Opus encode/decode | Yes (via trx-backend-soapysdr) | +| `serde_json` | data.jsonl serialisation | Yes | +| `tokio::fs` | async file I/O | Yes | + +--- + +## Open Questions + +1. **FLAC encoder choice**: `claxon` is a pure-Rust decoder but has no encoder; `libflac-sys` has encode+decode but requires a C toolchain. May need to add a pure-Rust FLAC encoder or use an intermediate WAV stage with post-encode. +2. **Playback isolation**: Should playback be exclusive (block all CAT commands) or concurrent? Initial design blocks CAT polling; revisit if users need to change frequency during playback. +3. **Session listing API**: The HTTP frontend needs an endpoint to enumerate sessions (`GET /api/recorder/sessions`). Schema TBD in Phase 4. +4. **Storage limits**: `max_session_duration_s` auto-splits sessions; a `max_total_size_gb` housekeeping option may be needed but is out of scope for initial phases.