diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
index 3d5e1ef..8f7f673 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
@@ -253,7 +253,8 @@ function applyAuthRestrictions() {
"settings-clear-ft4-history",
"settings-clear-ft2-history",
"settings-clear-wspr-history",
- "settings-clear-sat-history"
+ "settings-clear-sat-history",
+ "header-rec-btn"
];
pluginToggleBtns.forEach(id => {
const btn = document.getElementById(id);
@@ -3305,6 +3306,10 @@ function render(update) {
for (const [key, entry] of Object.entries(_decoderToggles)) {
syncDecoderToggle(entry, !!update[key], entry.label);
}
+ // Recorder state sync.
+ if (typeof update.recorder_enabled === "boolean" && window._syncRecorderState) {
+ window._syncRecorderState(update.recorder_enabled);
+ }
if (window.updateSatLiveState) window.updateSatLiveState(update);
const cwAutoEl = document.getElementById("cw-auto");
const cwWpmEl = document.getElementById("cw-wpm");
@@ -8852,6 +8857,31 @@ if (headerAudioToggle) {
headerAudioToggle.addEventListener("click", startRxAudio);
}
+// ── Recorder button ────────────────────────────────────────────────────────
+const headerRecBtn = document.getElementById("header-rec-btn");
+let recorderActive = false;
+function syncRecorderBtn() {
+ if (!headerRecBtn) return;
+ headerRecBtn.classList.toggle("rec-active", recorderActive);
+}
+if (headerRecBtn) {
+ headerRecBtn.addEventListener("click", async () => {
+ try {
+ if (recorderActive) {
+ await postPath("/api/recorder/stop");
+ } else {
+ await postPath("/api/recorder/start");
+ }
+ } catch (e) {
+ console.error("Recorder toggle failed", e);
+ }
+ });
+}
+window._syncRecorderState = function (enabled) {
+ recorderActive = enabled;
+ syncRecorderBtn();
+};
+
const rxVolPct = document.getElementById("rx-vol-pct");
const txVolPct = document.getElementById("tx-vol-pct");
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
index a851f78..57b273a 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
@@ -65,6 +65,7 @@
+
@@ -1152,6 +1153,10 @@
@@ -1163,7 +1168,7 @@
Entry details
- | Start | End | Center freq | Primary bookmark | Extra channels | Label | Interleave (min) | |
+ | Start | End | Center freq | Primary bookmark | Extra channels | Label | Interleave (min) | REC | |
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/scheduler.js
index e38fbf9..5efdf66 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/scheduler.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/scheduler.js
@@ -502,6 +502,9 @@
if (ilEl) ilEl.value = entry && entry.interleave_min ? entry.interleave_min : "";
if (centerHzEl) centerHzEl.value = entry && entry.center_hz ? entry.center_hz : "";
+ const recordEl = document.getElementById("scheduler-ts-entry-record");
+ if (recordEl) recordEl.checked = !!(entry && entry.record);
+
pendingExtraBmIds = entry && Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : [];
renderExtraBmList();
@@ -550,6 +553,9 @@
}
if (!currentConfig.entries) currentConfig.entries = [];
+ const recordCb = document.getElementById("scheduler-ts-entry-record");
+ const entryRecord = recordCb ? recordCb.checked : false;
+
const entryData = {
start_min: startMin,
end_min: endMin,
@@ -558,6 +564,7 @@
interleave_min: entryInterleave,
center_hz: centerHz,
bookmark_ids: extraBmIds,
+ record: entryRecord,
};
if (schEntryEditIdx !== null) {
@@ -696,6 +703,7 @@
'
' + extraCell + ' | ' +
'
' + escHtml(entry.label || "") + ' | ' +
'
' + il + ' | ' +
+ '
' + (entry.record ? 'Yes' : '') + ' | ' +
'
' +
'' +
'' +
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
index 00c46dd..26cb5c4 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
@@ -1142,6 +1142,31 @@ small { color: var(--text-muted); }
color: #00d17f;
border-color: #00d17f;
}
+.header-bar-btn.header-rec-btn {
+ height: 2rem;
+ min-height: 0;
+ padding: 0 0.5rem;
+ font-size: 0.7rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ border: 1px solid var(--border-light);
+ border-radius: 6px;
+ background: var(--input-bg);
+ color: var(--text-muted);
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
+}
+.header-rec-btn.rec-active {
+ color: #ff3b30;
+ border-color: #ff3b30;
+ background: rgba(255, 59, 48, 0.12);
+ animation: rec-pulse 1.5s ease-in-out infinite;
+}
+@keyframes rec-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.6; }
+}
.header-rig-switch {
display: flex;
align-items: center;
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
index fffe885..447ad6b 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
@@ -7,6 +7,7 @@
mod assets;
mod bookmarks;
mod decoder;
+pub mod recorder;
mod rig;
mod sse;
mod vchan;
@@ -606,6 +607,11 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(crate::server::background_decode::delete_background_decode)
.service(crate::server::background_decode::get_background_decode_status)
.service(crate::server::audio::audio_ws)
+ // Recorder
+ .service(recorder::recorder_start)
+ .service(recorder::recorder_stop)
+ .service(recorder::recorder_status)
+ .service(recorder::recorder_files)
// Static assets
.service(assets::index)
.service(assets::map_index)
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/recorder.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/recorder.rs
new file mode 100644
index 0000000..94b19d1
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/recorder.rs
@@ -0,0 +1,206 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: BSD-2-Clause
+
+//! HTTP API endpoints for audio recording.
+
+use std::sync::Arc;
+
+use actix_web::{get, post, web, Error, HttpResponse};
+use tokio::sync::{mpsc, watch};
+
+use trx_core::{RigCommand, RigState};
+use trx_frontend::FrontendRuntimeContext;
+
+use super::send_command;
+use crate::server::recorder::RecorderManager;
+
+// ============================================================================
+// Query types
+// ============================================================================
+
+#[derive(serde::Deserialize)]
+pub struct RecorderStartQuery {
+ pub remote: Option,
+ pub vchan_id: Option,
+}
+
+#[derive(serde::Deserialize)]
+pub struct RecorderStopQuery {
+ pub remote: Option,
+ pub vchan_id: Option,
+}
+
+// ============================================================================
+// Endpoints
+// ============================================================================
+
+/// Start recording audio for the active rig (or a specific vchan).
+#[post("/api/recorder/start")]
+pub async fn recorder_start(
+ query: web::Query,
+ context: web::Data>,
+ recorder_mgr: web::Data>,
+ state: web::Data>,
+ rig_tx: web::Data>,
+) -> Result {
+ let rig_id = resolve_rig_id(&context, query.remote.as_deref());
+ let vchan_id = query.vchan_id.as_deref();
+
+ // Resolve the audio broadcast sender for this rig/vchan.
+ let (audio_tx, sample_rate, channels, frame_duration_ms) =
+ resolve_audio_source(&context, &rig_id, vchan_id)?;
+
+ let current_state = state.get_ref().borrow().clone();
+ let freq_hz = Some(current_state.status.freq.hz);
+ let mode = Some(trx_protocol::mode_to_string(¤t_state.status.mode).into_owned());
+
+ let params = crate::server::recorder::AudioParams {
+ sample_rate,
+ channels,
+ frame_duration_ms,
+ };
+
+ match recorder_mgr.start(
+ &rig_id,
+ vchan_id,
+ audio_tx,
+ params,
+ freq_hz,
+ mode.as_deref(),
+ ) {
+ Ok(info) => {
+ // Sync recorder_enabled state to the rig.
+ let _ = send_command(
+ &rig_tx,
+ RigCommand::SetRecorderEnabled(true),
+ query.remote.clone(),
+ )
+ .await;
+ Ok(HttpResponse::Ok().json(info))
+ }
+ Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": e }))),
+ }
+}
+
+/// Stop recording.
+#[post("/api/recorder/stop")]
+pub async fn recorder_stop(
+ query: web::Query,
+ context: web::Data>,
+ recorder_mgr: web::Data>,
+ rig_tx: web::Data>,
+) -> Result {
+ let rig_id = resolve_rig_id(&context, query.remote.as_deref());
+ let vchan_id = query.vchan_id.as_deref();
+
+ match recorder_mgr.stop(&rig_id, vchan_id).await {
+ Ok(result) => {
+ // Check if any recordings remain active for this rig.
+ let still_recording = recorder_mgr
+ .list_active()
+ .iter()
+ .any(|r| r.rig_id == rig_id);
+ if !still_recording {
+ let _ = send_command(
+ &rig_tx,
+ RigCommand::SetRecorderEnabled(false),
+ query.remote.clone(),
+ )
+ .await;
+ }
+ Ok(HttpResponse::Ok().json(result))
+ }
+ Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": e }))),
+ }
+}
+
+/// Get the status of active recordings.
+#[get("/api/recorder/status")]
+pub async fn recorder_status(
+ recorder_mgr: web::Data>,
+) -> Result {
+ let active = recorder_mgr.list_active();
+ Ok(HttpResponse::Ok().json(active))
+}
+
+/// List recorded files in the output directory.
+#[get("/api/recorder/files")]
+pub async fn recorder_files(
+ recorder_mgr: web::Data>,
+) -> Result {
+ let files = recorder_mgr.list_files();
+ Ok(HttpResponse::Ok().json(files))
+}
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+fn resolve_rig_id(context: &FrontendRuntimeContext, remote: Option<&str>) -> String {
+ if let Some(r) = remote {
+ return r.to_string();
+ }
+ context
+ .routing
+ .active_rig_id
+ .lock()
+ .ok()
+ .and_then(|v| v.clone())
+ .unwrap_or_else(|| "default".to_string())
+}
+
+fn resolve_audio_source(
+ context: &FrontendRuntimeContext,
+ rig_id: &str,
+ vchan_id: Option<&str>,
+) -> Result<(tokio::sync::broadcast::Sender, u32, u8, u16), Error> {
+ if let Some(vchan_uuid_str) = vchan_id {
+ // Virtual channel audio.
+ let uuid: uuid::Uuid = vchan_uuid_str
+ .parse()
+ .map_err(|_| actix_web::error::ErrorBadRequest("invalid vchan_id UUID"))?;
+ let audio = context
+ .vchan
+ .audio
+ .read()
+ .unwrap_or_else(|e| e.into_inner());
+ let tx = audio
+ .get(&uuid)
+ .cloned()
+ .ok_or_else(|| actix_web::error::ErrorNotFound("vchan audio not found"))?;
+ // Virtual channels use the same stream info as the main rig.
+ let (sr, ch, fd) = stream_info_for_rig(context, rig_id);
+ Ok((tx, sr, ch, fd))
+ } else {
+ // Main rig audio — try per-rig first, then default.
+ let tx = context
+ .rig_audio
+ .rx
+ .read()
+ .ok()
+ .and_then(|map| map.get(rig_id).cloned())
+ .or_else(|| context.audio.rx.clone())
+ .ok_or_else(|| actix_web::error::ErrorNotFound("no audio source for rig"))?;
+
+ let (sr, ch, fd) = stream_info_for_rig(context, rig_id);
+ Ok((tx, sr, ch, fd))
+ }
+}
+
+fn stream_info_for_rig(context: &FrontendRuntimeContext, rig_id: &str) -> (u32, u8, u16) {
+ // Try per-rig stream info first.
+ if let Some(rx) = context.rig_audio_info_rx(rig_id) {
+ if let Some(info) = rx.borrow().as_ref() {
+ return (info.sample_rate, info.channels, info.frame_duration_ms);
+ }
+ }
+ // Fall back to the default audio info.
+ if let Some(ref info_rx) = context.audio.info {
+ if let Some(info) = info_rx.borrow().as_ref() {
+ return (info.sample_rate, info.channels, info.frame_duration_ms);
+ }
+ }
+ // Absolute fallback.
+ (48000, 2, 20)
+}
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/recorder.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/recorder.rs
new file mode 100644
index 0000000..2c306fd
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/recorder.rs
@@ -0,0 +1,560 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: BSD-2-Clause
+
+//! Audio recorder — writes incoming Opus packets to OGG/Opus files.
+//!
+//! The recorder subscribes to the same `broadcast::Sender` channels
+//! that feed the WebSocket audio endpoint, capturing pre-encoded Opus packets
+//! without any re-encoding.
+
+use std::collections::HashMap;
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::sync::Mutex;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use bytes::Bytes;
+use serde::{Deserialize, Serialize};
+use tokio::sync::{broadcast, watch};
+use tracing::{error, info, warn};
+
+// ============================================================================
+// Configuration
+// ============================================================================
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default)]
+pub struct RecorderConfig {
+ /// Whether the recorder feature is available.
+ pub enabled: bool,
+ /// Directory for recorded files. Default: `$XDG_DATA_HOME/trx-rs/recordings/`.
+ pub output_dir: Option,
+ /// Maximum duration of a single recording in seconds. None = unlimited.
+ pub max_duration_secs: Option,
+}
+
+impl Default for RecorderConfig {
+ fn default() -> Self {
+ Self {
+ enabled: true,
+ output_dir: None,
+ max_duration_secs: None,
+ }
+ }
+}
+
+impl RecorderConfig {
+ pub fn resolve_output_dir(&self) -> PathBuf {
+ if let Some(ref dir) = self.output_dir {
+ PathBuf::from(dir)
+ } else {
+ dirs::data_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join("trx-rs")
+ .join("recordings")
+ }
+ }
+}
+
+// ============================================================================
+// Recording metadata
+// ============================================================================
+
+#[derive(Debug, Clone, Serialize)]
+pub struct RecordingInfo {
+ pub key: String,
+ pub rig_id: String,
+ pub vchan_id: Option,
+ pub path: String,
+ pub started_at: i64,
+ pub sample_rate: u32,
+ pub channels: u8,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct RecordingResult {
+ pub key: String,
+ pub path: String,
+ pub duration_secs: f64,
+ pub bytes_written: u64,
+}
+
+/// Audio stream parameters for a recording.
+#[derive(Debug, Clone, Copy)]
+pub struct AudioParams {
+ pub sample_rate: u32,
+ pub channels: u8,
+ pub frame_duration_ms: u16,
+}
+
+// ============================================================================
+// OGG/Opus writer
+// ============================================================================
+
+/// Minimal OGG/Opus file writer.
+///
+/// Writes the mandatory OpusHead and OpusTags pages, then wraps each incoming
+/// Opus packet in its own OGG page. This produces a valid, seekable OGG Opus
+/// stream without pulling in an external OGG crate.
+struct OggOpusWriter {
+ file: std::fs::File,
+ serial: u32,
+ page_seq: u32,
+ granule_pos: u64,
+ samples_per_frame: u64,
+ bytes_written: u64,
+}
+
+impl OggOpusWriter {
+ fn create(
+ path: &Path,
+ sample_rate: u32,
+ channels: u8,
+ frame_duration_ms: u16,
+ ) -> std::io::Result {
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let file = std::fs::File::create(path)?;
+
+ let serial = {
+ let ts = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos();
+ (ts & 0xFFFF_FFFF) as u32
+ };
+
+ let samples_per_frame = (sample_rate as u64) * (frame_duration_ms as u64) / 1000;
+
+ let mut writer = Self {
+ file,
+ serial,
+ page_seq: 0,
+ granule_pos: 0,
+ samples_per_frame,
+ bytes_written: 0,
+ };
+
+ writer.write_opus_head(sample_rate, channels)?;
+ writer.write_opus_tags()?;
+
+ Ok(writer)
+ }
+
+ /// Write the OpusHead identification header (OGG page, BOS).
+ fn write_opus_head(&mut self, sample_rate: u32, channels: u8) -> std::io::Result<()> {
+ let mut head = Vec::with_capacity(19);
+ head.extend_from_slice(b"OpusHead");
+ head.push(1); // version
+ head.push(channels);
+ head.extend_from_slice(&0u16.to_le_bytes()); // pre-skip
+ head.extend_from_slice(&sample_rate.to_le_bytes()); // input sample rate
+ head.extend_from_slice(&0u16.to_le_bytes()); // output gain
+ head.push(0); // channel mapping family
+
+ // BOS flag = 0x02
+ self.write_ogg_page(0x02, 0, &head)
+ }
+
+ /// Write the OpusTags comment header.
+ fn write_opus_tags(&mut self) -> std::io::Result<()> {
+ let vendor = b"trx-rs";
+ let mut tags = Vec::with_capacity(24);
+ tags.extend_from_slice(b"OpusTags");
+ tags.extend_from_slice(&(vendor.len() as u32).to_le_bytes());
+ tags.extend_from_slice(vendor);
+ tags.extend_from_slice(&0u32.to_le_bytes()); // no user comments
+
+ self.write_ogg_page(0x00, 0, &tags)
+ }
+
+ /// Write a single Opus audio packet as an OGG page.
+ fn write_audio_packet(&mut self, opus_data: &[u8]) -> std::io::Result<()> {
+ self.granule_pos += self.samples_per_frame;
+ self.write_ogg_page(0x00, self.granule_pos, opus_data)
+ }
+
+ /// Finalize the stream by writing an EOS page.
+ fn finalize(mut self) -> std::io::Result {
+ // Write an empty EOS page.
+ self.write_ogg_page(0x04, self.granule_pos, &[])?;
+ self.file.flush()?;
+ Ok(self.bytes_written)
+ }
+
+ /// Write a single OGG page.
+ fn write_ogg_page(
+ &mut self,
+ header_type: u8,
+ granule_position: u64,
+ data: &[u8],
+ ) -> std::io::Result<()> {
+ // OGG page header
+ let mut header = Vec::with_capacity(27 + 255);
+ header.extend_from_slice(b"OggS"); // capture pattern
+ header.push(0); // stream structure version
+ header.push(header_type); // header type flag
+ header.extend_from_slice(&granule_position.to_le_bytes()); // granule position
+ header.extend_from_slice(&self.serial.to_le_bytes()); // stream serial number
+ header.extend_from_slice(&self.page_seq.to_le_bytes()); // page sequence number
+ header.extend_from_slice(&0u32.to_le_bytes()); // CRC (placeholder)
+ self.page_seq += 1;
+
+ // Segment table: split data into 255-byte segments.
+ let num_segments = if data.is_empty() {
+ 1
+ } else {
+ data.len().div_ceil(255)
+ };
+ // A single packet needs lacing values: full 255-byte segments + final remainder.
+ let mut segments = Vec::with_capacity(num_segments);
+ let mut remaining = data.len();
+ while remaining >= 255 {
+ segments.push(255u8);
+ remaining -= 255;
+ }
+ segments.push(remaining as u8);
+
+ header.push(segments.len() as u8); // number of page segments
+ header.extend_from_slice(&segments);
+
+ // Compute CRC-32 over header + data
+ let crc = ogg_crc32(&header, data);
+ header[22..26].copy_from_slice(&crc.to_le_bytes());
+
+ self.file.write_all(&header)?;
+ self.file.write_all(data)?;
+ self.bytes_written += header.len() as u64 + data.len() as u64;
+ Ok(())
+ }
+}
+
+/// OGG CRC-32 (polynomial 0x04C11DB7, direct algorithm).
+fn ogg_crc32(header: &[u8], data: &[u8]) -> u32 {
+ static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new();
+ let table = TABLE.get_or_init(|| {
+ let mut t = [0u32; 256];
+ for i in 0..256u32 {
+ let mut r = i << 24;
+ for _ in 0..8 {
+ r = if r & 0x80000000 != 0 {
+ (r << 1) ^ 0x04C11DB7
+ } else {
+ r << 1
+ };
+ }
+ t[i as usize] = r;
+ }
+ t
+ });
+
+ let mut crc = 0u32;
+ for &b in header.iter().chain(data.iter()) {
+ crc = (crc << 8) ^ table[((crc >> 24) ^ (b as u32)) as usize];
+ }
+ crc
+}
+
+// ============================================================================
+// RecorderHandle
+// ============================================================================
+
+struct RecorderHandle {
+ stop_tx: watch::Sender,
+ handle: tokio::task::JoinHandle |