[feat](trx-rs): add AIS decoder mode and frontend
Add dual-channel AIS decode support across the SoapySDR backend, server decode pipeline, and frontend plugins, including the new AIS tab, live bar, and map filtering. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -27,6 +27,7 @@ cpal = "0.15"
|
||||
opus = "0.3"
|
||||
trx-app = { path = "../trx-app" }
|
||||
trx-backend = { path = "trx-backend", features = ["soapysdr"] }
|
||||
trx-ais = { path = "../decoders/trx-ais" }
|
||||
trx-core = { path = "../trx-core" }
|
||||
trx-aprs = { path = "../decoders/trx-aprs" }
|
||||
trx-cw = { path = "../decoders/trx-cw" }
|
||||
|
||||
+141
-4
@@ -15,13 +15,14 @@ use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use trx_ais::AisDecoder;
|
||||
use trx_aprs::AprsDecoder;
|
||||
use trx_core::audio::{
|
||||
read_audio_msg, write_audio_msg, AudioStreamInfo, AUDIO_MSG_APRS_DECODE, AUDIO_MSG_CW_DECODE,
|
||||
AUDIO_MSG_FT8_DECODE, AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME,
|
||||
AUDIO_MSG_WSPR_DECODE,
|
||||
read_audio_msg, write_audio_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE,
|
||||
AUDIO_MSG_APRS_DECODE, AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT8_DECODE, AUDIO_MSG_RX_FRAME,
|
||||
AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_WSPR_DECODE,
|
||||
};
|
||||
use trx_core::decode::{AprsPacket, DecodedMessage, Ft8Message, WsprMessage};
|
||||
use trx_core::decode::{AisMessage, AprsPacket, DecodedMessage, Ft8Message, WsprMessage};
|
||||
use trx_core::rig::state::{RigMode, RigState};
|
||||
use trx_cw::CwDecoder;
|
||||
use trx_ft8::Ft8Decoder;
|
||||
@@ -31,6 +32,7 @@ use crate::config::AudioConfig;
|
||||
use trx_decode_log::DecoderLoggers;
|
||||
|
||||
const APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const AIS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const WSPR_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const FT8_SAMPLE_RATE: u32 = 12_000;
|
||||
@@ -124,6 +126,7 @@ fn classify_stream_error(err: &str) -> &'static str {
|
||||
/// instance can maintain its own independent history. Pass an
|
||||
/// `Arc<DecoderHistories>` into every decoder task and into the audio listener.
|
||||
pub struct DecoderHistories {
|
||||
ais: Mutex<VecDeque<(Instant, AisMessage)>>,
|
||||
aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
|
||||
ft8: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
||||
wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
|
||||
@@ -132,12 +135,38 @@ pub struct DecoderHistories {
|
||||
impl DecoderHistories {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
ais: Mutex::new(VecDeque::new()),
|
||||
aprs: Mutex::new(VecDeque::new()),
|
||||
ft8: Mutex::new(VecDeque::new()),
|
||||
wspr: Mutex::new(VecDeque::new()),
|
||||
})
|
||||
}
|
||||
|
||||
// --- AIS ---
|
||||
|
||||
fn prune_ais(history: &mut VecDeque<(Instant, AisMessage)>) {
|
||||
let cutoff = Instant::now() - AIS_HISTORY_RETENTION;
|
||||
while let Some((ts, _)) = history.front() {
|
||||
if *ts < cutoff {
|
||||
history.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_ais_message(&self, msg: AisMessage) {
|
||||
let mut h = self.ais.lock().expect("ais history mutex poisoned");
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_ais(&mut h);
|
||||
}
|
||||
|
||||
pub fn snapshot_ais_history(&self) -> Vec<AisMessage> {
|
||||
let mut h = self.ais.lock().expect("ais history mutex poisoned");
|
||||
Self::prune_ais(&mut h);
|
||||
h.iter().map(|(_, msg)| msg.clone()).collect()
|
||||
}
|
||||
|
||||
// --- APRS ---
|
||||
|
||||
fn prune_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
|
||||
@@ -817,6 +846,104 @@ pub async fn run_aprs_decoder(
|
||||
}
|
||||
}
|
||||
|
||||
fn downmix_if_needed(frame: Vec<f32>, channels: u16) -> Vec<f32> {
|
||||
if channels <= 1 {
|
||||
return frame;
|
||||
}
|
||||
|
||||
let num_frames = frame.len() / channels as usize;
|
||||
let mut mono = Vec::with_capacity(num_frames);
|
||||
for i in 0..num_frames {
|
||||
mono.push(frame[i * channels as usize]);
|
||||
}
|
||||
mono
|
||||
}
|
||||
|
||||
/// Run the AIS decoder task. Only processes PCM when rig mode is AIS.
|
||||
pub async fn run_ais_decoder(
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
mut pcm_a_rx: broadcast::Receiver<Vec<f32>>,
|
||||
mut pcm_b_rx: broadcast::Receiver<Vec<f32>>,
|
||||
mut state_rx: watch::Receiver<RigState>,
|
||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||
histories: Arc<DecoderHistories>,
|
||||
) {
|
||||
info!("AIS decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||
let mut decoder_a = AisDecoder::new(sample_rate);
|
||||
let mut decoder_b = AisDecoder::new(sample_rate);
|
||||
let mut was_active = false;
|
||||
let mut active = matches!(state_rx.borrow().status.mode, RigMode::AIS);
|
||||
|
||||
loop {
|
||||
if !active {
|
||||
match state_rx.changed().await {
|
||||
Ok(()) => {
|
||||
let state = state_rx.borrow();
|
||||
active = matches!(state.status.mode, RigMode::AIS);
|
||||
if active {
|
||||
pcm_a_rx = pcm_a_rx.resubscribe();
|
||||
pcm_b_rx = pcm_b_rx.resubscribe();
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
recv = pcm_a_rx.recv() => {
|
||||
match recv {
|
||||
Ok(frame) => {
|
||||
was_active = true;
|
||||
for msg in decoder_a.process_samples(&downmix_if_needed(frame, channels), "A") {
|
||||
histories.record_ais_message(msg.clone());
|
||||
let _ = decode_tx.send(DecodedMessage::Ais(msg));
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("AIS decoder A: dropped {} PCM frames", n);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
recv = pcm_b_rx.recv() => {
|
||||
match recv {
|
||||
Ok(frame) => {
|
||||
was_active = true;
|
||||
for msg in decoder_b.process_samples(&downmix_if_needed(frame, channels), "B") {
|
||||
histories.record_ais_message(msg.clone());
|
||||
let _ = decode_tx.send(DecodedMessage::Ais(msg));
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("AIS decoder B: dropped {} PCM frames", n);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
changed = state_rx.changed() => {
|
||||
match changed {
|
||||
Ok(()) => {
|
||||
let state = state_rx.borrow();
|
||||
active = matches!(state.status.mode, RigMode::AIS);
|
||||
if !active && was_active {
|
||||
decoder_a.reset();
|
||||
decoder_b.reset();
|
||||
was_active = false;
|
||||
}
|
||||
if active {
|
||||
pcm_a_rx = pcm_a_rx.resubscribe();
|
||||
pcm_b_rx = pcm_b_rx.resubscribe();
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the CW decoder task. Only processes PCM when rig mode is CW or CWR.
|
||||
pub async fn run_cw_decoder(
|
||||
sample_rate: u32,
|
||||
@@ -1332,6 +1459,15 @@ async fn handle_audio_client(
|
||||
let info_json = serde_json::to_vec(&stream_info).map_err(std::io::Error::other)?;
|
||||
write_audio_msg(&mut writer, AUDIO_MSG_STREAM_INFO, &info_json).await?;
|
||||
|
||||
// Send APRS history to newly connected client.
|
||||
let history = histories.snapshot_ais_history();
|
||||
for msg in history {
|
||||
let msg = DecodedMessage::Ais(msg);
|
||||
let msg_type = AUDIO_MSG_AIS_DECODE;
|
||||
if let Ok(json) = serde_json::to_vec(&msg) {
|
||||
write_audio_msg(&mut writer, msg_type, &json).await?;
|
||||
}
|
||||
}
|
||||
// Send APRS history to newly connected client.
|
||||
let history = histories.snapshot_aprs_history();
|
||||
for pkt in history {
|
||||
@@ -1385,6 +1521,7 @@ async fn handle_audio_client(
|
||||
match result {
|
||||
Ok(msg) => {
|
||||
let msg_type = match &msg {
|
||||
DecodedMessage::Ais(_) => AUDIO_MSG_AIS_DECODE,
|
||||
DecodedMessage::Aprs(_) => AUDIO_MSG_APRS_DECODE,
|
||||
DecodedMessage::Cw(_) => AUDIO_MSG_CW_DECODE,
|
||||
DecodedMessage::Ft8(_) => AUDIO_MSG_FT8_DECODE,
|
||||
|
||||
@@ -244,6 +244,7 @@ fn default_audio_bandwidth_for_mode(mode: &trx_core::rig::state::RigMode) -> u32
|
||||
RigMode::AM => 9_000,
|
||||
RigMode::FM => 12_500,
|
||||
RigMode::WFM => 180_000,
|
||||
RigMode::AIS => 25_000,
|
||||
RigMode::Other(_) => 3_000,
|
||||
}
|
||||
}
|
||||
@@ -264,6 +265,7 @@ fn parse_rig_mode(
|
||||
"AM" => RigMode::AM,
|
||||
"WFM" => RigMode::WFM,
|
||||
"FM" => RigMode::FM,
|
||||
"AIS" => RigMode::AIS,
|
||||
"DIG" => RigMode::DIG,
|
||||
"PKT" => RigMode::PKT,
|
||||
_ => initial_mode.clone(),
|
||||
@@ -274,10 +276,18 @@ fn parse_rig_mode(
|
||||
type SdrRigBuildResult = DynResult<(
|
||||
Box<dyn trx_core::rig::RigCat>,
|
||||
tokio::sync::broadcast::Receiver<Vec<f32>>,
|
||||
(
|
||||
tokio::sync::broadcast::Receiver<Vec<f32>>,
|
||||
tokio::sync::broadcast::Receiver<Vec<f32>>,
|
||||
),
|
||||
)>;
|
||||
|
||||
type OptionalSdrRig = Option<Box<dyn trx_core::rig::RigCat>>;
|
||||
type OptionalSdrPcmRx = Option<broadcast::Receiver<Vec<f32>>>;
|
||||
type OptionalSdrAisPcmRx = Option<(
|
||||
broadcast::Receiver<Vec<f32>>,
|
||||
broadcast::Receiver<Vec<f32>>,
|
||||
)>;
|
||||
|
||||
/// Build a `SoapySdrRig` with full channel config from a `RigInstanceConfig`.
|
||||
#[cfg(feature = "soapysdr")]
|
||||
@@ -312,6 +322,7 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
|
||||
64,
|
||||
));
|
||||
}
|
||||
let ais_channel_base_idx = channels.len();
|
||||
|
||||
let sdr_rig = trx_backend::SoapySdrRig::new_with_config(
|
||||
args,
|
||||
@@ -333,7 +344,11 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
|
||||
)?;
|
||||
|
||||
let pcm_rx = sdr_rig.subscribe_pcm();
|
||||
Ok((Box::new(sdr_rig) as Box<dyn trx_core::rig::RigCat>, pcm_rx))
|
||||
let ais_pcm = (
|
||||
sdr_rig.subscribe_pcm_channel(ais_channel_base_idx),
|
||||
sdr_rig.subscribe_pcm_channel(ais_channel_base_idx + 1),
|
||||
);
|
||||
Ok((Box::new(sdr_rig) as Box<dyn trx_core::rig::RigCat>, pcm_rx, ais_pcm))
|
||||
}
|
||||
|
||||
/// Build a `RigTaskConfig` for a single rig instance.
|
||||
@@ -408,6 +423,10 @@ fn spawn_rig_audio_stack(
|
||||
longitude: Option<f64>,
|
||||
listen_override: Option<IpAddr>,
|
||||
sdr_pcm_rx: Option<broadcast::Receiver<Vec<f32>>>,
|
||||
sdr_ais_pcm_rx: Option<(
|
||||
broadcast::Receiver<Vec<f32>>,
|
||||
broadcast::Receiver<Vec<f32>>,
|
||||
)>,
|
||||
) -> Vec<JoinHandle<()>> {
|
||||
let mut handles: Vec<JoinHandle<()>> = Vec::new();
|
||||
|
||||
@@ -593,6 +612,21 @@ fn spawn_rig_audio_stack(
|
||||
}
|
||||
}));
|
||||
|
||||
if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = sdr_ais_pcm_rx {
|
||||
let ais_state_rx = state_rx.clone();
|
||||
let ais_decode_tx = decode_tx.clone();
|
||||
let ais_shutdown_rx = shutdown_rx.clone();
|
||||
let ais_histories = histories.clone();
|
||||
let ais_sr = rig_cfg.audio.sample_rate;
|
||||
let ais_ch = rig_cfg.audio.channels as u16;
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_ais_decoder(ais_sr, ais_ch, ais_a_pcm_rx, ais_b_pcm_rx, ais_state_rx, ais_decode_tx, ais_histories) => {}
|
||||
_ = wait_for_shutdown(ais_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Spawn CW decoder task (no histories needed — CW has no persistent history)
|
||||
let cw_pcm_rx = pcm_tx.subscribe();
|
||||
let cw_state_rx = state_rx.clone();
|
||||
@@ -819,16 +853,16 @@ async fn main() -> DynResult<()> {
|
||||
|
||||
// Build SDR rig when applicable.
|
||||
#[cfg(feature = "soapysdr")]
|
||||
let (sdr_prebuilt_rig, sdr_pcm_rx): (OptionalSdrRig, OptionalSdrPcmRx) =
|
||||
let (sdr_prebuilt_rig, sdr_pcm_rx, sdr_ais_pcm_rx): (OptionalSdrRig, OptionalSdrPcmRx, OptionalSdrAisPcmRx) =
|
||||
if rig_cfg.rig.access.access_type.as_deref() == Some("sdr") {
|
||||
let (rig, pcm_rx) = build_sdr_rig_from_instance(rig_cfg)?;
|
||||
(Some(rig), Some(pcm_rx))
|
||||
let (rig, pcm_rx, ais_pcm_rx) = build_sdr_rig_from_instance(rig_cfg)?;
|
||||
(Some(rig), Some(pcm_rx), Some(ais_pcm_rx))
|
||||
} else {
|
||||
(None, None)
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "soapysdr"))]
|
||||
let (sdr_prebuilt_rig, sdr_pcm_rx): (OptionalSdrRig, OptionalSdrPcmRx) = (None, None);
|
||||
let (sdr_prebuilt_rig, sdr_pcm_rx, sdr_ais_pcm_rx): (OptionalSdrRig, OptionalSdrPcmRx, OptionalSdrAisPcmRx) = (None, None, None);
|
||||
|
||||
let histories = DecoderHistories::new();
|
||||
|
||||
@@ -889,6 +923,7 @@ async fn main() -> DynResult<()> {
|
||||
longitude,
|
||||
audio_listen_override,
|
||||
sdr_pcm_rx,
|
||||
sdr_ais_pcm_rx,
|
||||
);
|
||||
task_handles.extend(audio_handles);
|
||||
|
||||
|
||||
@@ -773,7 +773,7 @@ fn map_signal_strength(mode: &RigMode, raw: u8) -> i32 {
|
||||
// FT-817 returns 0-15 for signal strength
|
||||
// Map to approximate dBm / S-units
|
||||
match mode {
|
||||
RigMode::FM | RigMode::WFM => -120 + (raw as i32 * 6),
|
||||
RigMode::FM | RigMode::WFM | RigMode::AIS => -120 + (raw as i32 * 6),
|
||||
_ => -127 + (raw as i32 * 6),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ impl DummyRig {
|
||||
RigMode::AM,
|
||||
RigMode::FM,
|
||||
RigMode::WFM,
|
||||
RigMode::AIS,
|
||||
RigMode::DIG,
|
||||
RigMode::PKT,
|
||||
],
|
||||
|
||||
@@ -164,6 +164,7 @@ impl Ft450d {
|
||||
RigMode::AM,
|
||||
RigMode::WFM,
|
||||
RigMode::FM,
|
||||
RigMode::AIS,
|
||||
RigMode::DIG,
|
||||
RigMode::PKT,
|
||||
],
|
||||
@@ -511,6 +512,7 @@ fn encode_mode(mode: &RigMode) -> DynResult<char> {
|
||||
RigMode::USB => Ok('2'),
|
||||
RigMode::CW => Ok('3'),
|
||||
RigMode::FM => Ok('4'),
|
||||
RigMode::AIS => Ok('4'),
|
||||
RigMode::AM => Ok('5'),
|
||||
RigMode::DIG => Ok('6'),
|
||||
RigMode::CWR => Ok('7'),
|
||||
|
||||
@@ -195,6 +195,7 @@ impl Ft817 {
|
||||
RigMode::AM,
|
||||
RigMode::WFM,
|
||||
RigMode::FM,
|
||||
RigMode::AIS,
|
||||
RigMode::DIG,
|
||||
RigMode::PKT,
|
||||
],
|
||||
@@ -588,6 +589,7 @@ fn encode_mode(mode: &RigMode) -> u8 {
|
||||
RigMode::AM => 0x04,
|
||||
RigMode::WFM => 0x06,
|
||||
RigMode::FM => 0x08,
|
||||
RigMode::AIS => 0x08,
|
||||
RigMode::DIG => 0x0A,
|
||||
RigMode::PKT => 0x0C,
|
||||
RigMode::Other(_) => 0x00,
|
||||
|
||||
@@ -156,6 +156,7 @@ impl Demodulator {
|
||||
RigMode::AM => Self::Am,
|
||||
RigMode::FM => Self::Fm,
|
||||
RigMode::WFM => Self::Wfm,
|
||||
RigMode::AIS => Self::Fm,
|
||||
RigMode::CW | RigMode::CWR => Self::Cw,
|
||||
RigMode::DIG => Self::Passthrough,
|
||||
// VHF/UHF packet radio (APRS, AX.25) is FM-encoded AFSK.
|
||||
@@ -187,6 +188,7 @@ mod tests {
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::AM), Demodulator::Am);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::FM), Demodulator::Fm);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::WFM), Demodulator::Wfm);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::AIS), Demodulator::Fm);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::CW), Demodulator::Cw);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::CWR), Demodulator::Cw);
|
||||
assert_eq!(
|
||||
|
||||
@@ -23,6 +23,7 @@ fn iq_agc_for_mode(mode: &RigMode, sample_rate: u32) -> Option<SoftAgc> {
|
||||
let sr = sample_rate.max(1) as f32;
|
||||
match mode {
|
||||
RigMode::FM | RigMode::PKT => Some(SoftAgc::new(sr, 0.5, 150.0, 0.8, 12.0)),
|
||||
RigMode::AIS => Some(SoftAgc::new(sr, 0.5, 150.0, 0.8, 12.0)),
|
||||
RigMode::WFM => None,
|
||||
_ => None,
|
||||
}
|
||||
@@ -43,6 +44,7 @@ fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
|
||||
RigMode::AM => 9_000,
|
||||
RigMode::FM => 12_500,
|
||||
RigMode::WFM => 180_000,
|
||||
RigMode::AIS => 25_000,
|
||||
RigMode::Other(_) => 3_000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ use trx_core::rig::{
|
||||
};
|
||||
use trx_core::{DynResult, RigMode};
|
||||
|
||||
const AIS_CHANNEL_SPACING_HZ: i64 = 50_000;
|
||||
|
||||
/// RX-only backend for any SoapySDR-compatible device.
|
||||
pub struct SoapySdrRig {
|
||||
info: RigInfo,
|
||||
@@ -47,9 +49,23 @@ pub struct SoapySdrRig {
|
||||
gain_db: f64,
|
||||
/// Optional hard ceiling for the applied hardware gain in dB.
|
||||
max_gain_db: Option<f64>,
|
||||
/// Hidden AIS decoder channels (A and B) when available.
|
||||
ais_channel_indices: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl SoapySdrRig {
|
||||
fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
|
||||
match mode {
|
||||
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
|
||||
RigMode::PKT | RigMode::AIS => 25_000,
|
||||
RigMode::CW | RigMode::CWR => 500,
|
||||
RigMode::AM => 9_000,
|
||||
RigMode::FM => 12_500,
|
||||
RigMode::WFM => 180_000,
|
||||
RigMode::Other(_) => 3_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Full constructor. All channel configuration is passed as plain
|
||||
/// parameters so this crate does not need to depend on `trx-server`
|
||||
/// (which is a binary, not a library crate).
|
||||
@@ -130,6 +146,21 @@ impl SoapySdrRig {
|
||||
effective_gain_db,
|
||||
)?);
|
||||
|
||||
let primary_channel_count = channels.len();
|
||||
let mut all_channels = channels.to_vec();
|
||||
all_channels.push((
|
||||
(initial_freq.hz as i64 - hardware_center_hz) as f64,
|
||||
RigMode::FM,
|
||||
25_000,
|
||||
96,
|
||||
));
|
||||
all_channels.push((
|
||||
(initial_freq.hz as i64 + AIS_CHANNEL_SPACING_HZ - hardware_center_hz) as f64,
|
||||
RigMode::FM,
|
||||
25_000,
|
||||
96,
|
||||
));
|
||||
|
||||
let pipeline = dsp::SdrPipeline::start(
|
||||
iq_source,
|
||||
sdr_sample_rate,
|
||||
@@ -138,7 +169,7 @@ impl SoapySdrRig {
|
||||
frame_duration_ms,
|
||||
wfm_deemphasis_us,
|
||||
true, // wfm_stereo: enabled by default
|
||||
channels,
|
||||
&all_channels,
|
||||
);
|
||||
|
||||
let info = RigInfo {
|
||||
@@ -160,6 +191,7 @@ impl SoapySdrRig {
|
||||
RigMode::AM,
|
||||
RigMode::WFM,
|
||||
RigMode::FM,
|
||||
RigMode::AIS,
|
||||
RigMode::DIG,
|
||||
RigMode::PKT,
|
||||
],
|
||||
@@ -209,6 +241,7 @@ impl SoapySdrRig {
|
||||
wfm_denoise: WfmDenoiseLevel::Auto,
|
||||
gain_db,
|
||||
max_gain_db,
|
||||
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -233,6 +266,36 @@ impl SoapySdrRig {
|
||||
0, // center_offset_hz
|
||||
)
|
||||
}
|
||||
|
||||
fn update_ais_channel_offsets(&self) {
|
||||
let Some((ais_a_idx, ais_b_idx)) = self.ais_channel_indices else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(dsp_arc) = self.pipeline.channel_dsps.get(ais_a_idx) {
|
||||
let if_hz = (self.freq.hz as i64 - self.center_hz) as f64;
|
||||
dsp_arc.lock().unwrap().set_channel_if_hz(if_hz);
|
||||
}
|
||||
if let Some(dsp_arc) = self.pipeline.channel_dsps.get(ais_b_idx) {
|
||||
let if_hz = (self.freq.hz as i64 + AIS_CHANNEL_SPACING_HZ - self.center_hz) as f64;
|
||||
dsp_arc.lock().unwrap().set_channel_if_hz(if_hz);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_ais_channel_filters(&self) {
|
||||
let Some((ais_a_idx, ais_b_idx)) = self.ais_channel_indices else {
|
||||
return;
|
||||
};
|
||||
|
||||
for idx in [ais_a_idx, ais_b_idx] {
|
||||
if let Some(dsp_arc) = self.pipeline.channel_dsps.get(idx) {
|
||||
dsp_arc
|
||||
.lock()
|
||||
.unwrap()
|
||||
.set_filter(self.bandwidth_hz, self.fir_taps as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -251,7 +314,11 @@ impl Rig for SoapySdrRig {
|
||||
|
||||
impl AudioSource for SoapySdrRig {
|
||||
fn subscribe_pcm(&self) -> tokio::sync::broadcast::Receiver<Vec<f32>> {
|
||||
if let Some(sender) = self.pipeline.pcm_senders.get(self.primary_channel_idx) {
|
||||
self.subscribe_pcm_channel(self.primary_channel_idx)
|
||||
}
|
||||
|
||||
fn subscribe_pcm_channel(&self, channel_idx: usize) -> tokio::sync::broadcast::Receiver<Vec<f32>> {
|
||||
if let Some(sender) = self.pipeline.pcm_senders.get(channel_idx) {
|
||||
sender.subscribe()
|
||||
} else {
|
||||
// No channels configured — return a receiver that will never
|
||||
@@ -284,9 +351,14 @@ impl RigCat for SoapySdrRig {
|
||||
self.freq = freq;
|
||||
let half_span_hz = i128::from(self.pipeline.sdr_sample_rate) / 2;
|
||||
let current_center_hz = i128::from(self.center_hz);
|
||||
let target_hz = i128::from(freq.hz);
|
||||
let within_current_span = target_hz >= current_center_hz - half_span_hz
|
||||
&& target_hz <= current_center_hz + half_span_hz;
|
||||
let target_lo_hz = i128::from(freq.hz);
|
||||
let target_hi_hz = if self.mode == RigMode::AIS {
|
||||
i128::from(freq.hz) + i128::from(AIS_CHANNEL_SPACING_HZ)
|
||||
} else {
|
||||
i128::from(freq.hz)
|
||||
};
|
||||
let within_current_span = target_lo_hz >= current_center_hz - half_span_hz
|
||||
&& target_hi_hz <= current_center_hz + half_span_hz;
|
||||
|
||||
if !within_current_span {
|
||||
// Only retune when the requested dial frequency leaves the
|
||||
@@ -306,6 +378,7 @@ impl RigCat for SoapySdrRig {
|
||||
dsp.reset_wfm_state();
|
||||
}
|
||||
}
|
||||
self.update_ais_channel_offsets();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -324,6 +397,7 @@ impl RigCat for SoapySdrRig {
|
||||
let channel_if_hz = (self.freq.hz as i64 - self.center_hz) as f64;
|
||||
dsp_arc.lock().unwrap().set_channel_if_hz(channel_if_hz);
|
||||
}
|
||||
self.update_ais_channel_offsets();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -335,10 +409,14 @@ impl RigCat for SoapySdrRig {
|
||||
Box::pin(async move {
|
||||
tracing::debug!("SoapySdrRig: set_mode -> {:?}", mode);
|
||||
self.mode = mode.clone();
|
||||
self.bandwidth_hz = Self::default_bandwidth_for_mode(&mode);
|
||||
// Update the primary channel's demodulator in the live pipeline.
|
||||
if let Some(dsp_arc) = self.pipeline.channel_dsps.get(self.primary_channel_idx) {
|
||||
dsp_arc.lock().unwrap().set_mode(&mode);
|
||||
let mut dsp = dsp_arc.lock().unwrap();
|
||||
dsp.set_mode(&mode);
|
||||
dsp.set_filter(self.bandwidth_hz, self.fir_taps as usize);
|
||||
}
|
||||
self.apply_ais_channel_filters();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -490,6 +568,7 @@ impl RigCat for SoapySdrRig {
|
||||
.unwrap()
|
||||
.set_filter(bandwidth_hz, self.fir_taps as usize);
|
||||
}
|
||||
self.apply_ais_channel_filters();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -507,6 +586,7 @@ impl RigCat for SoapySdrRig {
|
||||
.unwrap()
|
||||
.set_filter(self.bandwidth_hz, taps as usize);
|
||||
}
|
||||
self.apply_ais_channel_filters();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user