[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:
2026-03-02 22:42:12 +01:00
parent b6692b759e
commit c778d4b9a8
28 changed files with 1200 additions and 86 deletions
+1
View File
@@ -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
View File
@@ -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,
+41 -6
View File
@@ -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);
+1 -1
View File
@@ -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),
}
}
+1
View File
@@ -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(())
})
}