From 079310fcc46450303d6a0a5b5076e3a767ba1729 Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Sat, 7 Feb 2026 14:23:07 +0100 Subject: [PATCH] [feat](trx-frontend-http): add audio WebSocket endpoint with auto-PTT Add /audio WebSocket endpoint that streams RX Opus frames to the browser and accepts TX frames back. Browser UI includes RX/TX Audio toggle buttons with WebCodecs Opus decode/encode and a level indicator. TX audio automatically engages PTT on start and releases on stop or WebSocket disconnect. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Stanislaw Grams --- .../trx-frontend/trx-frontend-http/Cargo.toml | 1 + .../trx-frontend/trx-frontend-http/src/api.rs | 1 + .../trx-frontend-http/src/audio.rs | 118 ++++++++ .../trx-frontend/trx-frontend-http/src/lib.rs | 2 + .../trx-frontend-http/src/server.rs | 2 + .../trx-frontend-http/src/status.rs | 264 ++++++++++++++++++ 6 files changed, 388 insertions(+) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs diff --git a/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml b/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml index 0447dbe..dea7c32 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml +++ b/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml @@ -15,6 +15,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tracing = { workspace = true } actix-web = "=4.4.1" +actix-ws = "0.3" tokio-stream = { version = "0.1", features = ["sync"] } futures-util = "0.3" bytes = "1" diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index 9764934..622fdd6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -167,6 +167,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(set_mode) .service(set_ptt) .service(set_tx_limit) + .service(crate::server::audio::audio_ws) .service(favicon) .service(logo); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs new file mode 100644 index 0000000..caadcbc --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! Audio WebSocket endpoint for the HTTP frontend. +//! +//! Exposes `/audio` which upgrades to a WebSocket: +//! - First text message: JSON `AudioStreamInfo` +//! - Subsequent binary messages: raw Opus packets (RX) +//! - Browser sends binary messages: raw Opus packets (TX) + +use std::sync::{Mutex, OnceLock}; + +use actix_web::{get, web, Error, HttpRequest, HttpResponse}; +use actix_ws::Message; +use bytes::Bytes; +use tokio::sync::{broadcast, mpsc, watch}; +use tracing::warn; + +use trx_core::audio::AudioStreamInfo; + +struct AudioChannels { + rx: broadcast::Sender, + tx: mpsc::Sender, + info: watch::Receiver>, +} + +fn audio_channels() -> &'static Mutex> { + static CHANNELS: OnceLock>> = OnceLock::new(); + CHANNELS.get_or_init(|| Mutex::new(None)) +} + +/// Set the audio channels from the client main. Must be called before the +/// HTTP server starts if audio is enabled. +pub fn set_audio_channels( + rx: broadcast::Sender, + tx: mpsc::Sender, + info: watch::Receiver>, +) { + let mut ch = audio_channels() + .lock() + .expect("audio channels mutex poisoned"); + *ch = Some(AudioChannels { rx, tx, info }); +} + +#[get("/audio")] +pub async fn audio_ws(req: HttpRequest, body: web::Payload) -> Result { + let channels = audio_channels().lock().expect("audio channels mutex poisoned"); + let Some(ref ch) = *channels else { + return Ok(HttpResponse::NotFound().body("audio not enabled")); + }; + + let mut rx_sub = ch.rx.subscribe(); + let tx_sender = ch.tx.clone(); + let mut info_rx = ch.info.clone(); + drop(channels); + + let (response, mut session, mut msg_stream) = actix_ws::handle(&req, body)?; + + // Spawn the WebSocket handler + actix_web::rt::spawn(async move { + // Wait for stream info and send as first text message + let info = loop { + if let Some(info) = info_rx.borrow().clone() { + break info; + } + if info_rx.changed().await.is_err() { + let _ = session.close(None).await; + return; + } + }; + + let info_json = match serde_json::to_string(&info) { + Ok(j) => j, + Err(_) => { + let _ = session.close(None).await; + return; + } + }; + if session.text(info_json).await.is_err() { + return; + } + + // Spawn RX forwarding task + let mut rx_session = session.clone(); + let rx_handle = actix_web::rt::spawn(async move { + loop { + match rx_sub.recv().await { + Ok(packet) => { + if rx_session.binary(packet).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!("Audio WS: dropped {} RX frames", n); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }); + + // Read TX frames from browser + while let Some(Ok(msg)) = msg_stream.recv().await { + match msg { + Message::Binary(data) => { + let _ = tx_sender.send(data).await; + } + Message::Close(_) => break, + _ => {} + } + } + + rx_handle.abort(); + let _ = session.close(None).await; + }); + + Ok(response) +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/lib.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/lib.rs index 9a2a7ac..27b715c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/lib.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/lib.rs @@ -4,6 +4,8 @@ pub mod server; +pub use server::audio::set_audio_channels; + pub fn register_frontend() { use trx_frontend::FrontendSpawner; trx_frontend::register_frontend("http", server::HttpFrontend::spawn_frontend); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs index 7c5edab..174c6fb 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs @@ -4,6 +4,8 @@ #[path = "api.rs"] mod api; +#[path = "audio.rs"] +pub mod audio; #[path = "status.rs"] pub mod status; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs index d1133da..112cf8e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs @@ -123,6 +123,17 @@ const INDEX_HTML_TEMPLATE: &str = r##" Units depend on rig (percent/watts). +
+
Audio
+
+ + +
+
+
+ Off +
+
@@ -582,6 +593,259 @@ const INDEX_HTML_TEMPLATE: &str = r##" }); connect(); + + // --- Audio streaming --- + const rxAudioBtn = document.getElementById("rx-audio-btn"); + const txAudioBtn = document.getElementById("tx-audio-btn"); + const audioStatus = document.getElementById("audio-status"); + const audioLevelFill = document.getElementById("audio-level-fill"); + + let audioWs = null; + let audioCtx = null; + let rxActive = false; + let txActive = false; + let txStream = null; + let txProcessor = null; + let streamInfo = null; + + // Simple ring-buffer based audio player + let playBuffer = []; + let playNode = null; + + function startRxAudio() { + if (rxActive) { stopRxAudio(); return; } + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + audioWs = new WebSocket(`${proto}//${location.host}/audio`); + audioWs.binaryType = "arraybuffer"; + audioStatus.textContent = "Connecting…"; + + audioWs.onopen = () => { + audioStatus.textContent = "Connected"; + }; + + audioWs.onmessage = (evt) => { + if (typeof evt.data === "string") { + // Stream info JSON + try { + streamInfo = JSON.parse(evt.data); + audioCtx = new AudioContext({ sampleRate: streamInfo.sample_rate || 48000 }); + rxActive = true; + rxAudioBtn.style.borderColor = "#00d17f"; + rxAudioBtn.style.color = "#00d17f"; + audioStatus.textContent = "RX"; + } catch (e) { + console.error("Audio stream info parse error", e); + } + return; + } + + // Binary Opus data — decode via WebCodecs AudioDecoder if available + if (!audioCtx) return; + const data = new Uint8Array(evt.data); + + // Show level indicator from packet size (rough estimate) + const level = Math.min(100, (data.length / 120) * 100); + audioLevelFill.style.width = `${level}%`; + + // Use WebCodecs AudioDecoder for Opus if available + if (typeof AudioDecoder !== "undefined" && !window._opusDecoder) { + try { + const channels = (streamInfo && streamInfo.channels) || 1; + const sampleRate = (streamInfo && streamInfo.sample_rate) || 48000; + window._opusDecoder = new AudioDecoder({ + output: (frame) => { + const buf = new Float32Array(frame.numberOfFrames * frame.numberOfChannels); + frame.copyTo(buf, { planeIndex: 0 }); + const ab = audioCtx.createBuffer(frame.numberOfChannels, frame.numberOfFrames, frame.sampleRate); + for (let ch = 0; ch < frame.numberOfChannels; ch++) { + const chData = new Float32Array(frame.numberOfFrames); + for (let i = 0; i < frame.numberOfFrames; i++) { + chData[i] = buf[i * frame.numberOfChannels + ch]; + } + ab.copyToChannel(chData, ch); + } + const src = audioCtx.createBufferSource(); + src.buffer = ab; + src.connect(audioCtx.destination); + const now = audioCtx.currentTime; + const schedTime = Math.max(now, (window._nextPlayTime || now)); + src.start(schedTime); + window._nextPlayTime = schedTime + ab.duration; + frame.close(); + }, + error: (e) => { console.error("AudioDecoder error", e); } + }); + window._opusDecoder.configure({ + codec: "opus", + sampleRate: sampleRate, + numberOfChannels: channels, + }); + } catch (e) { + console.warn("WebCodecs AudioDecoder not available for Opus", e); + window._opusDecoder = null; + } + } + if (window._opusDecoder) { + try { + window._opusDecoder.decode(new EncodedAudioChunk({ + type: "key", + timestamp: performance.now() * 1000, + data: data, + })); + } catch (e) { + // Ignore decode errors for individual frames + } + } + }; + + audioWs.onclose = () => { + // If TX was active when WS closed, release PTT + if (txActive) { stopTxAudio(); } + rxActive = false; + rxAudioBtn.style.borderColor = ""; + rxAudioBtn.style.color = ""; + audioStatus.textContent = "Off"; + audioLevelFill.style.width = "0%"; + if (window._opusDecoder) { + try { window._opusDecoder.close(); } catch(e) {} + window._opusDecoder = null; + } + window._nextPlayTime = 0; + }; + + audioWs.onerror = () => { + audioStatus.textContent = "Error"; + }; + } + + function stopRxAudio() { + rxActive = false; + if (audioWs) { audioWs.close(); audioWs = null; } + if (audioCtx) { audioCtx.close(); audioCtx = null; } + if (window._opusDecoder) { + try { window._opusDecoder.close(); } catch(e) {} + window._opusDecoder = null; + } + window._nextPlayTime = 0; + rxAudioBtn.style.borderColor = ""; + rxAudioBtn.style.color = ""; + audioStatus.textContent = "Off"; + audioLevelFill.style.width = "0%"; + } + + function startTxAudio() { + if (txActive) { stopTxAudio(); return; } + if (!audioWs || audioWs.readyState !== WebSocket.OPEN) { + audioStatus.textContent = "RX first"; + return; + } + if (!streamInfo) return; + + navigator.mediaDevices.getUserMedia({ + audio: { sampleRate: streamInfo.sample_rate || 48000, channelCount: streamInfo.channels || 1 } + }).then(async (stream) => { + txStream = stream; + txActive = true; + txAudioBtn.style.borderColor = "#e55353"; + txAudioBtn.style.color = "#e55353"; + audioStatus.textContent = "RX+TX"; + + // Engage PTT automatically + try { await postPath("/set_ptt?ptt=true"); } catch (e) { console.error("PTT on failed", e); } + + // If WebCodecs AudioEncoder is available, use it for Opus encoding + if (typeof AudioEncoder !== "undefined") { + const sampleRate = streamInfo.sample_rate || 48000; + const channels = streamInfo.channels || 1; + const encoder = new AudioEncoder({ + output: (chunk) => { + const buf = new ArrayBuffer(chunk.byteLength); + chunk.copyTo(buf); + if (audioWs && audioWs.readyState === WebSocket.OPEN) { + audioWs.send(buf); + } + }, + error: (e) => { console.error("AudioEncoder error", e); } + }); + encoder.configure({ + codec: "opus", + sampleRate: sampleRate, + numberOfChannels: channels, + bitrate: (streamInfo.bitrate_bps || 24000), + }); + window._txEncoder = encoder; + + // Use AudioWorklet or ScriptProcessor to feed encoder + if (!audioCtx) audioCtx = new AudioContext({ sampleRate: sampleRate }); + const source = audioCtx.createMediaStreamSource(stream); + const frameDuration = (streamInfo.frame_duration_ms || 20) / 1000; + const frameSize = Math.floor(sampleRate * frameDuration); + // Use ScriptProcessorNode (deprecated but widely supported) + const processor = audioCtx.createScriptProcessor(frameSize, channels, channels); + let tsCounter = 0; + processor.onaudioprocess = (e) => { + if (!txActive || !window._txEncoder) return; + const input = e.inputBuffer; + const data = new Float32Array(input.length * input.numberOfChannels); + for (let ch = 0; ch < input.numberOfChannels; ch++) { + const chData = input.getChannelData(ch); + for (let i = 0; i < input.length; i++) { + data[i * input.numberOfChannels + ch] = chData[i]; + } + } + try { + const frame = new AudioData({ + format: "f32-planar", + sampleRate: input.sampleRate, + numberOfFrames: input.length, + numberOfChannels: input.numberOfChannels, + timestamp: tsCounter, + data: input.getChannelData(0), + }); + tsCounter += (input.length / input.sampleRate) * 1_000_000; + window._txEncoder.encode(frame); + frame.close(); + } catch (e) { + // Ignore + } + }; + source.connect(processor); + processor.connect(audioCtx.destination); + txProcessor = { source, processor }; + } + }).catch((err) => { + console.error("getUserMedia failed:", err); + audioStatus.textContent = "Mic denied"; + }); + } + + async function stopTxAudio() { + if (!txActive) return; + txActive = false; + + // Release PTT automatically + try { await postPath("/set_ptt?ptt=false"); } catch (e) { console.error("PTT off failed", e); } + + if (txStream) { + txStream.getTracks().forEach(t => t.stop()); + txStream = null; + } + if (txProcessor) { + txProcessor.source.disconnect(); + txProcessor.processor.disconnect(); + txProcessor = null; + } + if (window._txEncoder) { + try { window._txEncoder.close(); } catch(e) {} + window._txEncoder = null; + } + txAudioBtn.style.borderColor = ""; + txAudioBtn.style.color = ""; + audioStatus.textContent = rxActive ? "RX" : "Off"; + } + + rxAudioBtn.addEventListener("click", startRxAudio); + txAudioBtn.addEventListener("click", startTxAudio);