[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:
Generated
+8
@@ -2432,6 +2432,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
|
"trx-rds",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2555,6 +2556,13 @@ dependencies = [
|
|||||||
"trx-core",
|
"trx-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trx-rds"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"trx-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-server"
|
name = "trx-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ members = [
|
|||||||
"src/decoders/trx-cw",
|
"src/decoders/trx-cw",
|
||||||
"src/decoders/trx-decode-log",
|
"src/decoders/trx-decode-log",
|
||||||
"src/decoders/trx-ft8",
|
"src/decoders/trx-ft8",
|
||||||
|
"src/decoders/trx-rds",
|
||||||
"src/decoders/trx-wspr",
|
"src/decoders/trx-wspr",
|
||||||
"src/trx-core",
|
"src/trx-core",
|
||||||
"src/trx-protocol",
|
"src/trx-protocol",
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "trx-rds"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
trx-core = { path = "../../trx-core" }
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
use std::f32::consts::TAU;
|
||||||
|
|
||||||
|
use trx_core::rig::state::RdsData;
|
||||||
|
|
||||||
|
const RDS_SUBCARRIER_HZ: f32 = 57_000.0;
|
||||||
|
const RDS_SYMBOL_RATE: f32 = 1_187.5;
|
||||||
|
const RDS_POLY: u16 = 0x1B9;
|
||||||
|
const SEARCH_REG_MASK: u32 = (1 << 26) - 1;
|
||||||
|
const PHASE_CANDIDATES: usize = 8;
|
||||||
|
|
||||||
|
const OFFSET_A: u16 = 0x0FC;
|
||||||
|
const OFFSET_B: u16 = 0x198;
|
||||||
|
const OFFSET_C: u16 = 0x168;
|
||||||
|
const OFFSET_CP: u16 = 0x350;
|
||||||
|
const OFFSET_D: u16 = 0x1B4;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct OnePoleLowPass {
|
||||||
|
alpha: f32,
|
||||||
|
y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OnePoleLowPass {
|
||||||
|
fn new(sample_rate: f32, cutoff_hz: f32) -> Self {
|
||||||
|
let sr = sample_rate.max(1.0);
|
||||||
|
let cutoff = cutoff_hz.clamp(1.0, sr * 0.49);
|
||||||
|
let dt = 1.0 / sr;
|
||||||
|
let rc = 1.0 / (2.0 * std::f32::consts::PI * cutoff);
|
||||||
|
let alpha = dt / (rc + dt);
|
||||||
|
Self { alpha, y: 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process(&mut self, x: f32) -> f32 {
|
||||||
|
self.y += self.alpha * (x - self.y);
|
||||||
|
self.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum BlockKind {
|
||||||
|
A,
|
||||||
|
B,
|
||||||
|
C,
|
||||||
|
CPrime,
|
||||||
|
D,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ExpectBlock {
|
||||||
|
B,
|
||||||
|
C,
|
||||||
|
D,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct Candidate {
|
||||||
|
clock_phase: f32,
|
||||||
|
clock_inc: f32,
|
||||||
|
sym_i_acc: f32,
|
||||||
|
sym_q_acc: f32,
|
||||||
|
sym_count: u16,
|
||||||
|
prev_sym: Option<(f32, f32)>,
|
||||||
|
search_reg: u32,
|
||||||
|
search_bits: u8,
|
||||||
|
locked: bool,
|
||||||
|
expect: ExpectBlock,
|
||||||
|
block_reg: u32,
|
||||||
|
block_bits: u8,
|
||||||
|
block_a: u16,
|
||||||
|
block_b: u16,
|
||||||
|
score: u32,
|
||||||
|
state: RdsData,
|
||||||
|
ps_bytes: [u8; 8],
|
||||||
|
ps_seen: [bool; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Candidate {
|
||||||
|
fn new(sample_rate: f32, phase_offset: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
clock_phase: phase_offset,
|
||||||
|
clock_inc: RDS_SYMBOL_RATE / sample_rate.max(1.0),
|
||||||
|
sym_i_acc: 0.0,
|
||||||
|
sym_q_acc: 0.0,
|
||||||
|
sym_count: 0,
|
||||||
|
prev_sym: None,
|
||||||
|
search_reg: 0,
|
||||||
|
search_bits: 0,
|
||||||
|
locked: false,
|
||||||
|
expect: ExpectBlock::B,
|
||||||
|
block_reg: 0,
|
||||||
|
block_bits: 0,
|
||||||
|
block_a: 0,
|
||||||
|
block_b: 0,
|
||||||
|
score: 0,
|
||||||
|
state: RdsData::default(),
|
||||||
|
ps_bytes: [b' '; 8],
|
||||||
|
ps_seen: [false; 4],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_sample(&mut self, i: f32, q: f32) -> Option<RdsData> {
|
||||||
|
self.sym_i_acc += i;
|
||||||
|
self.sym_q_acc += q;
|
||||||
|
self.sym_count = self.sym_count.saturating_add(1);
|
||||||
|
self.clock_phase += self.clock_inc;
|
||||||
|
if self.clock_phase < 1.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.clock_phase -= 1.0;
|
||||||
|
|
||||||
|
let count = f32::from(self.sym_count.max(1));
|
||||||
|
let symbol = (self.sym_i_acc / count, self.sym_q_acc / count);
|
||||||
|
self.sym_i_acc = 0.0;
|
||||||
|
self.sym_q_acc = 0.0;
|
||||||
|
self.sym_count = 0;
|
||||||
|
|
||||||
|
let update = if let Some((prev_i, prev_q)) = self.prev_sym {
|
||||||
|
let dot = symbol.0 * prev_i + symbol.1 * prev_q;
|
||||||
|
self.push_bit((dot < 0.0) as u8)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
self.prev_sym = Some(symbol);
|
||||||
|
update
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_bit(&mut self, bit: u8) -> Option<RdsData> {
|
||||||
|
if self.locked {
|
||||||
|
self.block_reg = ((self.block_reg << 1) | u32::from(bit)) & SEARCH_REG_MASK;
|
||||||
|
self.block_bits = self.block_bits.saturating_add(1);
|
||||||
|
if self.block_bits < 26 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let word = self.block_reg;
|
||||||
|
self.block_reg = 0;
|
||||||
|
self.block_bits = 0;
|
||||||
|
return self.consume_locked_block(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.search_reg = ((self.search_reg << 1) | u32::from(bit)) & SEARCH_REG_MASK;
|
||||||
|
self.search_bits = self.search_bits.saturating_add(1).min(26);
|
||||||
|
if self.search_bits < 26 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, kind) = decode_block(self.search_reg)?;
|
||||||
|
if kind != BlockKind::A {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.locked = true;
|
||||||
|
self.expect = ExpectBlock::B;
|
||||||
|
self.block_reg = 0;
|
||||||
|
self.block_bits = 0;
|
||||||
|
self.block_a = data;
|
||||||
|
self.state.pi = Some(data);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn consume_locked_block(&mut self, word: u32) -> Option<RdsData> {
|
||||||
|
let expected = self.expect;
|
||||||
|
let Some((data, kind)) = decode_block(word) else {
|
||||||
|
self.drop_lock(word);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
match (expected, kind) {
|
||||||
|
(ExpectBlock::B, BlockKind::B) => {
|
||||||
|
self.block_b = data;
|
||||||
|
self.expect = ExpectBlock::C;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
(ExpectBlock::C, BlockKind::C | BlockKind::CPrime) => {
|
||||||
|
self.expect = ExpectBlock::D;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
(ExpectBlock::D, BlockKind::D) => {
|
||||||
|
self.locked = false;
|
||||||
|
self.search_bits = 0;
|
||||||
|
self.search_reg = 0;
|
||||||
|
self.process_group(self.block_a, self.block_b, data)
|
||||||
|
}
|
||||||
|
(_, BlockKind::A) => {
|
||||||
|
self.locked = true;
|
||||||
|
self.expect = ExpectBlock::B;
|
||||||
|
self.block_reg = 0;
|
||||||
|
self.block_bits = 0;
|
||||||
|
self.block_a = data;
|
||||||
|
self.state.pi = Some(data);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.drop_lock(word);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop_lock(&mut self, word: u32) {
|
||||||
|
self.locked = false;
|
||||||
|
self.expect = ExpectBlock::B;
|
||||||
|
self.block_reg = 0;
|
||||||
|
self.block_bits = 0;
|
||||||
|
self.search_reg = word;
|
||||||
|
self.search_bits = 26;
|
||||||
|
if let Some((data, kind)) = decode_block(word) {
|
||||||
|
if kind == BlockKind::A {
|
||||||
|
self.locked = true;
|
||||||
|
self.search_reg = 0;
|
||||||
|
self.search_bits = 0;
|
||||||
|
self.block_a = data;
|
||||||
|
self.state.pi = Some(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_group(&mut self, block_a: u16, block_b: u16, block_d: u16) -> Option<RdsData> {
|
||||||
|
let mut changed = false;
|
||||||
|
if self.state.pi != Some(block_a) {
|
||||||
|
self.state.pi = Some(block_a);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pty = ((block_b >> 5) & 0x1f) as u8;
|
||||||
|
if self.state.pty != Some(pty) {
|
||||||
|
self.state.pty = Some(pty);
|
||||||
|
self.state.pty_name = Some(pty_name(pty).to_string());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let group_type = ((block_b >> 12) & 0x0f) as u8;
|
||||||
|
if group_type == 0 {
|
||||||
|
let segment = usize::from((block_b & 0x0003) as u8);
|
||||||
|
let [b0, b1] = block_d.to_be_bytes();
|
||||||
|
self.ps_bytes[segment * 2] = sanitize_text_byte(b0);
|
||||||
|
self.ps_bytes[segment * 2 + 1] = sanitize_text_byte(b1);
|
||||||
|
self.ps_seen[segment] = true;
|
||||||
|
if self.ps_seen.iter().all(|seen| *seen) {
|
||||||
|
let ps = String::from_utf8_lossy(&self.ps_bytes).trim_end().to_string();
|
||||||
|
if !ps.is_empty() && self.state.program_service.as_deref() != Some(ps.as_str()) {
|
||||||
|
self.state.program_service = Some(ps);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.score = self.score.saturating_add(1);
|
||||||
|
changed.then(|| self.state.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RdsDecoder {
|
||||||
|
carrier_phase: f32,
|
||||||
|
carrier_inc: f32,
|
||||||
|
i_lp: OnePoleLowPass,
|
||||||
|
q_lp: OnePoleLowPass,
|
||||||
|
candidates: Vec<Candidate>,
|
||||||
|
best_score: u32,
|
||||||
|
best_state: Option<RdsData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RdsDecoder {
|
||||||
|
pub fn new(sample_rate: u32) -> Self {
|
||||||
|
let sample_rate_f = sample_rate.max(1) as f32;
|
||||||
|
let mut candidates = Vec::with_capacity(PHASE_CANDIDATES);
|
||||||
|
for idx in 0..PHASE_CANDIDATES {
|
||||||
|
candidates.push(Candidate::new(
|
||||||
|
sample_rate_f,
|
||||||
|
idx as f32 / PHASE_CANDIDATES as f32,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
carrier_phase: 0.0,
|
||||||
|
carrier_inc: TAU * RDS_SUBCARRIER_HZ / sample_rate_f,
|
||||||
|
i_lp: OnePoleLowPass::new(sample_rate_f, 3_000.0),
|
||||||
|
q_lp: OnePoleLowPass::new(sample_rate_f, 3_000.0),
|
||||||
|
candidates,
|
||||||
|
best_score: 0,
|
||||||
|
best_state: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_samples(&mut self, samples: &[f32]) -> Option<&RdsData> {
|
||||||
|
for &sample in samples {
|
||||||
|
let (sin_p, cos_p) = self.carrier_phase.sin_cos();
|
||||||
|
self.carrier_phase = (self.carrier_phase + self.carrier_inc).rem_euclid(TAU);
|
||||||
|
let mixed_i = self.i_lp.process(sample * cos_p * 2.0);
|
||||||
|
let mixed_q = self.q_lp.process(sample * -sin_p * 2.0);
|
||||||
|
|
||||||
|
for candidate in &mut self.candidates {
|
||||||
|
if let Some(update) = candidate.process_sample(mixed_i, mixed_q) {
|
||||||
|
if candidate.score >= self.best_score {
|
||||||
|
self.best_score = candidate.score;
|
||||||
|
self.best_state = Some(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.best_state.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> Option<RdsData> {
|
||||||
|
self.best_state.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_text_byte(byte: u8) -> u8 {
|
||||||
|
if (0x20..=0x7e).contains(&byte) {
|
||||||
|
byte
|
||||||
|
} else {
|
||||||
|
b' '
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_block(word: u32) -> Option<(u16, BlockKind)> {
|
||||||
|
let data = (word >> 10) as u16;
|
||||||
|
let check = (word & 0x03ff) as u16;
|
||||||
|
let syndrome = crc10(data) ^ check;
|
||||||
|
let kind = match syndrome {
|
||||||
|
OFFSET_A => BlockKind::A,
|
||||||
|
OFFSET_B => BlockKind::B,
|
||||||
|
OFFSET_C => BlockKind::C,
|
||||||
|
OFFSET_CP => BlockKind::CPrime,
|
||||||
|
OFFSET_D => BlockKind::D,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((data, kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn crc10(data: u16) -> u16 {
|
||||||
|
let mut reg = u32::from(data) << 10;
|
||||||
|
let poly = u32::from(RDS_POLY);
|
||||||
|
for shift in (10..=25).rev() {
|
||||||
|
if (reg & (1 << shift)) != 0 {
|
||||||
|
reg ^= poly << (shift - 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(reg & 0x03ff) as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pty_name(pty: u8) -> &'static str {
|
||||||
|
match pty {
|
||||||
|
0 => "None",
|
||||||
|
1 => "News",
|
||||||
|
2 => "Current Affairs",
|
||||||
|
3 => "Information",
|
||||||
|
4 => "Sport",
|
||||||
|
5 => "Education",
|
||||||
|
6 => "Drama",
|
||||||
|
7 => "Culture",
|
||||||
|
8 => "Science",
|
||||||
|
9 => "Varied",
|
||||||
|
10 => "Pop Music",
|
||||||
|
11 => "Rock Music",
|
||||||
|
12 => "Easy Listening",
|
||||||
|
13 => "Light Classical",
|
||||||
|
14 => "Serious Classical",
|
||||||
|
15 => "Other Music",
|
||||||
|
16 => "Weather",
|
||||||
|
17 => "Finance",
|
||||||
|
18 => "Children's",
|
||||||
|
19 => "Social Affairs",
|
||||||
|
20 => "Religion",
|
||||||
|
21 => "Phone In",
|
||||||
|
22 => "Travel",
|
||||||
|
23 => "Leisure",
|
||||||
|
24 => "Jazz Music",
|
||||||
|
25 => "Country Music",
|
||||||
|
26 => "National Music",
|
||||||
|
27 => "Oldies Music",
|
||||||
|
28 => "Folk Music",
|
||||||
|
29 => "Documentary",
|
||||||
|
30 => "Alarm Test",
|
||||||
|
_ => "Alarm",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn encode_block(data: u16, offset: u16) -> u32 {
|
||||||
|
(u32::from(data) << 10) | u32::from(crc10(data) ^ offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_block_recognizes_valid_offsets() {
|
||||||
|
let block = encode_block(0x1234, OFFSET_A);
|
||||||
|
let (data, kind) = decode_block(block).expect("valid block");
|
||||||
|
assert_eq!(data, 0x1234);
|
||||||
|
assert_eq!(kind, BlockKind::A);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoder_emits_ps_and_pty_from_group_0a() {
|
||||||
|
let mut candidate = Candidate::new(240_000.0, 0.0);
|
||||||
|
let pi = 0x52ab;
|
||||||
|
let block_a = encode_block(pi, OFFSET_A);
|
||||||
|
let block_b = encode_block((10 << 5) | 0, OFFSET_B);
|
||||||
|
let block_d = encode_block(u16::from_be_bytes(*b"AB"), OFFSET_D);
|
||||||
|
|
||||||
|
for bit_idx in (0..26).rev() {
|
||||||
|
let bit = ((block_a >> bit_idx) & 1) as u8;
|
||||||
|
let _ = candidate.push_bit(bit);
|
||||||
|
}
|
||||||
|
for bit_idx in (0..26).rev() {
|
||||||
|
let bit = ((block_b >> bit_idx) & 1) as u8;
|
||||||
|
let _ = candidate.push_bit(bit);
|
||||||
|
}
|
||||||
|
let filler = encode_block(0, OFFSET_C);
|
||||||
|
for bit_idx in (0..26).rev() {
|
||||||
|
let bit = ((filler >> bit_idx) & 1) as u8;
|
||||||
|
let _ = candidate.push_bit(bit);
|
||||||
|
}
|
||||||
|
let mut last = None;
|
||||||
|
for bit_idx in (0..26).rev() {
|
||||||
|
let bit = ((block_d >> bit_idx) & 1) as u8;
|
||||||
|
last = candidate.push_bit(bit);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(last.is_some());
|
||||||
|
let state = last.unwrap();
|
||||||
|
assert_eq!(state.pi, Some(pi));
|
||||||
|
assert_eq!(state.pty, Some(10));
|
||||||
|
assert_eq!(state.pty_name.as_deref(), Some("Pop Music"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -889,6 +889,9 @@ function render(update) {
|
|||||||
if (update.filter && typeof update.filter.bandwidth_hz === "number") {
|
if (update.filter && typeof update.filter.bandwidth_hz === "number") {
|
||||||
currentBandwidthHz = update.filter.bandwidth_hz;
|
currentBandwidthHz = update.filter.bandwidth_hz;
|
||||||
syncBandwidthInput(currentBandwidthHz);
|
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") {
|
if (update.status && update.status.freq && typeof update.status.freq.hz === "number") {
|
||||||
lastFreqHz = update.status.freq.hz;
|
lastFreqHz = update.status.freq.hz;
|
||||||
@@ -904,7 +907,7 @@ function render(update) {
|
|||||||
if (update.status && update.status.mode) {
|
if (update.status && update.status.mode) {
|
||||||
const mode = normalizeMode(update.status.mode);
|
const mode = normalizeMode(update.status.mode);
|
||||||
modeEl.value = mode ? mode.toUpperCase() : "";
|
modeEl.value = mode ? mode.toUpperCase() : "";
|
||||||
updateWfmAudioModeControl();
|
updateWfmControls();
|
||||||
// When filter panel is active (SDR backend), update the BW slider range
|
// 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
|
// to match the new mode — but only if the server hasn't already sent a
|
||||||
// filter state that overrides it.
|
// filter state that overrides it.
|
||||||
@@ -1475,6 +1478,7 @@ async function applyModeFromPicker() {
|
|||||||
showHint("Mode missing", 1500);
|
showHint("Mode missing", 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
updateWfmControls();
|
||||||
modeEl.disabled = true;
|
modeEl.disabled = true;
|
||||||
showHint("Setting mode…");
|
showHint("Setting mode…");
|
||||||
try {
|
try {
|
||||||
@@ -2052,7 +2056,8 @@ const txAudioBtn = document.getElementById("tx-audio-btn");
|
|||||||
const audioStatus = document.getElementById("audio-status");
|
const audioStatus = document.getElementById("audio-status");
|
||||||
const audioLevelFill = document.getElementById("audio-level-fill");
|
const audioLevelFill = document.getElementById("audio-level-fill");
|
||||||
const audioRow = document.getElementById("audio-row");
|
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");
|
const wfmAudioModeEl = document.getElementById("wfm-audio-mode");
|
||||||
|
|
||||||
// Hide audio row if audio is not configured on the server
|
// Hide audio row if audio is not configured on the server
|
||||||
@@ -2080,6 +2085,8 @@ let txTimeoutTimer = null;
|
|||||||
let txTimeoutRemaining = 0;
|
let txTimeoutRemaining = 0;
|
||||||
let txTimeoutInterval = null;
|
let txTimeoutInterval = null;
|
||||||
const hasWebCodecs = typeof AudioDecoder !== "undefined" && typeof AudioEncoder !== "undefined";
|
const hasWebCodecs = typeof AudioDecoder !== "undefined" && typeof AudioEncoder !== "undefined";
|
||||||
|
const MAX_RX_BUFFER_SECS = 0.25;
|
||||||
|
const TARGET_RX_BUFFER_SECS = 0.04;
|
||||||
|
|
||||||
if (wfmAudioModeEl) {
|
if (wfmAudioModeEl) {
|
||||||
wfmAudioModeEl.value = loadSetting("wfmAudioMode", "stereo");
|
wfmAudioModeEl.value = loadSetting("wfmAudioMode", "stereo");
|
||||||
@@ -2087,12 +2094,16 @@ if (wfmAudioModeEl) {
|
|||||||
saveSetting("wfmAudioMode", wfmAudioModeEl.value);
|
saveSetting("wfmAudioMode", wfmAudioModeEl.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (wfmDeemphasisEl) {
|
||||||
|
wfmDeemphasisEl.addEventListener("change", () => {
|
||||||
|
postPath(`/set_wfm_deemphasis?us=${encodeURIComponent(wfmDeemphasisEl.value)}`).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateWfmAudioModeControl() {
|
function updateWfmControls() {
|
||||||
if (!wfmAudioModeWrap) return;
|
if (!wfmControlsCol) return;
|
||||||
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
|
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
|
||||||
const channels = (streamInfo && streamInfo.channels) || 1;
|
wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
|
||||||
wfmAudioModeWrap.style.display = mode === "WFM" && channels >= 2 ? "" : "none";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show compatibility warning for non-Chromium browsers
|
// Show compatibility warning for non-Chromium browsers
|
||||||
@@ -2148,8 +2159,9 @@ function startRxAudio() {
|
|||||||
// Stream info JSON
|
// Stream info JSON
|
||||||
try {
|
try {
|
||||||
streamInfo = JSON.parse(evt.data);
|
streamInfo = JSON.parse(evt.data);
|
||||||
updateWfmAudioModeControl();
|
updateWfmControls();
|
||||||
audioCtx = new AudioContext({ sampleRate: streamInfo.sample_rate || 48000 });
|
audioCtx = new AudioContext({ sampleRate: streamInfo.sample_rate || 48000 });
|
||||||
|
audioCtx.resume().catch(() => {});
|
||||||
rxGainNode = audioCtx.createGain();
|
rxGainNode = audioCtx.createGain();
|
||||||
rxGainNode.gain.value = rxVolSlider.value / 100;
|
rxGainNode.gain.value = rxVolSlider.value / 100;
|
||||||
rxGainNode.connect(audioCtx.destination);
|
rxGainNode.connect(audioCtx.destination);
|
||||||
@@ -2214,6 +2226,9 @@ function startRxAudio() {
|
|||||||
src.buffer = ab;
|
src.buffer = ab;
|
||||||
src.connect(rxGainNode);
|
src.connect(rxGainNode);
|
||||||
const now = audioCtx.currentTime;
|
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));
|
const schedTime = Math.max(now, (nextPlayTime || now));
|
||||||
src.start(schedTime);
|
src.start(schedTime);
|
||||||
nextPlayTime = schedTime + ab.duration;
|
nextPlayTime = schedTime + ab.duration;
|
||||||
@@ -2249,7 +2264,7 @@ function startRxAudio() {
|
|||||||
if (txActive) { stopTxAudio(); }
|
if (txActive) { stopTxAudio(); }
|
||||||
rxActive = false;
|
rxActive = false;
|
||||||
streamInfo = null;
|
streamInfo = null;
|
||||||
updateWfmAudioModeControl();
|
updateWfmControls();
|
||||||
rxAudioBtn.style.borderColor = "";
|
rxAudioBtn.style.borderColor = "";
|
||||||
rxAudioBtn.style.color = "";
|
rxAudioBtn.style.color = "";
|
||||||
audioStatus.textContent = "Off";
|
audioStatus.textContent = "Off";
|
||||||
@@ -2272,7 +2287,7 @@ function stopRxAudio() {
|
|||||||
streamInfo = null;
|
streamInfo = null;
|
||||||
if (audioWs) { audioWs.close(); audioWs = null; }
|
if (audioWs) { audioWs.close(); audioWs = null; }
|
||||||
if (audioCtx) { audioCtx.close(); audioCtx = null; }
|
if (audioCtx) { audioCtx.close(); audioCtx = null; }
|
||||||
updateWfmAudioModeControl();
|
updateWfmControls();
|
||||||
rxGainNode = null;
|
rxGainNode = null;
|
||||||
if (opusDecoder) {
|
if (opusDecoder) {
|
||||||
try { opusDecoder.close(); } catch(e) {}
|
try { opusDecoder.close(); } catch(e) {}
|
||||||
|
|||||||
@@ -117,6 +117,23 @@
|
|||||||
<button id="jog-up" type="button" class="jog-btn">+</button>
|
<button id="jog-up" type="button" class="jog-btn">+</button>
|
||||||
</div>
|
</div>
|
||||||
</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="controls-col controls-col-power label-below-col" id="tx-power-col">
|
||||||
<div class="label"><span>Transmit / Power</span></div>
|
<div class="label"><span>Transmit / Power</span></div>
|
||||||
<div class="btn-grid">
|
<div class="btn-grid">
|
||||||
@@ -168,7 +185,6 @@
|
|||||||
<button id="tx-audio-btn" type="button">TX Audio</button>
|
<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">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">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">
|
||||||
<div id="audio-level-fill"></div>
|
<div id="audio-level-fill"></div>
|
||||||
</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; }
|
#freq { font-family: 'DSEG14 Classic', monospace; font-size: 2rem; padding: 0.5rem 0.6rem; letter-spacing: 0.05em; text-align: center; }
|
||||||
.controls-row {
|
.controls-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: minmax(0, 1fr) auto auto minmax(0, 1fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -114,6 +114,26 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
|
|||||||
width: auto;
|
width: auto;
|
||||||
align-items: center;
|
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 {
|
.controls-col-center::after {
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
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 { width: 100%; justify-content: flex-end; }
|
||||||
.header-rig-switch select { min-width: 6.5rem; }
|
.header-rig-switch select { min-width: 6.5rem; }
|
||||||
.controls-row { grid-template-columns: 1fr auto; }
|
.controls-row { grid-template-columns: 1fr auto; }
|
||||||
|
.controls-col-wfm { grid-column: 1 / -1; }
|
||||||
.controls-col-power { grid-column: 1 / -1; }
|
.controls-col-power { grid-column: 1 / -1; }
|
||||||
.controls-col.label-below-col .inline,
|
.controls-col.label-below-col .inline,
|
||||||
.controls-col.label-below-col .btn-grid { margin-top: 0; }
|
.controls-col.label-below-col .btn-grid { margin-top: 0; }
|
||||||
|
.wfm-controls-inline { flex-wrap: wrap; }
|
||||||
.ft8-controls { flex-wrap: wrap; }
|
.ft8-controls { flex-wrap: wrap; }
|
||||||
#ft8-decode-toggle-btn, #wspr-decode-toggle-btn { white-space: nowrap; }
|
#ft8-decode-toggle-btn, #wspr-decode-toggle-btn { white-space: nowrap; }
|
||||||
.jog-container { flex-wrap: wrap; }
|
.jog-container { flex-wrap: wrap; }
|
||||||
|
|||||||
@@ -468,6 +468,19 @@ pub async fn set_fir_taps(
|
|||||||
send_command(&rig_tx, RigCommand::SetFirTaps(query.taps)).await
|
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")]
|
#[post("/toggle_aprs_decode")]
|
||||||
pub async fn toggle_aprs_decode(
|
pub async fn toggle_aprs_decode(
|
||||||
state: web::Data<watch::Receiver<RigState>>,
|
state: web::Data<watch::Receiver<RigState>>,
|
||||||
@@ -679,6 +692,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(set_tx_limit)
|
.service(set_tx_limit)
|
||||||
.service(set_bandwidth)
|
.service(set_bandwidth)
|
||||||
.service(set_fir_taps)
|
.service(set_fir_taps)
|
||||||
|
.service(set_wfm_deemphasis)
|
||||||
.service(toggle_aprs_decode)
|
.service(toggle_aprs_decode)
|
||||||
.service(toggle_cw_decode)
|
.service(toggle_cw_decode)
|
||||||
.service(set_cw_auto)
|
.service(set_cw_auto)
|
||||||
|
|||||||
@@ -13,5 +13,5 @@ pub type DynResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
|||||||
pub use rig::command::RigCommand;
|
pub use rig::command::RigCommand;
|
||||||
pub use rig::request::RigRequest;
|
pub use rig::request::RigRequest;
|
||||||
pub use rig::response::{RigError, RigResult};
|
pub use rig::response::{RigError, RigResult};
|
||||||
pub use rig::state::{RigFilterState, RigMode, RigSnapshot, RigState};
|
pub use rig::state::{RdsData, RigFilterState, RigMode, RigSnapshot, RigState};
|
||||||
pub use rig::AudioSource;
|
pub use rig::AudioSource;
|
||||||
|
|||||||
@@ -33,5 +33,6 @@ pub enum RigCommand {
|
|||||||
ResetWsprDecoder,
|
ResetWsprDecoder,
|
||||||
SetBandwidth(u32),
|
SetBandwidth(u32),
|
||||||
SetFirTaps(u32),
|
SetFirTaps(u32),
|
||||||
|
SetWfmDeemphasis(u32),
|
||||||
GetSpectrum,
|
GetSpectrum,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -516,6 +516,7 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
|
|||||||
| RigCommand::ResetWsprDecoder
|
| RigCommand::ResetWsprDecoder
|
||||||
| RigCommand::SetBandwidth(_)
|
| RigCommand::SetBandwidth(_)
|
||||||
| RigCommand::SetFirTaps(_)
|
| RigCommand::SetFirTaps(_)
|
||||||
|
| RigCommand::SetWfmDeemphasis(_)
|
||||||
| RigCommand::GetSpectrum => Box::new(GetSnapshotCommand),
|
| RigCommand::GetSpectrum => Box::new(GetSnapshotCommand),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,16 @@ pub trait RigCat: Rig + Send {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_wfm_deemphasis<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
_deemphasis_us: u32,
|
||||||
|
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
|
Box::pin(std::future::ready(Err(
|
||||||
|
Box::new(response::RigError::not_supported("set_wfm_deemphasis"))
|
||||||
|
as Box<dyn std::error::Error + Send + Sync>,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Return the current filter state if this backend supports filter controls.
|
/// Return the current filter state if this backend supports filter controls.
|
||||||
fn filter_state(&self) -> Option<state::RigFilterState> {
|
fn filter_state(&self) -> Option<state::RigFilterState> {
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -269,6 +269,12 @@ pub struct RigFilterState {
|
|||||||
pub bandwidth_hz: u32,
|
pub bandwidth_hz: u32,
|
||||||
pub fir_taps: u32,
|
pub fir_taps: u32,
|
||||||
pub cw_center_hz: u32,
|
pub cw_center_hz: u32,
|
||||||
|
#[serde(default = "default_wfm_deemphasis_us")]
|
||||||
|
pub wfm_deemphasis_us: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_wfm_deemphasis_us() -> u32 {
|
||||||
|
75
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spectrum data from SDR backends (FFT magnitude over the full capture bandwidth).
|
/// Spectrum data from SDR backends (FFT magnitude over the full capture bandwidth).
|
||||||
@@ -280,6 +286,22 @@ pub struct SpectrumData {
|
|||||||
pub center_hz: u64,
|
pub center_hz: u64,
|
||||||
/// SDR capture sample rate in Hz; the displayed span is ±sample_rate/2.
|
/// SDR capture sample rate in Hz; the displayed span is ±sample_rate/2.
|
||||||
pub sample_rate: u32,
|
pub sample_rate: u32,
|
||||||
|
/// Decoded Radio Data System state, when available for WFM.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rds: Option<RdsData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live RDS metadata decoded from a WFM broadcast.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct RdsData {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pi: Option<u16>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub program_service: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pty: Option<u8>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pty_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-only projection of state shared with clients.
|
/// Read-only projection of state shared with clients.
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ mod tests {
|
|||||||
bandwidth_hz: 3000,
|
bandwidth_hz: 3000,
|
||||||
fir_taps: 64,
|
fir_taps: 64,
|
||||||
cw_center_hz: 700,
|
cw_center_hz: 700,
|
||||||
|
wfm_deemphasis_us: 75,
|
||||||
}),
|
}),
|
||||||
..minimal_snapshot()
|
..minimal_snapshot()
|
||||||
})
|
})
|
||||||
@@ -332,6 +333,7 @@ mod tests {
|
|||||||
bandwidth_hz: 12000,
|
bandwidth_hz: 12000,
|
||||||
fir_taps: 128,
|
fir_taps: 128,
|
||||||
cw_center_hz: 700,
|
cw_center_hz: 700,
|
||||||
|
wfm_deemphasis_us: 50,
|
||||||
}),
|
}),
|
||||||
..minimal_snapshot()
|
..minimal_snapshot()
|
||||||
};
|
};
|
||||||
@@ -340,6 +342,7 @@ mod tests {
|
|||||||
let f = decoded.filter.expect("filter should round-trip");
|
let f = decoded.filter.expect("filter should round-trip");
|
||||||
assert_eq!(f.bandwidth_hz, 12000);
|
assert_eq!(f.bandwidth_hz, 12000);
|
||||||
assert_eq!(f.fir_taps, 128);
|
assert_eq!(f.fir_taps, 128);
|
||||||
|
assert_eq!(f.wfm_deemphasis_us, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn minimal_snapshot() -> trx_core::rig::state::RigSnapshot {
|
fn minimal_snapshot() -> trx_core::rig::state::RigSnapshot {
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
|
|||||||
ClientCommand::ResetWsprDecoder => RigCommand::ResetWsprDecoder,
|
ClientCommand::ResetWsprDecoder => RigCommand::ResetWsprDecoder,
|
||||||
ClientCommand::SetBandwidth { bandwidth_hz } => RigCommand::SetBandwidth(bandwidth_hz),
|
ClientCommand::SetBandwidth { bandwidth_hz } => RigCommand::SetBandwidth(bandwidth_hz),
|
||||||
ClientCommand::SetFirTaps { taps } => RigCommand::SetFirTaps(taps),
|
ClientCommand::SetFirTaps { taps } => RigCommand::SetFirTaps(taps),
|
||||||
|
ClientCommand::SetWfmDeemphasis { deemphasis_us } => {
|
||||||
|
RigCommand::SetWfmDeemphasis(deemphasis_us)
|
||||||
|
}
|
||||||
ClientCommand::GetSpectrum => RigCommand::GetSpectrum,
|
ClientCommand::GetSpectrum => RigCommand::GetSpectrum,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,6 +92,9 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand {
|
|||||||
RigCommand::ResetWsprDecoder => ClientCommand::ResetWsprDecoder,
|
RigCommand::ResetWsprDecoder => ClientCommand::ResetWsprDecoder,
|
||||||
RigCommand::SetBandwidth(bandwidth_hz) => ClientCommand::SetBandwidth { bandwidth_hz },
|
RigCommand::SetBandwidth(bandwidth_hz) => ClientCommand::SetBandwidth { bandwidth_hz },
|
||||||
RigCommand::SetFirTaps(taps) => ClientCommand::SetFirTaps { taps },
|
RigCommand::SetFirTaps(taps) => ClientCommand::SetFirTaps { taps },
|
||||||
|
RigCommand::SetWfmDeemphasis(deemphasis_us) => {
|
||||||
|
ClientCommand::SetWfmDeemphasis { deemphasis_us }
|
||||||
|
}
|
||||||
RigCommand::GetSpectrum => ClientCommand::GetSpectrum,
|
RigCommand::GetSpectrum => ClientCommand::GetSpectrum,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ pub enum ClientCommand {
|
|||||||
ResetWsprDecoder,
|
ResetWsprDecoder,
|
||||||
SetBandwidth { bandwidth_hz: u32 },
|
SetBandwidth { bandwidth_hz: u32 },
|
||||||
SetFirTaps { taps: u32 },
|
SetFirTaps { taps: u32 },
|
||||||
|
SetWfmDeemphasis { deemphasis_us: u32 },
|
||||||
GetSpectrum,
|
GetSpectrum,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -449,6 +449,16 @@ async fn process_command(
|
|||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
return snapshot_from(ctx.state);
|
return snapshot_from(ctx.state);
|
||||||
}
|
}
|
||||||
|
RigCommand::SetWfmDeemphasis(deemphasis_us) => {
|
||||||
|
if let Err(e) = ctx.rig.set_wfm_deemphasis(deemphasis_us).await {
|
||||||
|
return Err(RigError::communication(format!("set_wfm_deemphasis: {e}")));
|
||||||
|
}
|
||||||
|
if let Some(f) = ctx.state.filter.as_mut() {
|
||||||
|
f.wfm_deemphasis_us = deemphasis_us;
|
||||||
|
}
|
||||||
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
|
return snapshot_from(ctx.state);
|
||||||
|
}
|
||||||
RigCommand::SetCenterFreq(freq) => {
|
RigCommand::SetCenterFreq(freq) => {
|
||||||
if let Err(e) = ctx.rig.set_center_freq(freq).await {
|
if let Err(e) = ctx.rig.set_center_freq(freq).await {
|
||||||
return Err(RigError::communication(format!("set_center_freq: {e}")));
|
return Err(RigError::communication(format!("set_center_freq: {e}")));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ license = "BSD-2-Clause"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
trx-core = { path = "../../../trx-core" }
|
trx-core = { path = "../../../trx-core" }
|
||||||
|
trx-rds = { path = "../../../decoders/trx-rds" }
|
||||||
tokio = { workspace = true, features = ["sync", "rt"] }
|
tokio = { workspace = true, features = ["sync", "rt"] }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
// SPDX-License-Identifier: BSD-2-Clause
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
use num_complex::Complex;
|
use num_complex::Complex;
|
||||||
use trx_core::rig::state::RigMode;
|
use trx_core::rig::state::{RdsData, RigMode};
|
||||||
|
use trx_rds::RdsDecoder;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct OnePoleLowPass {
|
struct OnePoleLowPass {
|
||||||
@@ -50,6 +51,7 @@ impl Deemphasis {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WfmStereoDecoder {
|
pub struct WfmStereoDecoder {
|
||||||
output_channels: usize,
|
output_channels: usize,
|
||||||
|
rds_decoder: RdsDecoder,
|
||||||
pilot_phase: f32,
|
pilot_phase: f32,
|
||||||
pilot_freq: f32,
|
pilot_freq: f32,
|
||||||
pilot_freq_err: f32,
|
pilot_freq_err: f32,
|
||||||
@@ -65,11 +67,18 @@ pub struct WfmStereoDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WfmStereoDecoder {
|
impl WfmStereoDecoder {
|
||||||
pub fn new(composite_rate: u32, audio_rate: u32, output_channels: usize) -> Self {
|
pub fn new(
|
||||||
|
composite_rate: u32,
|
||||||
|
audio_rate: u32,
|
||||||
|
output_channels: usize,
|
||||||
|
deemphasis_us: u32,
|
||||||
|
) -> Self {
|
||||||
let composite_rate_f = composite_rate.max(1) as f32;
|
let composite_rate_f = composite_rate.max(1) as f32;
|
||||||
let output_decim = (composite_rate / audio_rate.max(1)).max(1) as usize;
|
let output_decim = (composite_rate / audio_rate.max(1)).max(1) as usize;
|
||||||
|
let deemphasis_us = deemphasis_us as f32;
|
||||||
Self {
|
Self {
|
||||||
output_channels: output_channels.max(1),
|
output_channels: output_channels.max(1),
|
||||||
|
rds_decoder: RdsDecoder::new(composite_rate),
|
||||||
pilot_phase: 0.0,
|
pilot_phase: 0.0,
|
||||||
pilot_freq: 2.0 * std::f32::consts::PI * 19_000.0 / composite_rate_f,
|
pilot_freq: 2.0 * std::f32::consts::PI * 19_000.0 / composite_rate_f,
|
||||||
pilot_freq_err: 0.0,
|
pilot_freq_err: 0.0,
|
||||||
@@ -77,9 +86,9 @@ impl WfmStereoDecoder {
|
|||||||
pilot_q_lp: OnePoleLowPass::new(composite_rate_f, 400.0),
|
pilot_q_lp: OnePoleLowPass::new(composite_rate_f, 400.0),
|
||||||
sum_lp: OnePoleLowPass::new(composite_rate_f, 15_000.0),
|
sum_lp: OnePoleLowPass::new(composite_rate_f, 15_000.0),
|
||||||
diff_lp: OnePoleLowPass::new(composite_rate_f, 15_000.0),
|
diff_lp: OnePoleLowPass::new(composite_rate_f, 15_000.0),
|
||||||
deemph_m: Deemphasis::new(audio_rate.max(1) as f32, 75.0),
|
deemph_m: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
|
||||||
deemph_l: Deemphasis::new(audio_rate.max(1) as f32, 75.0),
|
deemph_l: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
|
||||||
deemph_r: Deemphasis::new(audio_rate.max(1) as f32, 75.0),
|
deemph_r: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
|
||||||
output_decim,
|
output_decim,
|
||||||
output_counter: 0,
|
output_counter: 0,
|
||||||
}
|
}
|
||||||
@@ -90,6 +99,7 @@ impl WfmStereoDecoder {
|
|||||||
if composite.is_empty() {
|
if composite.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
let _ = self.rds_decoder.process_samples(&composite);
|
||||||
|
|
||||||
let mut output = Vec::with_capacity(
|
let mut output = Vec::with_capacity(
|
||||||
(composite.len() / self.output_decim.max(1)) * self.output_channels.max(1),
|
(composite.len() / self.output_decim.max(1)) * self.output_channels.max(1),
|
||||||
@@ -129,6 +139,10 @@ impl WfmStereoDecoder {
|
|||||||
|
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn rds_data(&self) -> Option<RdsData> {
|
||||||
|
self.rds_decoder.snapshot()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selects the demodulation algorithm for a channel.
|
/// Selects the demodulation algorithm for a channel.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use num_complex::Complex;
|
|||||||
use rustfft::num_complex::Complex as FftComplex;
|
use rustfft::num_complex::Complex as FftComplex;
|
||||||
use rustfft::{Fft, FftPlanner};
|
use rustfft::{Fft, FftPlanner};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use trx_core::rig::state::RigMode;
|
use trx_core::rig::state::{RdsData, RigMode};
|
||||||
|
|
||||||
use crate::demod::{Demodulator, WfmStereoDecoder};
|
use crate::demod::{Demodulator, WfmStereoDecoder};
|
||||||
|
|
||||||
@@ -266,6 +266,8 @@ pub struct ChannelDsp {
|
|||||||
audio_bandwidth_hz: u32,
|
audio_bandwidth_hz: u32,
|
||||||
/// FIR tap count used when rebuilding filters.
|
/// FIR tap count used when rebuilding filters.
|
||||||
fir_taps: usize,
|
fir_taps: usize,
|
||||||
|
/// WFM deemphasis time constant in microseconds.
|
||||||
|
wfm_deemphasis_us: u32,
|
||||||
/// Decimation factor: `sdr_sample_rate / audio_sample_rate`.
|
/// Decimation factor: `sdr_sample_rate / audio_sample_rate`.
|
||||||
pub decim_factor: usize,
|
pub decim_factor: usize,
|
||||||
/// Number of PCM channels emitted in each frame.
|
/// Number of PCM channels emitted in each frame.
|
||||||
@@ -338,6 +340,7 @@ impl ChannelDsp {
|
|||||||
channel_sample_rate,
|
channel_sample_rate,
|
||||||
self.audio_sample_rate,
|
self.audio_sample_rate,
|
||||||
self.output_channels,
|
self.output_channels,
|
||||||
|
self.wfm_deemphasis_us,
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -354,6 +357,7 @@ impl ChannelDsp {
|
|||||||
output_channels: usize,
|
output_channels: usize,
|
||||||
frame_duration_ms: u16,
|
frame_duration_ms: u16,
|
||||||
audio_bandwidth_hz: u32,
|
audio_bandwidth_hz: u32,
|
||||||
|
wfm_deemphasis_us: u32,
|
||||||
fir_taps: usize,
|
fir_taps: usize,
|
||||||
pcm_tx: broadcast::Sender<Vec<f32>>,
|
pcm_tx: broadcast::Sender<Vec<f32>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -390,6 +394,7 @@ impl ChannelDsp {
|
|||||||
audio_sample_rate,
|
audio_sample_rate,
|
||||||
audio_bandwidth_hz,
|
audio_bandwidth_hz,
|
||||||
fir_taps: taps,
|
fir_taps: taps,
|
||||||
|
wfm_deemphasis_us,
|
||||||
decim_factor,
|
decim_factor,
|
||||||
output_channels,
|
output_channels,
|
||||||
frame_buf: Vec::with_capacity(frame_size + output_channels),
|
frame_buf: Vec::with_capacity(frame_size + output_channels),
|
||||||
@@ -403,6 +408,7 @@ impl ChannelDsp {
|
|||||||
channel_sample_rate,
|
channel_sample_rate,
|
||||||
audio_sample_rate,
|
audio_sample_rate,
|
||||||
output_channels,
|
output_channels,
|
||||||
|
wfm_deemphasis_us,
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -425,6 +431,15 @@ impl ChannelDsp {
|
|||||||
self.rebuild_filters();
|
self.rebuild_filters();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_wfm_deemphasis(&mut self, deemphasis_us: u32) {
|
||||||
|
self.wfm_deemphasis_us = deemphasis_us;
|
||||||
|
self.rebuild_filters();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rds_data(&self) -> Option<RdsData> {
|
||||||
|
self.wfm_decoder.as_ref().and_then(WfmStereoDecoder::rds_data)
|
||||||
|
}
|
||||||
|
|
||||||
/// Process a block of raw IQ samples through the full DSP chain.
|
/// Process a block of raw IQ samples through the full DSP chain.
|
||||||
///
|
///
|
||||||
/// 1. **Batch mixer**: compute the full LO signal for the block at once,
|
/// 1. **Batch mixer**: compute the full LO signal for the block at once,
|
||||||
@@ -521,6 +536,7 @@ impl SdrPipeline {
|
|||||||
audio_sample_rate: u32,
|
audio_sample_rate: u32,
|
||||||
output_channels: usize,
|
output_channels: usize,
|
||||||
frame_duration_ms: u16,
|
frame_duration_ms: u16,
|
||||||
|
wfm_deemphasis_us: u32,
|
||||||
channels: &[(f64, RigMode, u32, usize)],
|
channels: &[(f64, RigMode, u32, usize)],
|
||||||
) -> Self {
|
) -> Self {
|
||||||
const IQ_BROADCAST_CAPACITY: usize = 64;
|
const IQ_BROADCAST_CAPACITY: usize = 64;
|
||||||
@@ -541,6 +557,7 @@ impl SdrPipeline {
|
|||||||
output_channels,
|
output_channels,
|
||||||
frame_duration_ms,
|
frame_duration_ms,
|
||||||
audio_bandwidth_hz,
|
audio_bandwidth_hz,
|
||||||
|
wfm_deemphasis_us,
|
||||||
fir_taps,
|
fir_taps,
|
||||||
pcm_tx.clone(),
|
pcm_tx.clone(),
|
||||||
);
|
);
|
||||||
@@ -760,7 +777,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn channel_dsp_processes_silence() {
|
fn channel_dsp_processes_silence() {
|
||||||
let (pcm_tx, _pcm_rx) = broadcast::channel::<Vec<f32>>(8);
|
let (pcm_tx, _pcm_rx) = broadcast::channel::<Vec<f32>>(8);
|
||||||
let mut dsp = ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 1, 20, 3000, 31, pcm_tx);
|
let mut dsp =
|
||||||
|
ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 1, 20, 3000, 75, 31, pcm_tx);
|
||||||
let block = vec![Complex::new(0.0_f32, 0.0_f32); 4096];
|
let block = vec![Complex::new(0.0_f32, 0.0_f32); 4096];
|
||||||
dsp.process_block(&block);
|
dsp.process_block(&block);
|
||||||
}
|
}
|
||||||
@@ -768,7 +786,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn channel_dsp_set_mode() {
|
fn channel_dsp_set_mode() {
|
||||||
let (pcm_tx, _) = broadcast::channel::<Vec<f32>>(8);
|
let (pcm_tx, _) = broadcast::channel::<Vec<f32>>(8);
|
||||||
let mut dsp = ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 1, 20, 3000, 31, pcm_tx);
|
let mut dsp =
|
||||||
|
ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 1, 20, 3000, 75, 31, pcm_tx);
|
||||||
assert_eq!(dsp.demodulator, Demodulator::Usb);
|
assert_eq!(dsp.demodulator, Demodulator::Usb);
|
||||||
dsp.set_mode(&RigMode::FM);
|
dsp.set_mode(&RigMode::FM);
|
||||||
assert_eq!(dsp.demodulator, Demodulator::Fm);
|
assert_eq!(dsp.demodulator, Demodulator::Fm);
|
||||||
@@ -782,6 +801,7 @@ mod tests {
|
|||||||
48_000,
|
48_000,
|
||||||
1,
|
1,
|
||||||
20,
|
20,
|
||||||
|
75,
|
||||||
&[(200_000.0, RigMode::USB, 3000, 64)],
|
&[(200_000.0, RigMode::USB, 3000, 64)],
|
||||||
);
|
);
|
||||||
assert_eq!(pipeline.pcm_senders.len(), 1);
|
assert_eq!(pipeline.pcm_senders.len(), 1);
|
||||||
@@ -790,7 +810,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pipeline_empty_channels() {
|
fn pipeline_empty_channels() {
|
||||||
let pipeline = SdrPipeline::start(Box::new(MockIqSource), 1_920_000, 48_000, 1, 20, &[]);
|
let pipeline = SdrPipeline::start(Box::new(MockIqSource), 1_920_000, 48_000, 1, 20, 75, &[]);
|
||||||
assert_eq!(pipeline.pcm_senders.len(), 0);
|
assert_eq!(pipeline.pcm_senders.len(), 0);
|
||||||
assert_eq!(pipeline.channel_dsps.len(), 0);
|
assert_eq!(pipeline.channel_dsps.len(), 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ pub struct SoapySdrRig {
|
|||||||
center_hz: i64,
|
center_hz: i64,
|
||||||
/// Used to send hardware retune commands to the IQ read loop.
|
/// Used to send hardware retune commands to the IQ read loop.
|
||||||
retune_cmd: Arc<std::sync::Mutex<Option<f64>>>,
|
retune_cmd: Arc<std::sync::Mutex<Option<f64>>>,
|
||||||
|
/// Current WFM deemphasis setting in microseconds.
|
||||||
|
wfm_deemphasis_us: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SoapySdrRig {
|
impl SoapySdrRig {
|
||||||
@@ -111,6 +113,7 @@ impl SoapySdrRig {
|
|||||||
audio_sample_rate,
|
audio_sample_rate,
|
||||||
audio_channels,
|
audio_channels,
|
||||||
frame_duration_ms,
|
frame_duration_ms,
|
||||||
|
75,
|
||||||
channels,
|
channels,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -177,6 +180,7 @@ impl SoapySdrRig {
|
|||||||
center_offset_hz,
|
center_offset_hz,
|
||||||
center_hz: hardware_center_hz,
|
center_hz: hardware_center_hz,
|
||||||
retune_cmd,
|
retune_cmd,
|
||||||
|
wfm_deemphasis_us: 75,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +299,25 @@ impl RigCat for SoapySdrRig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_wfm_deemphasis<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
deemphasis_us: u32,
|
||||||
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let deemphasis_us = match deemphasis_us {
|
||||||
|
50 | 75 => deemphasis_us,
|
||||||
|
other => {
|
||||||
|
return Err(format!("unsupported WFM deemphasis {}", other).into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.wfm_deemphasis_us = deemphasis_us;
|
||||||
|
if let Some(dsp_arc) = self.pipeline.channel_dsps.get(self.primary_channel_idx) {
|
||||||
|
dsp_arc.lock().unwrap().set_wfm_deemphasis(deemphasis_us);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn get_signal_strength<'a>(
|
fn get_signal_strength<'a>(
|
||||||
&'a mut self,
|
&'a mut self,
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<u8>> + Send + 'a>> {
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<u8>> + Send + 'a>> {
|
||||||
@@ -426,15 +449,22 @@ impl RigCat for SoapySdrRig {
|
|||||||
bandwidth_hz: self.bandwidth_hz,
|
bandwidth_hz: self.bandwidth_hz,
|
||||||
fir_taps: self.fir_taps,
|
fir_taps: self.fir_taps,
|
||||||
cw_center_hz: 700,
|
cw_center_hz: 700,
|
||||||
|
wfm_deemphasis_us: self.wfm_deemphasis_us,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_spectrum(&self) -> Option<SpectrumData> {
|
fn get_spectrum(&self) -> Option<SpectrumData> {
|
||||||
let bins = self.spectrum_buf.lock().ok()?.clone()?;
|
let bins = self.spectrum_buf.lock().ok()?.clone()?;
|
||||||
|
let rds = self
|
||||||
|
.pipeline
|
||||||
|
.channel_dsps
|
||||||
|
.get(self.primary_channel_idx)
|
||||||
|
.and_then(|dsp| dsp.lock().ok().and_then(|d| d.rds_data()));
|
||||||
Some(SpectrumData {
|
Some(SpectrumData {
|
||||||
bins,
|
bins,
|
||||||
center_hz: self.center_hz.max(0) as u64,
|
center_hz: self.center_hz.max(0) as u64,
|
||||||
sample_rate: self.pipeline.sdr_sample_rate,
|
sample_rate: self.pipeline.sdr_sample_rate,
|
||||||
|
rds,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user