[feat](trx-rs): add WFM RDS and playback controls

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-02-27 23:57:46 +01:00
parent f77d0b0bb1
commit fffc4c6b90
21 changed files with 659 additions and 21 deletions
@@ -889,6 +889,9 @@ function render(update) {
if (update.filter && typeof update.filter.bandwidth_hz === "number") {
currentBandwidthHz = update.filter.bandwidth_hz;
syncBandwidthInput(currentBandwidthHz);
if (wfmDeemphasisEl && typeof update.filter.wfm_deemphasis_us === "number") {
wfmDeemphasisEl.value = String(update.filter.wfm_deemphasis_us);
}
}
if (update.status && update.status.freq && typeof update.status.freq.hz === "number") {
lastFreqHz = update.status.freq.hz;
@@ -904,7 +907,7 @@ function render(update) {
if (update.status && update.status.mode) {
const mode = normalizeMode(update.status.mode);
modeEl.value = mode ? mode.toUpperCase() : "";
updateWfmAudioModeControl();
updateWfmControls();
// When filter panel is active (SDR backend), update the BW slider range
// to match the new mode — but only if the server hasn't already sent a
// filter state that overrides it.
@@ -1475,6 +1478,7 @@ async function applyModeFromPicker() {
showHint("Mode missing", 1500);
return;
}
updateWfmControls();
modeEl.disabled = true;
showHint("Setting mode…");
try {
@@ -2052,7 +2056,8 @@ const txAudioBtn = document.getElementById("tx-audio-btn");
const audioStatus = document.getElementById("audio-status");
const audioLevelFill = document.getElementById("audio-level-fill");
const audioRow = document.getElementById("audio-row");
const wfmAudioModeWrap = document.getElementById("wfm-audio-mode-wrap");
const wfmControlsCol = document.getElementById("wfm-controls-col");
const wfmDeemphasisEl = document.getElementById("wfm-deemphasis");
const wfmAudioModeEl = document.getElementById("wfm-audio-mode");
// Hide audio row if audio is not configured on the server
@@ -2080,6 +2085,8 @@ let txTimeoutTimer = null;
let txTimeoutRemaining = 0;
let txTimeoutInterval = null;
const hasWebCodecs = typeof AudioDecoder !== "undefined" && typeof AudioEncoder !== "undefined";
const MAX_RX_BUFFER_SECS = 0.25;
const TARGET_RX_BUFFER_SECS = 0.04;
if (wfmAudioModeEl) {
wfmAudioModeEl.value = loadSetting("wfmAudioMode", "stereo");
@@ -2087,12 +2094,16 @@ if (wfmAudioModeEl) {
saveSetting("wfmAudioMode", wfmAudioModeEl.value);
});
}
if (wfmDeemphasisEl) {
wfmDeemphasisEl.addEventListener("change", () => {
postPath(`/set_wfm_deemphasis?us=${encodeURIComponent(wfmDeemphasisEl.value)}`).catch(() => {});
});
}
function updateWfmAudioModeControl() {
if (!wfmAudioModeWrap) return;
function updateWfmControls() {
if (!wfmControlsCol) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
const channels = (streamInfo && streamInfo.channels) || 1;
wfmAudioModeWrap.style.display = mode === "WFM" && channels >= 2 ? "" : "none";
wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
}
// Show compatibility warning for non-Chromium browsers
@@ -2148,8 +2159,9 @@ function startRxAudio() {
// Stream info JSON
try {
streamInfo = JSON.parse(evt.data);
updateWfmAudioModeControl();
updateWfmControls();
audioCtx = new AudioContext({ sampleRate: streamInfo.sample_rate || 48000 });
audioCtx.resume().catch(() => {});
rxGainNode = audioCtx.createGain();
rxGainNode.gain.value = rxVolSlider.value / 100;
rxGainNode.connect(audioCtx.destination);
@@ -2214,6 +2226,9 @@ function startRxAudio() {
src.buffer = ab;
src.connect(rxGainNode);
const now = audioCtx.currentTime;
if (nextPlayTime && nextPlayTime - now > MAX_RX_BUFFER_SECS) {
nextPlayTime = now + TARGET_RX_BUFFER_SECS;
}
const schedTime = Math.max(now, (nextPlayTime || now));
src.start(schedTime);
nextPlayTime = schedTime + ab.duration;
@@ -2249,7 +2264,7 @@ function startRxAudio() {
if (txActive) { stopTxAudio(); }
rxActive = false;
streamInfo = null;
updateWfmAudioModeControl();
updateWfmControls();
rxAudioBtn.style.borderColor = "";
rxAudioBtn.style.color = "";
audioStatus.textContent = "Off";
@@ -2272,7 +2287,7 @@ function stopRxAudio() {
streamInfo = null;
if (audioWs) { audioWs.close(); audioWs = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; }
updateWfmAudioModeControl();
updateWfmControls();
rxGainNode = null;
if (opusDecoder) {
try { opusDecoder.close(); } catch(e) {}
@@ -117,6 +117,23 @@
<button id="jog-up" type="button" class="jog-btn">+</button>
</div>
</div>
<div class="controls-col controls-col-wfm label-below-col" id="wfm-controls-col" style="display:none;">
<div class="inline wfm-controls-inline">
<label class="wfm-control">Deemphasis
<select id="wfm-deemphasis" class="status-input">
<option value="50">50 uS</option>
<option value="75">75 uS</option>
</select>
</label>
<label class="wfm-control">Audio
<select id="wfm-audio-mode" class="status-input">
<option value="stereo">Stereo</option>
<option value="mono">Mono</option>
</select>
</label>
</div>
<div class="label"><span>WFM</span></div>
</div>
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
<div class="label"><span>Transmit / Power</span></div>
<div class="btn-grid">
@@ -168,7 +185,6 @@
<button id="tx-audio-btn" type="button">TX Audio</button>
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
<label class="vol-label" id="wfm-audio-mode-wrap" style="display:none;">WFM<select id="wfm-audio-mode" class="status-input"><option value="stereo">Stereo</option><option value="mono">Mono</option></select></label>
<div id="audio-level">
<div id="audio-level-fill"></div>
</div>
@@ -79,7 +79,7 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
#freq { font-family: 'DSEG14 Classic', monospace; font-size: 2rem; padding: 0.5rem 0.6rem; letter-spacing: 0.05em; text-align: center; }
.controls-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-columns: minmax(0, 1fr) auto auto minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
@@ -114,6 +114,26 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
width: auto;
align-items: center;
}
.controls-col-wfm.label-below-col .label {
justify-content: flex-start;
}
.wfm-controls-inline {
gap: 0.6rem;
justify-content: flex-start;
}
.wfm-control {
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.85rem;
white-space: nowrap;
}
.wfm-control .status-input {
min-width: 4.6rem;
width: auto;
font-size: 0.9rem;
}
.controls-col-center::after {
content: "";
display: block;
@@ -583,9 +603,11 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.header-rig-switch { width: 100%; justify-content: flex-end; }
.header-rig-switch select { min-width: 6.5rem; }
.controls-row { grid-template-columns: 1fr auto; }
.controls-col-wfm { grid-column: 1 / -1; }
.controls-col-power { grid-column: 1 / -1; }
.controls-col.label-below-col .inline,
.controls-col.label-below-col .btn-grid { margin-top: 0; }
.wfm-controls-inline { flex-wrap: wrap; }
.ft8-controls { flex-wrap: wrap; }
#ft8-decode-toggle-btn, #wspr-decode-toggle-btn { white-space: nowrap; }
.jog-container { flex-wrap: wrap; }
@@ -468,6 +468,19 @@ pub async fn set_fir_taps(
send_command(&rig_tx, RigCommand::SetFirTaps(query.taps)).await
}
#[derive(serde::Deserialize)]
pub struct WfmDeemphasisQuery {
pub us: u32,
}
#[post("/set_wfm_deemphasis")]
pub async fn set_wfm_deemphasis(
query: web::Query<WfmDeemphasisQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetWfmDeemphasis(query.us)).await
}
#[post("/toggle_aprs_decode")]
pub async fn toggle_aprs_decode(
state: web::Data<watch::Receiver<RigState>>,
@@ -679,6 +692,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(set_tx_limit)
.service(set_bandwidth)
.service(set_fir_taps)
.service(set_wfm_deemphasis)
.service(toggle_aprs_decode)
.service(toggle_cw_decode)
.service(set_cw_auto)