[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:
Generated
+8
@@ -2375,6 +2375,13 @@ dependencies = [
|
||||
"strength_reduce",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-ais"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"trx-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-app"
|
||||
version = "0.1.0"
|
||||
@@ -2591,6 +2598,7 @@ dependencies = [
|
||||
"tokio-serial",
|
||||
"toml",
|
||||
"tracing",
|
||||
"trx-ais",
|
||||
"trx-app",
|
||||
"trx-aprs",
|
||||
"trx-backend",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
[workspace]
|
||||
members = [
|
||||
"src/decoders/trx-ais",
|
||||
"src/decoders/trx-aprs",
|
||||
"src/decoders/trx-cw",
|
||||
"src/decoders/trx-decode-log",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-ais"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../trx-core" }
|
||||
@@ -0,0 +1,389 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
//! Basic AIS GMSK/HDLC decoder.
|
||||
//!
|
||||
//! This decoder operates on narrowband FM-demodulated audio. It uses a simple
|
||||
//! sign slicer at the symbol rate, HDLC flag detection with NRZI decoding and
|
||||
//! bit de-stuffing, then parses common AIS position/static messages.
|
||||
|
||||
use trx_core::decode::AisMessage;
|
||||
|
||||
const AIS_BAUD: f32 = 9_600.0;
|
||||
|
||||
const CRC_CCITT_TABLE: [u16; 256] = {
|
||||
let mut table = [0u16; 256];
|
||||
let mut i = 0usize;
|
||||
while i < 256 {
|
||||
let mut crc = i as u16;
|
||||
let mut j = 0;
|
||||
while j < 8 {
|
||||
if crc & 1 != 0 {
|
||||
crc = (crc >> 1) ^ 0x8408;
|
||||
} else {
|
||||
crc >>= 1;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
table[i] = crc;
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
|
||||
fn crc16ccitt(bytes: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0xFFFF;
|
||||
for &b in bytes {
|
||||
crc = (crc >> 8) ^ CRC_CCITT_TABLE[((crc ^ b as u16) & 0xFF) as usize];
|
||||
}
|
||||
crc ^ 0xFFFF
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RawFrame {
|
||||
payload: Vec<u8>,
|
||||
bits: Vec<u8>,
|
||||
crc_ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AisDecoder {
|
||||
sample_rate: f32,
|
||||
symbol_phase: f32,
|
||||
dc_state: f32,
|
||||
lp_state: f32,
|
||||
env_state: f32,
|
||||
prev_raw_bit: u8,
|
||||
ones: u32,
|
||||
in_frame: bool,
|
||||
frame_bits: Vec<u8>,
|
||||
frames: Vec<RawFrame>,
|
||||
}
|
||||
|
||||
impl AisDecoder {
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
Self {
|
||||
sample_rate: sample_rate.max(1) as f32,
|
||||
symbol_phase: 0.0,
|
||||
dc_state: 0.0,
|
||||
lp_state: 0.0,
|
||||
env_state: 1e-3,
|
||||
prev_raw_bit: 0,
|
||||
ones: 0,
|
||||
in_frame: false,
|
||||
frame_bits: Vec::new(),
|
||||
frames: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.symbol_phase = 0.0;
|
||||
self.dc_state = 0.0;
|
||||
self.lp_state = 0.0;
|
||||
self.env_state = 1e-3;
|
||||
self.prev_raw_bit = 0;
|
||||
self.ones = 0;
|
||||
self.in_frame = false;
|
||||
self.frame_bits.clear();
|
||||
self.frames.clear();
|
||||
}
|
||||
|
||||
pub fn process_samples(&mut self, samples: &[f32], channel: &str) -> Vec<AisMessage> {
|
||||
for &sample in samples {
|
||||
self.process_sample(sample);
|
||||
}
|
||||
|
||||
let frames = std::mem::take(&mut self.frames);
|
||||
let mut out = Vec::new();
|
||||
for frame in frames {
|
||||
if let Some(msg) = parse_frame(frame, channel) {
|
||||
out.push(msg);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn process_sample(&mut self, sample: f32) {
|
||||
// Remove slow DC drift from the FM discriminator output.
|
||||
self.dc_state += 0.0025 * (sample - self.dc_state);
|
||||
let dc_free = sample - self.dc_state;
|
||||
|
||||
// Gentle low-pass smoothing to suppress narrow impulsive noise.
|
||||
self.lp_state += 0.28 * (dc_free - self.lp_state);
|
||||
|
||||
// Track envelope to keep the slicer stable on weak signals.
|
||||
self.env_state += 0.02 * (self.lp_state.abs() - self.env_state);
|
||||
let normalized = if self.env_state > 1e-4 {
|
||||
self.lp_state / self.env_state
|
||||
} else {
|
||||
self.lp_state
|
||||
};
|
||||
|
||||
self.symbol_phase += AIS_BAUD;
|
||||
while self.symbol_phase >= self.sample_rate {
|
||||
self.symbol_phase -= self.sample_rate;
|
||||
let raw_bit = if normalized >= 0.0 { 1 } else { 0 };
|
||||
self.process_symbol(raw_bit);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_symbol(&mut self, raw_bit: u8) {
|
||||
let decoded_bit = if raw_bit == self.prev_raw_bit { 1 } else { 0 };
|
||||
self.prev_raw_bit = raw_bit;
|
||||
|
||||
if decoded_bit == 1 {
|
||||
self.ones += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// A zero terminates the current run of ones.
|
||||
if self.ones >= 7 {
|
||||
self.in_frame = false;
|
||||
self.frame_bits.clear();
|
||||
self.ones = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.ones == 6 {
|
||||
if self.in_frame && self.frame_bits.len() >= 256 {
|
||||
if let Some(frame) = self.bits_to_frame() {
|
||||
self.frames.push(frame);
|
||||
}
|
||||
}
|
||||
self.frame_bits.clear();
|
||||
self.in_frame = true;
|
||||
self.ones = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.ones == 5 {
|
||||
if self.in_frame {
|
||||
for _ in 0..5 {
|
||||
self.frame_bits.push(1);
|
||||
}
|
||||
}
|
||||
self.ones = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.in_frame {
|
||||
for _ in 0..self.ones {
|
||||
self.frame_bits.push(1);
|
||||
}
|
||||
self.frame_bits.push(0);
|
||||
}
|
||||
self.ones = 0;
|
||||
}
|
||||
|
||||
fn bits_to_frame(&self) -> Option<RawFrame> {
|
||||
if self.frame_bits.len() < 24 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let usable_bits = self.frame_bits.len() - (self.frame_bits.len() % 8);
|
||||
if usable_bits < 24 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bits = self.frame_bits[..usable_bits].to_vec();
|
||||
let mut bytes = Vec::with_capacity(usable_bits / 8);
|
||||
for chunk in bits.chunks(8) {
|
||||
let mut byte = 0u8;
|
||||
for (idx, &bit) in chunk.iter().enumerate() {
|
||||
if bit != 0 {
|
||||
byte |= 1 << idx;
|
||||
}
|
||||
}
|
||||
bytes.push(byte);
|
||||
}
|
||||
|
||||
if bytes.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let payload_len = bytes.len() - 2;
|
||||
let payload = bytes[..payload_len].to_vec();
|
||||
let received_fcs = u16::from_le_bytes([bytes[payload_len], bytes[payload_len + 1]]);
|
||||
let crc_ok = crc16ccitt(&payload) == received_fcs;
|
||||
|
||||
Some(RawFrame {
|
||||
payload,
|
||||
bits,
|
||||
crc_ok,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_frame(frame: RawFrame, channel: &str) -> Option<AisMessage> {
|
||||
if !frame.crc_ok {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bits = bytes_to_msb_bits(&frame.payload);
|
||||
if bits.len() < 40 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let message_type = get_uint(&bits, 0, 6)? as u8;
|
||||
let repeat = get_uint(&bits, 6, 2)? as u8;
|
||||
let mmsi = get_uint(&bits, 8, 30)? as u32;
|
||||
|
||||
let mut msg = AisMessage {
|
||||
ts_ms: None,
|
||||
channel: channel.to_string(),
|
||||
message_type,
|
||||
repeat,
|
||||
mmsi,
|
||||
crc_ok: frame.crc_ok,
|
||||
bit_len: frame.bits.len(),
|
||||
raw_bytes: frame.payload,
|
||||
lat: None,
|
||||
lon: None,
|
||||
sog_knots: None,
|
||||
cog_deg: None,
|
||||
heading_deg: None,
|
||||
nav_status: None,
|
||||
vessel_name: None,
|
||||
callsign: None,
|
||||
destination: None,
|
||||
};
|
||||
|
||||
match message_type {
|
||||
1..=3 => {
|
||||
msg.nav_status = get_uint(&bits, 38, 4).map(|v| v as u8);
|
||||
msg.sog_knots = decode_tenths(get_uint(&bits, 50, 10)?, 1023);
|
||||
msg.lon = decode_coord(get_int(&bits, 61, 28)?, 181.0);
|
||||
msg.lat = decode_coord(get_int(&bits, 89, 27)?, 91.0);
|
||||
msg.cog_deg = decode_tenths(get_uint(&bits, 116, 12)?, 3600);
|
||||
msg.heading_deg = decode_heading(get_uint(&bits, 128, 9)?);
|
||||
}
|
||||
18 => {
|
||||
msg.sog_knots = decode_tenths(get_uint(&bits, 46, 10)?, 1023);
|
||||
msg.lon = decode_coord(get_int(&bits, 57, 28)?, 181.0);
|
||||
msg.lat = decode_coord(get_int(&bits, 85, 27)?, 91.0);
|
||||
msg.cog_deg = decode_tenths(get_uint(&bits, 112, 12)?, 3600);
|
||||
msg.heading_deg = decode_heading(get_uint(&bits, 124, 9)?);
|
||||
}
|
||||
19 => {
|
||||
msg.sog_knots = decode_tenths(get_uint(&bits, 46, 10)?, 1023);
|
||||
msg.lon = decode_coord(get_int(&bits, 57, 28)?, 181.0);
|
||||
msg.lat = decode_coord(get_int(&bits, 85, 27)?, 91.0);
|
||||
msg.cog_deg = decode_tenths(get_uint(&bits, 112, 12)?, 3600);
|
||||
msg.heading_deg = decode_heading(get_uint(&bits, 124, 9)?);
|
||||
msg.vessel_name = decode_sixbit_text(&bits, 143, 120);
|
||||
}
|
||||
5 => {
|
||||
msg.callsign = decode_sixbit_text(&bits, 70, 42);
|
||||
msg.vessel_name = decode_sixbit_text(&bits, 112, 120);
|
||||
msg.destination = decode_sixbit_text(&bits, 302, 120);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Some(msg)
|
||||
}
|
||||
|
||||
fn bytes_to_msb_bits(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut bits = Vec::with_capacity(bytes.len() * 8);
|
||||
for &byte in bytes {
|
||||
for shift in (0..8).rev() {
|
||||
bits.push((byte >> shift) & 1);
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
fn get_uint(bits: &[u8], start: usize, len: usize) -> Option<u32> {
|
||||
if len == 0 || start.checked_add(len)? > bits.len() || len > 32 {
|
||||
return None;
|
||||
}
|
||||
let mut out = 0u32;
|
||||
for &bit in &bits[start..start + len] {
|
||||
out = (out << 1) | u32::from(bit);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn get_int(bits: &[u8], start: usize, len: usize) -> Option<i32> {
|
||||
let raw = get_uint(bits, start, len)?;
|
||||
if len == 0 || len > 31 {
|
||||
return None;
|
||||
}
|
||||
let sign_mask = 1u32 << (len - 1);
|
||||
if raw & sign_mask == 0 {
|
||||
Some(raw as i32)
|
||||
} else {
|
||||
Some((raw as i32) - ((1u32 << len) as i32))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_tenths(raw: u32, invalid: u32) -> Option<f32> {
|
||||
if raw == invalid {
|
||||
None
|
||||
} else {
|
||||
Some(raw as f32 / 10.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_heading(raw: u32) -> Option<u16> {
|
||||
if raw >= 360 {
|
||||
None
|
||||
} else {
|
||||
Some(raw as u16)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_coord(raw: i32, invalid_abs: f64) -> Option<f64> {
|
||||
let value = raw as f64 / 600_000.0;
|
||||
if value.abs() >= invalid_abs {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_sixbit_text(bits: &[u8], start: usize, len: usize) -> Option<String> {
|
||||
if start.checked_add(len)? > bits.len() || len % 6 != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
for offset in (0..len).step_by(6) {
|
||||
let value = get_uint(bits, start + offset, 6)? as u8;
|
||||
let ch = if value < 32 {
|
||||
char::from(value + 64)
|
||||
} else {
|
||||
char::from(value)
|
||||
};
|
||||
if ch != '@' {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = out.trim().trim_matches('@').trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decodes_signed_coordinates() {
|
||||
assert_eq!(decode_coord(60_000, 181.0), Some(0.1));
|
||||
assert_eq!(decode_coord(-60_000, 181.0), Some(-0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_sixbit_name() {
|
||||
let bytes = [0x10_u8, 0x41_u8, 0x11_u8, 0x92_u8, 0x08_u8, 0x00_u8];
|
||||
let bits = bytes_to_msb_bits(&bytes);
|
||||
let text = decode_sixbit_text(&bits, 0, 36);
|
||||
assert!(text.is_some());
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,9 @@ use tracing::{info, warn};
|
||||
use trx_frontend::RemoteRigEntry;
|
||||
|
||||
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::DecodedMessage;
|
||||
|
||||
@@ -148,7 +148,8 @@ async fn handle_audio_connection(
|
||||
let _ = rx_tx.send(Bytes::from(payload));
|
||||
}
|
||||
Ok((
|
||||
AUDIO_MSG_APRS_DECODE
|
||||
AUDIO_MSG_AIS_DECODE
|
||||
| AUDIO_MSG_APRS_DECODE
|
||||
| AUDIO_MSG_CW_DECODE
|
||||
| AUDIO_MSG_FT8_DECODE
|
||||
| AUDIO_MSG_WSPR_DECODE,
|
||||
|
||||
@@ -13,7 +13,7 @@ use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use trx_core::audio::AudioStreamInfo;
|
||||
use trx_core::decode::{AprsPacket, CwEvent, DecodedMessage, Ft8Message, WsprMessage};
|
||||
use trx_core::decode::{AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, WsprMessage};
|
||||
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
||||
use trx_core::{DynResult, RigRequest, RigState};
|
||||
|
||||
@@ -136,6 +136,8 @@ pub struct FrontendRuntimeContext {
|
||||
pub audio_info: Option<watch::Receiver<Option<AudioStreamInfo>>>,
|
||||
/// Decode message broadcast channel
|
||||
pub decode_rx: Option<broadcast::Sender<DecodedMessage>>,
|
||||
/// AIS decode history (timestamp, message)
|
||||
pub ais_history: Arc<Mutex<VecDeque<(Instant, AisMessage)>>>,
|
||||
/// APRS decode history (timestamp, packet)
|
||||
pub aprs_history: Arc<Mutex<VecDeque<(Instant, AprsPacket)>>>,
|
||||
/// CW decode history (timestamp, event)
|
||||
@@ -196,6 +198,7 @@ impl FrontendRuntimeContext {
|
||||
audio_tx: None,
|
||||
audio_info: None,
|
||||
decode_rx: None,
|
||||
ais_history: Arc::new(Mutex::new(VecDeque::new())),
|
||||
aprs_history: Arc::new(Mutex::new(VecDeque::new())),
|
||||
cw_history: Arc::new(Mutex::new(VecDeque::new())),
|
||||
ft8_history: Arc::new(Mutex::new(VecDeque::new())),
|
||||
|
||||
@@ -845,10 +845,11 @@ function drawSignalOverlay() {
|
||||
|
||||
if (lastFreqHz != null && currentBandwidthHz > 0) {
|
||||
const halfBw = currentBandwidthHz / 2;
|
||||
const xL = hzToX(lastFreqHz - halfBw);
|
||||
const xR = hzToX(lastFreqHz + halfBw);
|
||||
for (const centerHz of visibleBandwidthCenters(lastFreqHz)) {
|
||||
const xL = hzToX(centerHz - halfBw);
|
||||
const xR = hzToX(centerHz + halfBw);
|
||||
const stripW = xR - xL;
|
||||
if (stripW > 1) {
|
||||
if (stripW <= 1) continue;
|
||||
const grd = ctx.createLinearGradient(xL, 0, xR, 0);
|
||||
grd.addColorStop(0, "rgba(240,173,78,0.05)");
|
||||
grd.addColorStop(0.2, "rgba(240,173,78,0.14)");
|
||||
@@ -1204,6 +1205,31 @@ function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") {
|
||||
return Math.max(0, Number.isFinite(maxBw) ? maxBw : currentBandwidthHz);
|
||||
}
|
||||
|
||||
function isAisMode(mode = modeEl ? modeEl.value : "") {
|
||||
return String(mode || "").toUpperCase() === "AIS";
|
||||
}
|
||||
|
||||
function coverageSpanForMode(freqHz, bandwidthHz = coverageGuardBandwidthHz(), mode = modeEl ? modeEl.value : "") {
|
||||
if (!Number.isFinite(freqHz)) return null;
|
||||
const safeBw = Math.max(0, Number.isFinite(bandwidthHz) ? bandwidthHz : 0);
|
||||
let loHz = freqHz - safeBw / 2;
|
||||
let hiHz = freqHz + safeBw / 2;
|
||||
if (isAisMode(mode)) {
|
||||
const aisBFreqHz = freqHz + 50_000;
|
||||
loHz = Math.min(loHz, aisBFreqHz - safeBw / 2);
|
||||
hiHz = Math.max(hiHz, aisBFreqHz + safeBw / 2);
|
||||
}
|
||||
return { loHz, hiHz };
|
||||
}
|
||||
|
||||
function visibleBandwidthCenters(freqHz = lastFreqHz, mode = modeEl ? modeEl.value : "") {
|
||||
if (!Number.isFinite(freqHz)) return [];
|
||||
if (isAisMode(mode)) {
|
||||
return [freqHz, freqHz + 50_000];
|
||||
}
|
||||
return [freqHz];
|
||||
}
|
||||
|
||||
function effectiveSpectrumCoverageSpanHz(sampleRateHz) {
|
||||
const sampleRate = Number(sampleRateHz);
|
||||
if (!Number.isFinite(sampleRate) || sampleRate <= 0) return 0;
|
||||
@@ -1220,17 +1246,17 @@ function requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz = covera
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeBw = Math.max(0, Number.isFinite(bandwidthHz) ? bandwidthHz : 0);
|
||||
const halfSpanHz = sampleRate / 2;
|
||||
const requiredHalfSpanHz = safeBw / 2 + spectrumCoverageMarginHz;
|
||||
if (requiredHalfSpanHz * 2 >= sampleRate) {
|
||||
const span = coverageSpanForMode(freqHz, bandwidthHz);
|
||||
if (!span) return null;
|
||||
const requiredLoHz = span.loHz - spectrumCoverageMarginHz;
|
||||
const requiredHiHz = span.hiHz + spectrumCoverageMarginHz;
|
||||
if (requiredHiHz - requiredLoHz >= sampleRate) {
|
||||
return alignFreqToRigStep(Math.round(freqHz));
|
||||
}
|
||||
|
||||
const currentLoHz = currentCenterHz - halfSpanHz;
|
||||
const currentHiHz = currentCenterHz + halfSpanHz;
|
||||
const requiredLoHz = freqHz - requiredHalfSpanHz;
|
||||
const requiredHiHz = freqHz + requiredHalfSpanHz;
|
||||
if (requiredLoHz >= currentLoHz && requiredHiHz <= currentHiHz) {
|
||||
return null;
|
||||
}
|
||||
@@ -1300,8 +1326,11 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) {
|
||||
|
||||
const halfUsableSpanHz = usableSpanHz / 2;
|
||||
const fullHalfSpanHz = sampleRate / 2;
|
||||
const guardHalfSpanHz = bandwidthHz / 2 + spectrumCoverageMarginHz;
|
||||
if (guardHalfSpanHz * 2 >= usableSpanHz) {
|
||||
const span = coverageSpanForMode(freqHz, bandwidthHz);
|
||||
if (!span) return null;
|
||||
const requiredLoHz = span.loHz - spectrumCoverageMarginHz;
|
||||
const requiredHiHz = span.hiHz + spectrumCoverageMarginHz;
|
||||
if (requiredHiHz - requiredLoHz >= usableSpanHz) {
|
||||
const fallbackCenterHz = requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz);
|
||||
if (!Number.isFinite(fallbackCenterHz)) return null;
|
||||
return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY };
|
||||
@@ -1310,8 +1339,8 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) {
|
||||
const evalHalfSpanHz = Math.max(0, (sampleRate - usableSpanHz) / 2);
|
||||
const evalMinCenterHz = currentCenterHz - evalHalfSpanHz;
|
||||
const evalMaxCenterHz = currentCenterHz + evalHalfSpanHz;
|
||||
const fitMinCenterHz = freqHz + guardHalfSpanHz - halfUsableSpanHz;
|
||||
const fitMaxCenterHz = freqHz - guardHalfSpanHz + halfUsableSpanHz;
|
||||
const fitMinCenterHz = requiredHiHz - halfUsableSpanHz;
|
||||
const fitMaxCenterHz = requiredLoHz + halfUsableSpanHz;
|
||||
const minCenterHz = Math.max(evalMinCenterHz, fitMinCenterHz);
|
||||
const maxCenterHz = Math.min(evalMaxCenterHz, fitMaxCenterHz);
|
||||
if (!Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) {
|
||||
@@ -1334,8 +1363,8 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) {
|
||||
|
||||
let bestStartIdx = null;
|
||||
let bestScore = Number.POSITIVE_INFINITY;
|
||||
const signalLoHz = freqHz - bandwidthHz / 2;
|
||||
const signalHiHz = freqHz + bandwidthHz / 2;
|
||||
const signalLoHz = span.loHz;
|
||||
const signalHiHz = span.hiHz;
|
||||
|
||||
for (let startIdx = startMinIdx; startIdx <= startMaxIdx; startIdx += 1) {
|
||||
const endIdx = Math.min(maxIdx, startIdx + usableBins);
|
||||
@@ -1351,7 +1380,8 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) {
|
||||
}
|
||||
|
||||
// Keep a very small bias toward a reasonably centered passband when scores are close.
|
||||
const centeredOffsetHz = Math.abs(candidateCenterHz - freqHz);
|
||||
const spanMidHz = (span.loHz + span.hiHz) / 2;
|
||||
const centeredOffsetHz = Math.abs(candidateCenterHz - spanMidHz);
|
||||
score *= 1 + centeredOffsetHz / Math.max(usableSpanHz, 1) * 0.08;
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
@@ -1388,13 +1418,16 @@ function sweetSpotProbeCenters(data, freqHz, bandwidthHz) {
|
||||
if (!Number.isFinite(usableSpanHz) || usableSpanHz <= 0) return [];
|
||||
|
||||
const halfUsableSpanHz = usableSpanHz / 2;
|
||||
const guardHalfSpanHz = bandwidthHz / 2 + spectrumCoverageMarginHz;
|
||||
if (guardHalfSpanHz * 2 >= usableSpanHz) {
|
||||
const span = coverageSpanForMode(freqHz, bandwidthHz);
|
||||
if (!span) return [];
|
||||
const requiredLoHz = span.loHz - spectrumCoverageMarginHz;
|
||||
const requiredHiHz = span.hiHz + spectrumCoverageMarginHz;
|
||||
if (requiredHiHz - requiredLoHz >= usableSpanHz) {
|
||||
return [alignFreqToRigStep(Math.round(freqHz))];
|
||||
}
|
||||
|
||||
const minCenterHz = freqHz + guardHalfSpanHz - halfUsableSpanHz;
|
||||
const maxCenterHz = freqHz - guardHalfSpanHz + halfUsableSpanHz;
|
||||
const minCenterHz = requiredHiHz - halfUsableSpanHz;
|
||||
const maxCenterHz = requiredLoHz + halfUsableSpanHz;
|
||||
if (!Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) {
|
||||
return [];
|
||||
}
|
||||
@@ -1486,15 +1519,17 @@ function tunedFrequencyForCenterCoverage(centerHz, freqHz = lastFreqHz, bandwidt
|
||||
const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate);
|
||||
if (!Number.isFinite(sampleRate) || sampleRate <= 0) return null;
|
||||
|
||||
const safeBw = Math.max(0, Number.isFinite(bandwidthHz) ? bandwidthHz : 0);
|
||||
const span = coverageSpanForMode(freqHz, bandwidthHz);
|
||||
if (!span) return null;
|
||||
const halfSpanHz = sampleRate / 2;
|
||||
const requiredHalfSpanHz = safeBw / 2 + spectrumCoverageMarginHz;
|
||||
if (requiredHalfSpanHz * 2 >= sampleRate) {
|
||||
const requiredLoOffset = freqHz - (span.loHz - spectrumCoverageMarginHz);
|
||||
const requiredHiOffset = (span.hiHz + spectrumCoverageMarginHz) - freqHz;
|
||||
if (requiredLoOffset + requiredHiOffset >= sampleRate) {
|
||||
return alignFreqToRigStep(Math.round(centerHz));
|
||||
}
|
||||
|
||||
const minFreqHz = centerHz - halfSpanHz + requiredHalfSpanHz;
|
||||
const maxFreqHz = centerHz + halfSpanHz - requiredHalfSpanHz;
|
||||
const minFreqHz = centerHz - halfSpanHz + requiredLoOffset;
|
||||
const maxFreqHz = centerHz + halfSpanHz - requiredHiOffset;
|
||||
if (freqHz >= minFreqHz && freqHz <= maxFreqHz) {
|
||||
return null;
|
||||
}
|
||||
@@ -1953,10 +1988,15 @@ function render(update) {
|
||||
}
|
||||
}
|
||||
const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : "";
|
||||
const aisStatus = document.getElementById("ais-status");
|
||||
const aprsStatus = document.getElementById("aprs-status");
|
||||
const cwStatus = document.getElementById("cw-status");
|
||||
const ft8Status = document.getElementById("ft8-status");
|
||||
const wsprStatus = document.getElementById("wspr-status");
|
||||
if (aisStatus && modeUpper !== "AIS" && aisStatus.textContent === "Receiving") {
|
||||
aisStatus.textContent = "Connected, listening for packets";
|
||||
}
|
||||
if (window.updateAisBar) window.updateAisBar();
|
||||
if (aprsStatus && modeUpper !== "PKT" && aprsStatus.textContent === "Receiving") {
|
||||
aprsStatus.textContent = "Connected, listening for packets";
|
||||
}
|
||||
@@ -2644,6 +2684,7 @@ const MODE_BW_DEFAULTS = {
|
||||
USB: [2_700, 300, 6_000, 100],
|
||||
AM: [9_000, 500, 20_000, 500],
|
||||
FM: [12_500, 2_500, 25_000, 500],
|
||||
AIS: [25_000, 12_500, 50_000, 500],
|
||||
WFM: [180_000, 50_000,300_000,5_000],
|
||||
DIG: [3_000, 300, 6_000, 100],
|
||||
PKT: [25_000, 300, 50_000, 500],
|
||||
@@ -2925,8 +2966,9 @@ let aprsRadioPath = null;
|
||||
const stationMarkers = new Map();
|
||||
const locatorMarkers = new Map();
|
||||
const mapMarkers = new Set();
|
||||
const mapFilter = { aprs: true, ft8: true, wspr: true };
|
||||
const mapFilter = { ais: true, aprs: true, ft8: true, wspr: true };
|
||||
const APRS_TRACK_MAX_POINTS = 64;
|
||||
const aisMarkers = new Map();
|
||||
|
||||
window.clearMapMarkersByType = function(type) {
|
||||
if (type === "aprs") {
|
||||
@@ -2944,6 +2986,17 @@ window.clearMapMarkersByType = function(type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "ais") {
|
||||
aisMarkers.forEach((entry) => {
|
||||
if (entry && entry.marker) {
|
||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||
mapMarkers.delete(entry.marker);
|
||||
}
|
||||
});
|
||||
aisMarkers.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "ft8" || type === "wspr") {
|
||||
const prefix = `${type}:`;
|
||||
for (const [key, entry] of locatorMarkers.entries()) {
|
||||
@@ -3041,9 +3094,16 @@ function initAprsMap() {
|
||||
}
|
||||
applyMapFilter();
|
||||
|
||||
const aisFilter = document.getElementById("map-filter-ais");
|
||||
const aprsFilter = document.getElementById("map-filter-aprs");
|
||||
const ft8Filter = document.getElementById("map-filter-ft8");
|
||||
const wsprFilter = document.getElementById("map-filter-wspr");
|
||||
if (aisFilter) {
|
||||
aisFilter.addEventListener("change", () => {
|
||||
mapFilter.ais = aisFilter.checked;
|
||||
applyMapFilter();
|
||||
});
|
||||
}
|
||||
if (aprsFilter) {
|
||||
aprsFilter.addEventListener("change", () => {
|
||||
mapFilter.aprs = aprsFilter.checked;
|
||||
@@ -3188,6 +3248,30 @@ function buildAprsPopupHtml(call, lat, lon, info, pkt) {
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function buildAisPopupHtml(msg) {
|
||||
const age = msg?._tsMs ? formatTimeAgo(msg._tsMs) : null;
|
||||
const distKm = (serverLat != null && serverLon != null && msg?.lat != null && msg?.lon != null)
|
||||
? haversineKm(serverLat, serverLon, msg.lat, msg.lon)
|
||||
: null;
|
||||
const distStr = distKm != null
|
||||
? (distKm < 1 ? `${Math.round(distKm * 1000)} m` : `${distKm.toFixed(1)} km`)
|
||||
: null;
|
||||
const meta = [age, distStr, msg?.channel ? `AIS ${escapeMapHtml(msg.channel)}` : null].filter(Boolean).join(" · ");
|
||||
let rows = "";
|
||||
rows += `<tr><td class="aprs-popup-label">MMSI</td><td>${escapeMapHtml(String(msg.mmsi || "--"))}</td></tr>`;
|
||||
rows += `<tr><td class="aprs-popup-label">Type</td><td>${escapeMapHtml(String(msg.message_type || "--"))}</td></tr>`;
|
||||
if (msg?.sog_knots != null) rows += `<tr><td class="aprs-popup-label">SOG</td><td>${Number(msg.sog_knots).toFixed(1)} kn</td></tr>`;
|
||||
if (msg?.cog_deg != null) rows += `<tr><td class="aprs-popup-label">COG</td><td>${Number(msg.cog_deg).toFixed(1)}°</td></tr>`;
|
||||
if (msg?.lat != null && msg?.lon != null) rows += `<tr><td class="aprs-popup-label">Pos</td><td>${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</td></tr>`;
|
||||
const info = [msg?.vessel_name, msg?.callsign, msg?.destination].filter(Boolean).map(escapeMapHtml).join(" · ");
|
||||
return `<div class="aprs-popup">` +
|
||||
`<div class="aprs-popup-call">${escapeMapHtml(msg?.vessel_name || `MMSI ${msg?.mmsi || "--"}`)}</div>` +
|
||||
(meta ? `<div class="aprs-popup-meta">${meta}</div>` : "") +
|
||||
(rows ? `<table class="aprs-popup-table">${rows}</table>` : "") +
|
||||
(info ? `<div class="aprs-popup-info">${info}</div>` : "") +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function aprsPositionsEqual(a, b) {
|
||||
if (!a || !b) return false;
|
||||
return Math.abs(a[0] - b[0]) < 0.000001 && Math.abs(a[1] - b[1]) < 0.000001;
|
||||
@@ -3276,6 +3360,31 @@ window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCod
|
||||
}
|
||||
};
|
||||
|
||||
window.aisMapAddVessel = function(msg) {
|
||||
if (msg == null || msg.lat == null || msg.lon == null || !Number.isFinite(msg.mmsi)) return;
|
||||
if (!aprsMap) initAprsMap();
|
||||
const key = String(msg.mmsi);
|
||||
const popupHtml = buildAisPopupHtml(msg);
|
||||
const existing = aisMarkers.get(key);
|
||||
if (existing && existing.marker) {
|
||||
existing.msg = msg;
|
||||
existing.marker.setLatLng([msg.lat, msg.lon]);
|
||||
existing.marker.setPopupContent(popupHtml);
|
||||
return;
|
||||
}
|
||||
if (!aprsMap) return;
|
||||
const marker = L.circleMarker([msg.lat, msg.lon], {
|
||||
radius: 6,
|
||||
color: "#e2553d",
|
||||
fillColor: "#ff7559",
|
||||
fillOpacity: 0.82,
|
||||
}).addTo(aprsMap).bindPopup(popupHtml);
|
||||
marker.__trxType = "ais";
|
||||
mapMarkers.add(marker);
|
||||
aisMarkers.set(key, { marker, msg });
|
||||
applyMapFilter();
|
||||
};
|
||||
|
||||
function maidenheadToBounds(grid) {
|
||||
if (!grid || grid.length < 4) return null;
|
||||
const g = grid.toUpperCase();
|
||||
@@ -3310,6 +3419,7 @@ function applyMapFilter() {
|
||||
mapMarkers.forEach((marker) => {
|
||||
const type = marker.__trxType;
|
||||
const visible =
|
||||
(type === "ais" && mapFilter.ais) ||
|
||||
(type === "aprs" && mapFilter.aprs) ||
|
||||
(type === "ft8" && mapFilter.ft8) ||
|
||||
(type === "wspr" && mapFilter.wspr);
|
||||
@@ -3999,15 +4109,18 @@ document.getElementById("copyright-year").textContent = new Date().getFullYear()
|
||||
let decodeSource = null;
|
||||
let decodeConnected = false;
|
||||
function updateDecodeStatus(text) {
|
||||
const ais = document.getElementById("ais-status");
|
||||
const aprs = document.getElementById("aprs-status");
|
||||
const cw = document.getElementById("cw-status");
|
||||
const ft8 = document.getElementById("ft8-status");
|
||||
if (ais && ais.textContent !== "Receiving") ais.textContent = text;
|
||||
if (aprs && aprs.textContent !== "Receiving") aprs.textContent = text;
|
||||
if (cw && cw.textContent !== "Receiving") cw.textContent = text;
|
||||
if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text;
|
||||
}
|
||||
function connectDecode() {
|
||||
if (decodeSource) { decodeSource.close(); }
|
||||
if (window.resetAisHistoryView) window.resetAisHistoryView();
|
||||
if (window.resetAprsHistoryView) window.resetAprsHistoryView();
|
||||
if (window.resetCwHistoryView) window.resetCwHistoryView();
|
||||
if (window.resetFt8HistoryView) window.resetFt8HistoryView();
|
||||
@@ -4020,6 +4133,7 @@ function connectDecode() {
|
||||
decodeSource.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data);
|
||||
if (msg.type === "ais" && window.onServerAis) window.onServerAis(msg);
|
||||
if (msg.type === "aprs" && window.onServerAprs) window.onServerAprs(msg);
|
||||
if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
|
||||
if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg);
|
||||
@@ -4796,9 +4910,10 @@ function drawSpectrum(data) {
|
||||
// ── BW strip (drawn before spectrum so traces appear on top) ──────────────
|
||||
if (lastFreqHz != null && currentBandwidthHz > 0) {
|
||||
if (_bwDragEdge) {
|
||||
const xMid = hzToX(lastFreqHz);
|
||||
// Bottom bookmark tab centered on the dial frequency, shown only while resizing BW
|
||||
// Bottom bookmark tab centered on each visible channel, shown while resizing BW
|
||||
const bwText = formatBwLabel(currentBandwidthHz);
|
||||
for (const centerHz of visibleBandwidthCenters(lastFreqHz)) {
|
||||
const xMid = hzToX(centerHz);
|
||||
ctx.save();
|
||||
ctx.font = `bold ${Math.round(10 * dpr)}px sans-serif`;
|
||||
const tw = ctx.measureText(bwText).width;
|
||||
@@ -4828,6 +4943,7 @@ function drawSpectrum(data) {
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Spectrum fill ─────────────────────────────────────────────────────────
|
||||
ctx.save();
|
||||
@@ -5188,11 +5304,24 @@ if (overviewCanvas) {
|
||||
function getBwEdgeHit(cssX, cssW, range) {
|
||||
if (!lastFreqHz || !currentBandwidthHz || !lastSpectrumData) return null;
|
||||
const halfBw = currentBandwidthHz / 2;
|
||||
const xL = ((lastFreqHz - halfBw - range.visLoHz) / range.visSpanHz) * cssW;
|
||||
const xR = ((lastFreqHz + halfBw - range.visLoHz) / range.visSpanHz) * cssW;
|
||||
const HIT = 8;
|
||||
if (Math.abs(cssX - xL) < HIT) return "left";
|
||||
if (Math.abs(cssX - xR) < HIT) return "right";
|
||||
let bestEdge = null;
|
||||
let bestDist = Number.POSITIVE_INFINITY;
|
||||
for (const centerHz of visibleBandwidthCenters(lastFreqHz)) {
|
||||
const xL = ((centerHz - halfBw - range.visLoHz) / range.visSpanHz) * cssW;
|
||||
const xR = ((centerHz + halfBw - range.visLoHz) / range.visSpanHz) * cssW;
|
||||
const distL = Math.abs(cssX - xL);
|
||||
const distR = Math.abs(cssX - xR);
|
||||
if (distL < HIT && distL < bestDist) {
|
||||
bestEdge = "left";
|
||||
bestDist = distL;
|
||||
}
|
||||
if (distR < HIT && distR < bestDist) {
|
||||
bestEdge = "right";
|
||||
bestDist = distR;
|
||||
}
|
||||
}
|
||||
if (bestEdge) return bestEdge;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
<div class="overview-strip">
|
||||
<canvas id="overview-canvas" aria-hidden="true"></canvas>
|
||||
<div id="rds-ps-overlay" aria-live="polite" aria-label="RDS station name"></div>
|
||||
<div id="ais-bar-overlay" aria-live="polite" aria-label="Recent AIS messages"></div>
|
||||
<div id="aprs-bar-overlay" aria-live="polite" aria-label="Recent APRS frames"></div>
|
||||
</div>
|
||||
<div id="spectrum-bookmark-axis"></div>
|
||||
@@ -348,6 +349,7 @@
|
||||
<div id="tab-plugins" class="tab-panel" style="display:none;">
|
||||
<div class="sub-tab-bar">
|
||||
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
||||
<button class="sub-tab" data-subtab="ais">AIS</button>
|
||||
<button class="sub-tab" data-subtab="aprs">APRS</button>
|
||||
<button class="sub-tab" data-subtab="cw">CW</button>
|
||||
<button class="sub-tab" data-subtab="ft8">FT8</button>
|
||||
@@ -355,6 +357,12 @@
|
||||
<button class="sub-tab" data-subtab="rds">RDS</button>
|
||||
</div>
|
||||
<div id="subtab-overview" class="sub-tab-panel">
|
||||
<div class="plugin-item">
|
||||
<strong>AIS Decoder</strong>
|
||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||
Decodes dual-channel AIS traffic from RX audio using 9.6 kbit/s GMSK and HDLC.
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-item">
|
||||
<strong>APRS Decoder</strong>
|
||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||
@@ -411,6 +419,14 @@
|
||||
</div>
|
||||
<pre id="rds-raw" class="rds-raw">--</pre>
|
||||
</div>
|
||||
<div id="subtab-ais" class="sub-tab-panel" style="display:none;">
|
||||
<div class="aprs-controls">
|
||||
<button id="ais-clear-btn" type="button">Clear</button>
|
||||
<input id="ais-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. MMSI, vessel, A)" />
|
||||
<small id="ais-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||
</div>
|
||||
<div id="ais-messages"></div>
|
||||
</div>
|
||||
<div id="subtab-aprs" class="sub-tab-panel" style="display:none;">
|
||||
<div class="aprs-controls">
|
||||
<button id="aprs-clear-btn" type="button">Clear</button>
|
||||
@@ -469,6 +485,7 @@
|
||||
</div>
|
||||
<div id="tab-map" class="tab-panel" style="display:none;">
|
||||
<div class="map-controls">
|
||||
<label><input type="checkbox" id="map-filter-ais" checked /> AIS</label>
|
||||
<label><input type="checkbox" id="map-filter-aprs" checked /> APRS</label>
|
||||
<label><input type="checkbox" id="map-filter-ft8" checked /> FT8</label>
|
||||
<label><input type="checkbox" id="map-filter-wspr" checked /> WSPR</label>
|
||||
@@ -504,6 +521,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
<script src="/ais.js"></script>
|
||||
<script src="/aprs.js"></script>
|
||||
<script src="/ft8.js"></script>
|
||||
<script src="/wspr.js"></script>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// --- AIS Decoder Plugin (server-side decode) ---
|
||||
const aisStatus = document.getElementById("ais-status");
|
||||
const aisMessagesEl = document.getElementById("ais-messages");
|
||||
const aisFilterInput = document.getElementById("ais-filter");
|
||||
const aisClearBtn = document.getElementById("ais-clear-btn");
|
||||
const aisBarOverlay = document.getElementById("ais-bar-overlay");
|
||||
const AIS_MAX_MESSAGES = 200;
|
||||
const AIS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||
let aisFilterText = "";
|
||||
let aisMessageHistory = [];
|
||||
|
||||
function aisDisplayName(msg) {
|
||||
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
||||
}
|
||||
|
||||
function renderAisRow(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ais-message";
|
||||
const ts = msg._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const name = aisDisplayName(msg);
|
||||
const channel = msg.channel ? `AIS-${msg.channel}` : "AIS";
|
||||
const pos = msg.lat != null && msg.lon != null
|
||||
? ` <a class="aprs-pos" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>`
|
||||
: "";
|
||||
const motion = [
|
||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
|
||||
].filter(Boolean).join(" · ");
|
||||
row.dataset.filterText = [
|
||||
name,
|
||||
msg.mmsi,
|
||||
msg.channel,
|
||||
msg.vessel_name,
|
||||
msg.callsign,
|
||||
msg.destination,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
row.innerHTML =
|
||||
`<span class="ais-time">${ts}</span>` +
|
||||
`<span class="ais-call">${escapeMapHtml(name)}</span> ` +
|
||||
`<span class="aprs-time">[${escapeMapHtml(channel)}]</span> ` +
|
||||
`<span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` +
|
||||
(motion ? ` <span>${escapeMapHtml(motion)}</span>` : "") +
|
||||
pos;
|
||||
applyAisFilterToRow(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function applyAisFilterToRow(row) {
|
||||
if (!aisFilterText) {
|
||||
row.style.display = "";
|
||||
return;
|
||||
}
|
||||
const message = row.dataset.filterText || "";
|
||||
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
||||
}
|
||||
|
||||
function applyAisFilterToAll() {
|
||||
if (!aisMessagesEl) return;
|
||||
const rows = aisMessagesEl.querySelectorAll(".ais-message");
|
||||
rows.forEach((row) => applyAisFilterToRow(row));
|
||||
}
|
||||
|
||||
function updateAisBar() {
|
||||
if (!aisBarOverlay) return;
|
||||
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||
const messages = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
|
||||
if (!isAis || messages.length === 0) {
|
||||
aisBarOverlay.style.display = "none";
|
||||
aisBarOverlay.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">AIS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAisBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAisBar();}" aria-label="Clear AIS overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>';
|
||||
for (const msg of messages) {
|
||||
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||
const pin = msg.lat != null && msg.lon != null
|
||||
? `<button class="aprs-bar-pin" title="${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">📍</button>`
|
||||
: "";
|
||||
const name = `<span class="ais-call">${escapeMapHtml(aisDisplayName(msg))}</span>`;
|
||||
const channel = msg.channel ? ` AIS-${escapeMapHtml(msg.channel)}` : "";
|
||||
const details = [
|
||||
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
|
||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
|
||||
].filter(Boolean).join(" · ");
|
||||
html += `<div class="aprs-bar-frame">` +
|
||||
`<div class="aprs-bar-frame-main">${ts}${pin}${name}${channel}: ${details}</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
aisBarOverlay.innerHTML = html;
|
||||
aisBarOverlay.style.display = "flex";
|
||||
}
|
||||
window.updateAisBar = updateAisBar;
|
||||
window.clearAisBar = function() {
|
||||
window.resetAisHistoryView();
|
||||
};
|
||||
|
||||
window.resetAisHistoryView = function() {
|
||||
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
||||
aisMessageHistory = [];
|
||||
updateAisBar();
|
||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais");
|
||||
};
|
||||
|
||||
function addAisMessage(msg) {
|
||||
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
msg._tsMs = tsMs;
|
||||
msg._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
|
||||
aisMessageHistory.unshift(msg);
|
||||
if (aisMessageHistory.length > AIS_MAX_MESSAGES) aisMessageHistory.length = AIS_MAX_MESSAGES;
|
||||
updateAisBar();
|
||||
|
||||
if (aisMessagesEl) {
|
||||
const row = renderAisRow(msg);
|
||||
aisMessagesEl.prepend(row);
|
||||
while (aisMessagesEl.children.length > AIS_MAX_MESSAGES) {
|
||||
aisMessagesEl.removeChild(aisMessagesEl.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) {
|
||||
window.aisMapAddVessel(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if (aisClearBtn) {
|
||||
aisClearBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await postPath("/clear_ais_decode");
|
||||
window.resetAisHistoryView();
|
||||
} catch (e) {
|
||||
console.error("AIS clear failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (aisFilterInput) {
|
||||
aisFilterInput.addEventListener("input", () => {
|
||||
aisFilterText = aisFilterInput.value.trim().toUpperCase();
|
||||
applyAisFilterToAll();
|
||||
});
|
||||
}
|
||||
|
||||
window.onServerAis = function(msg) {
|
||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||
addAisMessage({
|
||||
channel: msg.channel,
|
||||
message_type: msg.message_type,
|
||||
mmsi: msg.mmsi,
|
||||
lat: msg.lat,
|
||||
lon: msg.lon,
|
||||
sog_knots: msg.sog_knots,
|
||||
cog_deg: msg.cog_deg,
|
||||
heading_deg: msg.heading_deg,
|
||||
vessel_name: msg.vessel_name,
|
||||
callsign: msg.callsign,
|
||||
destination: msg.destination,
|
||||
ts_ms: msg.ts_ms,
|
||||
});
|
||||
};
|
||||
@@ -629,7 +629,8 @@ small { color: var(--text-muted); }
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--card-bg) 62%, transparent);
|
||||
}
|
||||
#aprs-bar-overlay {
|
||||
#aprs-bar-overlay,
|
||||
#ais-bar-overlay {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -1087,11 +1088,16 @@ small { color: var(--text-muted); }
|
||||
.sub-tab:hover:not(.active) { color: var(--text); }
|
||||
#aprs-map { min-height: 150px; border-radius: 6px; }
|
||||
.aprs-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
|
||||
#aprs-packets { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
#aprs-packets,
|
||||
#ais-messages { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
.aprs-packet { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--border); line-height: 1.4; }
|
||||
.aprs-packet:last-child { border-bottom: none; }
|
||||
.ais-message { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--border); line-height: 1.4; }
|
||||
.ais-message:last-child { border-bottom: none; }
|
||||
.aprs-call { color: var(--accent-green); font-weight: 600; }
|
||||
.ais-call { color: var(--accent-red); font-weight: 600; }
|
||||
.aprs-time { color: var(--text-muted); margin-right: 0.5rem; }
|
||||
.ais-time { color: var(--text-muted); margin-right: 0.5rem; }
|
||||
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; }
|
||||
.aprs-pos { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; }
|
||||
.aprs-pos:hover { text-decoration: underline; }
|
||||
|
||||
@@ -258,6 +258,11 @@ pub async fn decode_events(
|
||||
|
||||
let history = {
|
||||
let mut out = Vec::new();
|
||||
out.extend(
|
||||
crate::server::audio::snapshot_ais_history(context.get_ref())
|
||||
.into_iter()
|
||||
.map(trx_core::decode::DecodedMessage::Ais),
|
||||
);
|
||||
out.extend(
|
||||
crate::server::audio::snapshot_aprs_history(context.get_ref())
|
||||
.into_iter()
|
||||
@@ -685,6 +690,14 @@ pub async fn clear_aprs_decode(
|
||||
send_command(&rig_tx, RigCommand::ResetAprsDecoder).await
|
||||
}
|
||||
|
||||
#[post("/clear_ais_decode")]
|
||||
pub async fn clear_ais_decode(
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_ais_history(context.get_ref());
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
#[post("/clear_cw_decode")]
|
||||
pub async fn clear_cw_decode(
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
@@ -938,6 +951,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(set_cw_tone)
|
||||
.service(toggle_ft8_decode)
|
||||
.service(toggle_wspr_decode)
|
||||
.service(clear_ais_decode)
|
||||
.service(clear_aprs_decode)
|
||||
.service(clear_cw_decode)
|
||||
.service(clear_ft8_decode)
|
||||
@@ -954,6 +968,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(logo)
|
||||
.service(style_css)
|
||||
.service(app_js)
|
||||
.service(ais_js)
|
||||
.service(aprs_js)
|
||||
.service(ft8_js)
|
||||
.service(wspr_js)
|
||||
@@ -1020,6 +1035,16 @@ async fn aprs_js() -> impl Responder {
|
||||
.body(status::APRS_JS)
|
||||
}
|
||||
|
||||
#[get("/ais.js")]
|
||||
async fn ais_js() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
))
|
||||
.body(status::AIS_JS)
|
||||
}
|
||||
|
||||
#[get("/ft8.js")]
|
||||
async fn ft8_js() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
|
||||
@@ -20,7 +20,7 @@ use bytes::Bytes;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::warn;
|
||||
|
||||
use trx_core::decode::{AprsPacket, CwEvent, DecodedMessage, Ft8Message, WsprMessage};
|
||||
use trx_core::decode::{AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, WsprMessage};
|
||||
use trx_frontend::FrontendRuntimeContext;
|
||||
|
||||
const HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
@@ -34,6 +34,24 @@ fn prune_aprs_history(history: &mut VecDeque<(Instant, AprsPacket)>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_ais_history(history: &mut VecDeque<(Instant, AisMessage)>) {
|
||||
while let Some((ts, _)) = history.front() {
|
||||
if ts.elapsed() <= HISTORY_RETENTION {
|
||||
break;
|
||||
}
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn record_ais(context: &FrontendRuntimeContext, msg: AisMessage) {
|
||||
let mut history = context
|
||||
.ais_history
|
||||
.lock()
|
||||
.expect("ais history mutex poisoned");
|
||||
history.push_back((Instant::now(), msg));
|
||||
prune_ais_history(&mut history);
|
||||
}
|
||||
|
||||
fn prune_cw_history(history: &mut VecDeque<(Instant, CwEvent)>) {
|
||||
while let Some((ts, _)) = history.front() {
|
||||
if ts.elapsed() <= HISTORY_RETENTION {
|
||||
@@ -113,6 +131,22 @@ pub fn snapshot_aprs_history(context: &FrontendRuntimeContext) -> Vec<AprsPacket
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn snapshot_ais_history(context: &FrontendRuntimeContext) -> Vec<AisMessage> {
|
||||
let mut history = context
|
||||
.ais_history
|
||||
.lock()
|
||||
.expect("ais history mutex poisoned");
|
||||
prune_ais_history(&mut history);
|
||||
history
|
||||
.iter()
|
||||
.map(|(ts, msg)| {
|
||||
let mut msg = msg.clone();
|
||||
msg.ts_ms = Some(timestamp_ms_for_elapsed(ts.elapsed()));
|
||||
msg
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn snapshot_cw_history(context: &FrontendRuntimeContext) -> Vec<CwEvent> {
|
||||
let mut history = context
|
||||
.cw_history
|
||||
@@ -148,6 +182,14 @@ pub fn clear_aprs_history(context: &FrontendRuntimeContext) {
|
||||
history.clear();
|
||||
}
|
||||
|
||||
pub fn clear_ais_history(context: &FrontendRuntimeContext) {
|
||||
let mut history = context
|
||||
.ais_history
|
||||
.lock()
|
||||
.expect("ais history mutex poisoned");
|
||||
history.clear();
|
||||
}
|
||||
|
||||
fn timestamp_ms_for_elapsed(elapsed: Duration) -> i64 {
|
||||
let wall_clock = SystemTime::now()
|
||||
.checked_sub(elapsed)
|
||||
@@ -206,6 +248,7 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(msg) => match msg {
|
||||
DecodedMessage::Ais(msg) => record_ais(&context, msg),
|
||||
DecodedMessage::Aprs(pkt) => record_aprs(&context, pkt),
|
||||
DecodedMessage::Cw(evt) => record_cw(&context, evt),
|
||||
DecodedMessage::Ft8(msg) => record_ft8(&context, msg),
|
||||
|
||||
@@ -9,6 +9,7 @@ const CLIENT_BUILD_DATE: &str = env!("TRX_CLIENT_BUILD_DATE");
|
||||
const INDEX_HTML: &str = include_str!("../assets/web/index.html");
|
||||
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
|
||||
pub const APP_JS: &str = include_str!("../assets/web/app.js");
|
||||
pub const AIS_JS: &str = include_str!("../assets/web/plugins/ais.js");
|
||||
pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js");
|
||||
pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");
|
||||
pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
|
||||
|
||||
@@ -15,6 +15,7 @@ pub const AUDIO_MSG_APRS_DECODE: u8 = 0x03;
|
||||
pub const AUDIO_MSG_CW_DECODE: u8 = 0x04;
|
||||
pub const AUDIO_MSG_FT8_DECODE: u8 = 0x05;
|
||||
pub const AUDIO_MSG_WSPR_DECODE: u8 = 0x06;
|
||||
pub const AUDIO_MSG_AIS_DECODE: u8 = 0x07;
|
||||
|
||||
/// Maximum payload size (1 MB) to reject bogus frames early.
|
||||
const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
//! Shared types for server-side decoded messages (APRS, CW).
|
||||
//! Shared types for server-side decoded messages (APRS, AIS, CW).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize};
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum DecodedMessage {
|
||||
#[serde(rename = "ais")]
|
||||
Ais(AisMessage),
|
||||
#[serde(rename = "aprs")]
|
||||
Aprs(AprsPacket),
|
||||
#[serde(rename = "cw")]
|
||||
@@ -20,6 +22,37 @@ pub enum DecodedMessage {
|
||||
Wspr(WsprMessage),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AisMessage {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ts_ms: Option<i64>,
|
||||
pub channel: String,
|
||||
pub message_type: u8,
|
||||
pub repeat: u8,
|
||||
pub mmsi: u32,
|
||||
pub crc_ok: bool,
|
||||
pub bit_len: usize,
|
||||
pub raw_bytes: Vec<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lat: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lon: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sog_knots: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cog_deg: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub heading_deg: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nav_status: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vessel_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub callsign: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub destination: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AprsPacket {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -72,6 +72,18 @@ pub trait AudioSource: Send + Sync {
|
||||
/// Subscribe to demodulated PCM audio from the primary channel.
|
||||
/// Returns a broadcast receiver that yields 20ms frames of mono f32 PCM.
|
||||
fn subscribe_pcm(&self) -> tokio::sync::broadcast::Receiver<Vec<f32>>;
|
||||
|
||||
/// Subscribe to PCM from a specific backend channel when available.
|
||||
/// Channel `0` is always the primary channel.
|
||||
fn subscribe_pcm_channel(&self, channel_idx: usize) -> tokio::sync::broadcast::Receiver<Vec<f32>> {
|
||||
if channel_idx == 0 {
|
||||
self.subscribe_pcm()
|
||||
} else {
|
||||
let (tx, rx) = tokio::sync::broadcast::channel(1);
|
||||
drop(tx);
|
||||
rx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Common interface for rig backends.
|
||||
|
||||
@@ -70,6 +70,7 @@ pub enum RigMode {
|
||||
AM,
|
||||
WFM,
|
||||
FM,
|
||||
AIS,
|
||||
DIG,
|
||||
PKT,
|
||||
Other(String),
|
||||
|
||||
@@ -11,7 +11,7 @@ use trx_core::rig::state::RigMode;
|
||||
|
||||
/// Parse a mode string into a RigMode.
|
||||
///
|
||||
/// Handles LSB, USB, CW, CWR, AM, FM, WFM, DIG, DIGI, PKT, PACKET.
|
||||
/// Handles LSB, USB, CW, CWR, AM, FM, WFM, AIS, DIG, DIGI, PKT, PACKET.
|
||||
/// Falls back to Other(string) for unknown modes.
|
||||
pub fn parse_mode(s: &str) -> RigMode {
|
||||
match s.to_uppercase().as_str() {
|
||||
@@ -22,6 +22,7 @@ pub fn parse_mode(s: &str) -> RigMode {
|
||||
"AM" => RigMode::AM,
|
||||
"FM" => RigMode::FM,
|
||||
"WFM" => RigMode::WFM,
|
||||
"AIS" => RigMode::AIS,
|
||||
"DIG" | "DIGI" => RigMode::DIG,
|
||||
"PKT" | "PACKET" => RigMode::PKT,
|
||||
other => RigMode::Other(other.to_string()),
|
||||
@@ -41,6 +42,7 @@ pub fn mode_to_string(mode: &RigMode) -> String {
|
||||
RigMode::AM => "AM".to_string(),
|
||||
RigMode::FM => "FM".to_string(),
|
||||
RigMode::WFM => "WFM".to_string(),
|
||||
RigMode::AIS => "AIS".to_string(),
|
||||
RigMode::DIG => "DIG".to_string(),
|
||||
RigMode::PKT => "PKT".to_string(),
|
||||
RigMode::Other(s) => s.clone(),
|
||||
@@ -78,6 +80,7 @@ mod tests {
|
||||
assert_eq!(parse_mode("AM"), RigMode::AM);
|
||||
assert_eq!(parse_mode("FM"), RigMode::FM);
|
||||
assert_eq!(parse_mode("WFM"), RigMode::WFM);
|
||||
assert_eq!(parse_mode("AIS"), RigMode::AIS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -122,6 +125,7 @@ mod tests {
|
||||
assert_eq!(mode_to_string(&RigMode::AM), "AM");
|
||||
assert_eq!(mode_to_string(&RigMode::FM), "FM");
|
||||
assert_eq!(mode_to_string(&RigMode::WFM), "WFM");
|
||||
assert_eq!(mode_to_string(&RigMode::AIS), "AIS");
|
||||
assert_eq!(mode_to_string(&RigMode::DIG), "DIG");
|
||||
assert_eq!(mode_to_string(&RigMode::PKT), "PKT");
|
||||
}
|
||||
@@ -141,6 +145,7 @@ mod tests {
|
||||
RigMode::AM,
|
||||
RigMode::FM,
|
||||
RigMode::WFM,
|
||||
RigMode::AIS,
|
||||
RigMode::DIG,
|
||||
RigMode::PKT,
|
||||
];
|
||||
|
||||
@@ -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