[feat](trx-rs): split VDES frontend and decoder path
Add a dedicated VDES plugin tab and live bar, stop reusing the AIS vessel UI, and serve a separate VDES frontend script. Rework the SDR backend so VDES receives a single 100 kHz IQ tap, then replace the fake AIS-clone decoder path with an early M.2092-1 oriented complex-baseband scaffold using burst detection, coarse pi/4-QPSK slicing, and TER-MCS-1.100 frame heuristics. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
Generated
+2
@@ -2591,6 +2591,7 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"cpal",
|
"cpal",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"num-complex",
|
||||||
"opus",
|
"opus",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2615,6 +2616,7 @@ dependencies = [
|
|||||||
name = "trx-vdes"
|
name = "trx-vdes"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"num-complex",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
num-complex = "0.4"
|
||||||
trx-core = { path = "../../trx-core" }
|
trx-core = { path = "../../trx-core" }
|
||||||
|
|||||||
+418
-429
@@ -2,505 +2,494 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
//! Basic VDES GMSK/HDLC decoder.
|
//! Early VDES 100 kHz decoder scaffold.
|
||||||
//!
|
//!
|
||||||
//! This decoder operates on narrowband FM-demodulated audio. It uses a simple
|
//! This decoder no longer reuses the AIS FM-audio path. It consumes filtered
|
||||||
//! sign slicer at the symbol rate, HDLC flag detection with NRZI decoding and
|
//! complex baseband for a single 100 kHz channel and performs:
|
||||||
//! bit de-stuffing, then parses the same position/static fields used by the
|
//! - burst energy detection
|
||||||
//! current AIS decoder path.
|
//! - coarse DC removal / normalization
|
||||||
|
//! - differential phase extraction
|
||||||
|
//! - coarse symbol timing at the 76.8 ksps VDE-TER baseline
|
||||||
|
//! - `pi/4`-QPSK quadrant slicing
|
||||||
|
//!
|
||||||
|
//! It intentionally stops at a raw burst payload stage. Full M.2092-1 FEC,
|
||||||
|
//! interleaving, link-layer parsing, and application payload decoding are not
|
||||||
|
//! implemented yet.
|
||||||
|
|
||||||
|
use num_complex::Complex;
|
||||||
use trx_core::decode::VdesMessage;
|
use trx_core::decode::VdesMessage;
|
||||||
|
|
||||||
const VDES_BAUD: f32 = 9_600.0;
|
const VDES_SYMBOL_RATE: f32 = 76_800.0;
|
||||||
|
const MIN_BURST_MS: f32 = 2.0;
|
||||||
const CRC_CCITT_TABLE: [u16; 256] = {
|
const BURST_END_MS: f32 = 0.4;
|
||||||
let mut table = [0u16; 256];
|
const MIN_BURST_SYMBOLS: usize = 64;
|
||||||
let mut i = 0usize;
|
const TER_MCS1_100_BURST_SYMBOLS: usize = 1_984;
|
||||||
while i < 256 {
|
const TER_MCS1_100_RAMP_SYMBOLS: usize = 32;
|
||||||
let mut crc = i as u16;
|
const TER_MCS1_100_SYNC_SYMBOLS: usize = 27;
|
||||||
let mut j = 0;
|
const TER_MCS1_100_LINK_ID_SYMBOLS: usize = 16;
|
||||||
while j < 8 {
|
const TER_MCS1_100_PAYLOAD_SYMBOLS: usize = 1_877;
|
||||||
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct VdesDecoder {
|
pub struct VdesDecoder {
|
||||||
sample_rate: f32,
|
sample_rate: f32,
|
||||||
samples_per_symbol: f32,
|
noise_floor: f32,
|
||||||
sample_clock: f32,
|
in_burst: bool,
|
||||||
dc_state: f32,
|
quiet_run: u32,
|
||||||
lp_fast: f32,
|
burst_samples: Vec<Complex<f32>>,
|
||||||
lp_slow: f32,
|
|
||||||
env_state: f32,
|
|
||||||
polarity: i8,
|
|
||||||
samples_since_transition: u32,
|
|
||||||
clock_locked: bool,
|
|
||||||
prev_raw_bit: u8,
|
|
||||||
ones: u32,
|
|
||||||
in_frame: bool,
|
|
||||||
frame_bits: Vec<u8>,
|
|
||||||
frames: Vec<RawFrame>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VdesDecoder {
|
impl VdesDecoder {
|
||||||
pub fn new(sample_rate: u32) -> Self {
|
pub fn new(sample_rate: u32) -> Self {
|
||||||
let sample_rate = sample_rate.max(1) as f32;
|
|
||||||
Self {
|
Self {
|
||||||
sample_rate,
|
sample_rate: sample_rate.max(1) as f32,
|
||||||
samples_per_symbol: sample_rate / VDES_BAUD,
|
noise_floor: 1.0e-4,
|
||||||
sample_clock: 0.0,
|
in_burst: false,
|
||||||
dc_state: 0.0,
|
quiet_run: 0,
|
||||||
lp_fast: 0.0,
|
burst_samples: Vec::new(),
|
||||||
lp_slow: 0.0,
|
|
||||||
env_state: 1e-3,
|
|
||||||
polarity: 1,
|
|
||||||
samples_since_transition: 0,
|
|
||||||
clock_locked: false,
|
|
||||||
prev_raw_bit: 0,
|
|
||||||
ones: 0,
|
|
||||||
in_frame: false,
|
|
||||||
frame_bits: Vec::new(),
|
|
||||||
frames: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.samples_per_symbol = self.sample_rate / VDES_BAUD;
|
self.noise_floor = 1.0e-4;
|
||||||
self.sample_clock = 0.0;
|
self.in_burst = false;
|
||||||
self.dc_state = 0.0;
|
self.quiet_run = 0;
|
||||||
self.lp_fast = 0.0;
|
self.burst_samples.clear();
|
||||||
self.lp_slow = 0.0;
|
|
||||||
self.env_state = 1e-3;
|
|
||||||
self.polarity = 1;
|
|
||||||
self.samples_since_transition = 0;
|
|
||||||
self.clock_locked = false;
|
|
||||||
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<VdesMessage> {
|
pub fn process_samples(&mut self, samples: &[Complex<f32>], channel: &str) -> Vec<VdesMessage> {
|
||||||
for &sample in samples {
|
|
||||||
self.process_sample(sample);
|
|
||||||
}
|
|
||||||
|
|
||||||
let frames = std::mem::take(&mut self.frames);
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for frame in frames {
|
let min_burst_samples =
|
||||||
if let Some(msg) = parse_frame(frame, channel) {
|
((self.sample_rate * (MIN_BURST_MS / 1000.0)).round() as usize).max(16);
|
||||||
out.push(msg);
|
let quiet_limit =
|
||||||
|
((self.sample_rate * (BURST_END_MS / 1000.0)).round() as u32).max(4);
|
||||||
|
|
||||||
|
for &sample in samples {
|
||||||
|
let power = sample.norm_sqr();
|
||||||
|
if !self.in_burst {
|
||||||
|
self.noise_floor = 0.995 * self.noise_floor + 0.005 * power;
|
||||||
|
let trigger = (self.noise_floor * 8.0).max(2.0e-4);
|
||||||
|
if power >= trigger {
|
||||||
|
self.in_burst = true;
|
||||||
|
self.quiet_run = 0;
|
||||||
|
self.burst_samples.clear();
|
||||||
|
self.burst_samples.push(sample);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.burst_samples.push(sample);
|
||||||
|
let sustain = (self.noise_floor * 3.0).max(1.2e-4);
|
||||||
|
if power < sustain {
|
||||||
|
self.quiet_run = self.quiet_run.saturating_add(1);
|
||||||
|
} else {
|
||||||
|
self.quiet_run = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.quiet_run >= quiet_limit {
|
||||||
|
if self.burst_samples.len() >= min_burst_samples {
|
||||||
|
if let Some(msg) = self.finalize_burst(channel) {
|
||||||
|
out.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.in_burst = false;
|
||||||
|
self.quiet_run = 0;
|
||||||
|
self.burst_samples.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_sample(&mut self, sample: f32) {
|
fn finalize_burst(&self, channel: &str) -> Option<VdesMessage> {
|
||||||
// Remove slow DC drift from the FM discriminator output.
|
let samples = self.prepare_burst();
|
||||||
self.dc_state += 0.0025 * (sample - self.dc_state);
|
if samples.len() < 8 {
|
||||||
let dc_free = sample - self.dc_state;
|
|
||||||
|
|
||||||
// A simple band-pass-ish response makes GMSK symbol transitions stand out
|
|
||||||
// without needing a full matched filter.
|
|
||||||
self.lp_fast += 0.32 * (dc_free - self.lp_fast);
|
|
||||||
self.lp_slow += 0.045 * (dc_free - self.lp_slow);
|
|
||||||
let shaped = self.lp_fast - self.lp_slow;
|
|
||||||
|
|
||||||
// Track envelope to keep the slicer stable on weak signals.
|
|
||||||
self.env_state += 0.015 * (shaped.abs() - self.env_state);
|
|
||||||
let normalized = if self.env_state > 1e-4 {
|
|
||||||
shaped / self.env_state
|
|
||||||
} else {
|
|
||||||
shaped
|
|
||||||
};
|
|
||||||
|
|
||||||
let threshold = 0.12;
|
|
||||||
let next_polarity = if normalized > threshold {
|
|
||||||
1
|
|
||||||
} else if normalized < -threshold {
|
|
||||||
-1
|
|
||||||
} else {
|
|
||||||
self.polarity
|
|
||||||
};
|
|
||||||
|
|
||||||
self.samples_since_transition = self.samples_since_transition.saturating_add(1);
|
|
||||||
if next_polarity != self.polarity {
|
|
||||||
self.observe_transition();
|
|
||||||
self.polarity = next_polarity;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.clock_locked {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.sample_clock += 1.0;
|
|
||||||
while self.sample_clock >= self.samples_per_symbol {
|
|
||||||
self.sample_clock -= self.samples_per_symbol;
|
|
||||||
let raw_bit = if self.polarity >= 0 { 1 } else { 0 };
|
|
||||||
self.process_symbol(raw_bit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn observe_transition(&mut self) {
|
|
||||||
let interval = self.samples_since_transition.max(1) as f32;
|
|
||||||
self.samples_since_transition = 0;
|
|
||||||
|
|
||||||
let nominal = (self.sample_rate / VDES_BAUD).max(1.0);
|
|
||||||
let symbols = (interval / nominal).round().clamp(1.0, 8.0);
|
|
||||||
let estimate = (interval / symbols).clamp(nominal * 0.75, nominal * 1.25);
|
|
||||||
self.samples_per_symbol += 0.18 * (estimate - self.samples_per_symbol);
|
|
||||||
self.sample_clock = self.samples_per_symbol * 0.5;
|
|
||||||
self.clock_locked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
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;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let usable_bits = self.frame_bits.len() - (self.frame_bits.len() % 8);
|
let symbols = slice_pi4_qpsk_symbols(&samples, self.sample_rate);
|
||||||
if usable_bits < 24 {
|
if symbols.len() < MIN_BURST_SYMBOLS {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bits = self.frame_bits[..usable_bits].to_vec();
|
let framed = extract_candidate_frame(&symbols)?;
|
||||||
let mut bytes = Vec::with_capacity(usable_bits / 8);
|
let link_id = decode_link_id_from_symbols(&framed.symbols);
|
||||||
for chunk in bits.chunks(8) {
|
let payload_symbols = framed.payload_symbols();
|
||||||
let mut byte = 0u8;
|
let deinterleaved = deinterleave_100khz_frame(payload_symbols);
|
||||||
for (idx, &bit) in chunk.iter().enumerate() {
|
let raw_bytes = pack_dibits_msb(&deinterleaved);
|
||||||
if bit != 0 {
|
let rms = burst_rms(&samples);
|
||||||
byte |= 1 << idx;
|
let mode = classify_vdes_burst(framed.symbols.len());
|
||||||
}
|
let link_text = link_id
|
||||||
}
|
.map(|value| format!("LID {}", value))
|
||||||
bytes.push(byte);
|
.unwrap_or_else(|| "LID ?".to_string());
|
||||||
}
|
|
||||||
|
|
||||||
if bytes.len() < 3 {
|
Some(VdesMessage {
|
||||||
return None;
|
ts_ms: None,
|
||||||
}
|
channel: channel.to_string(),
|
||||||
|
message_type: mode.message_type,
|
||||||
let payload_len = bytes.len() - 2;
|
repeat: 0,
|
||||||
let payload = bytes[..payload_len].to_vec();
|
mmsi: 0,
|
||||||
let received_fcs = u16::from_le_bytes([bytes[payload_len], bytes[payload_len + 1]]);
|
crc_ok: false,
|
||||||
let crc_ok = crc16ccitt(&payload) == received_fcs;
|
bit_len: deinterleaved.len() * 2,
|
||||||
|
raw_bytes,
|
||||||
Some(RawFrame {
|
lat: None,
|
||||||
payload,
|
lon: None,
|
||||||
bits,
|
sog_knots: None,
|
||||||
crc_ok,
|
cog_deg: None,
|
||||||
|
heading_deg: None,
|
||||||
|
nav_status: None,
|
||||||
|
vessel_name: Some(format!("VDES Frame {} sym", framed.symbols.len())),
|
||||||
|
callsign: Some(format!("{} {} @{}", mode.label, link_text, framed.start_offset)),
|
||||||
|
destination: Some(format!(
|
||||||
|
"TER-MCS-1.100 RMS {:.2} sync {:.2} turbo FEC pending",
|
||||||
|
rms, framed.preamble_score
|
||||||
|
)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prepare_burst(&self) -> Vec<Complex<f32>> {
|
||||||
|
if self.burst_samples.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = self.burst_samples.len() as f32;
|
||||||
|
let mean = self
|
||||||
|
.burst_samples
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.fold(Complex::new(0.0_f32, 0.0_f32), |acc, sample| acc + sample)
|
||||||
|
/ len;
|
||||||
|
|
||||||
|
let mut out: Vec<Complex<f32>> = self
|
||||||
|
.burst_samples
|
||||||
|
.iter()
|
||||||
|
.map(|sample| *sample - mean)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let rms = burst_rms(&out);
|
||||||
|
if rms > 1.0e-6 {
|
||||||
|
for sample in &mut out {
|
||||||
|
*sample /= rms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_frame(frame: RawFrame, channel: &str) -> Option<VdesMessage> {
|
struct BurstMode<'a> {
|
||||||
if !frame.crc_ok {
|
label: &'a str,
|
||||||
return None;
|
message_type: u8,
|
||||||
}
|
|
||||||
|
|
||||||
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 = VdesMessage {
|
|
||||||
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> {
|
struct FrameSlice {
|
||||||
let mut bits = Vec::with_capacity(bytes.len() * 8);
|
start_offset: usize,
|
||||||
for &byte in bytes {
|
preamble_score: f32,
|
||||||
for shift in (0..8).rev() {
|
symbols: Vec<u8>,
|
||||||
bits.push((byte >> shift) & 1);
|
}
|
||||||
|
|
||||||
|
impl FrameSlice {
|
||||||
|
fn payload_symbols(&self) -> &[u8] {
|
||||||
|
let payload_start = TER_MCS1_100_RAMP_SYMBOLS + TER_MCS1_100_SYNC_SYMBOLS + TER_MCS1_100_LINK_ID_SYMBOLS;
|
||||||
|
let payload_end = payload_start + TER_MCS1_100_PAYLOAD_SYMBOLS;
|
||||||
|
if self.symbols.len() <= payload_start {
|
||||||
|
return &[];
|
||||||
}
|
}
|
||||||
|
&self.symbols[payload_start..self.symbols.len().min(payload_end)]
|
||||||
}
|
}
|
||||||
bits
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_uint(bits: &[u8], start: usize, len: usize) -> Option<u32> {
|
fn classify_vdes_burst(symbols: usize) -> BurstMode<'static> {
|
||||||
if len == 0 || start.checked_add(len)? > bits.len() || len > 32 {
|
if symbols >= TER_MCS1_100_BURST_SYMBOLS {
|
||||||
return None;
|
BurstMode {
|
||||||
}
|
label: "TER-MCS-1.100",
|
||||||
let mut out = 0u32;
|
message_type: 101,
|
||||||
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 {
|
} else {
|
||||||
Some((raw as i32) - ((1u32 << len) as i32))
|
BurstMode {
|
||||||
|
label: "TER-MCS-1",
|
||||||
|
message_type: 100,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_tenths(raw: u32, invalid: u32) -> Option<f32> {
|
fn extract_candidate_frame(symbols: &[u8]) -> Option<FrameSlice> {
|
||||||
if raw == invalid {
|
if symbols.len() < TER_MCS1_100_SYNC_SYMBOLS {
|
||||||
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;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut out = String::new();
|
let search_limit = symbols
|
||||||
for offset in (0..len).step_by(6) {
|
.len()
|
||||||
let value = get_uint(bits, start + offset, 6)? as u8;
|
.saturating_sub(TER_MCS1_100_BURST_SYMBOLS.saturating_sub(TER_MCS1_100_SYNC_SYMBOLS));
|
||||||
let ch = if value < 32 {
|
let mut best_offset = 0usize;
|
||||||
char::from(value + 64)
|
let mut best_score = f32::MIN;
|
||||||
|
|
||||||
|
for offset in 0..=search_limit {
|
||||||
|
let sync_offset = offset + TER_MCS1_100_RAMP_SYMBOLS;
|
||||||
|
if sync_offset >= symbols.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let score = preamble_like_score(&symbols[sync_offset..]);
|
||||||
|
if score > best_score {
|
||||||
|
best_score = score;
|
||||||
|
best_offset = offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let available = symbols.len().saturating_sub(best_offset);
|
||||||
|
if available < MIN_BURST_SYMBOLS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let take = available.min(TER_MCS1_100_BURST_SYMBOLS);
|
||||||
|
Some(FrameSlice {
|
||||||
|
start_offset: best_offset,
|
||||||
|
preamble_score: best_score,
|
||||||
|
symbols: symbols[best_offset..best_offset + take].to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preamble_like_score(symbols: &[u8]) -> f32 {
|
||||||
|
if symbols.len() < TER_MCS1_100_SYNC_SYMBOLS {
|
||||||
|
return f32::MIN;
|
||||||
|
}
|
||||||
|
let window = &symbols[..TER_MCS1_100_SYNC_SYMBOLS];
|
||||||
|
let mut score = 0.0_f32;
|
||||||
|
for (idx, &dibit) in window.iter().enumerate() {
|
||||||
|
if dibit == 0b00 || dibit == 0b11 {
|
||||||
|
score += 1.0;
|
||||||
} else {
|
} else {
|
||||||
char::from(value)
|
score -= 1.5;
|
||||||
};
|
}
|
||||||
if ch != '@' {
|
if idx > 0 {
|
||||||
out.push(ch);
|
if dibit != window[idx - 1] {
|
||||||
|
score += 0.4;
|
||||||
|
} else {
|
||||||
|
score -= 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
score / TER_MCS1_100_SYNC_SYMBOLS as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deinterleave_100khz_frame(symbols: &[u8]) -> Vec<u8> {
|
||||||
|
if symbols.len() < 8 {
|
||||||
|
return symbols.to_vec();
|
||||||
|
}
|
||||||
|
let cols = 16usize;
|
||||||
|
let rows = symbols.len().div_ceil(cols);
|
||||||
|
let mut out = vec![0u8; symbols.len()];
|
||||||
|
for idx in 0..symbols.len() {
|
||||||
|
let row = idx / cols;
|
||||||
|
let col = idx % cols;
|
||||||
|
let interleaved_idx = col * rows + row;
|
||||||
|
if interleaved_idx < symbols.len() {
|
||||||
|
out[idx] = symbols[interleaved_idx];
|
||||||
|
} else {
|
||||||
|
out[idx] = symbols[idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_link_id_from_symbols(symbols: &[u8]) -> Option<u8> {
|
||||||
|
let start = TER_MCS1_100_RAMP_SYMBOLS + TER_MCS1_100_SYNC_SYMBOLS;
|
||||||
|
let end = start + TER_MCS1_100_LINK_ID_SYMBOLS;
|
||||||
|
if symbols.len() < end {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bits = dibits_to_bits(&symbols[start..end]);
|
||||||
|
if bits.len() != 32 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
decode_rm_1_5(&bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dibits_to_bits(symbols: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(symbols.len() * 2);
|
||||||
|
for &dibit in symbols {
|
||||||
|
out.push((dibit >> 1) & 1);
|
||||||
|
out.push(dibit & 1);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_rm_1_5(bits: &[u8]) -> Option<u8> {
|
||||||
|
if bits.len() != 32 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut best_id = 0u8;
|
||||||
|
let mut best_dist = usize::MAX;
|
||||||
|
for id in 0u8..64 {
|
||||||
|
let code = rm_1_5_codeword(id);
|
||||||
|
let dist = code
|
||||||
|
.iter()
|
||||||
|
.zip(bits.iter())
|
||||||
|
.filter(|(a, b)| a != b)
|
||||||
|
.count();
|
||||||
|
if dist < best_dist {
|
||||||
|
best_dist = dist;
|
||||||
|
best_id = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best_dist <= 8 {
|
||||||
|
Some(best_id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rm_1_5_codeword(value: u8) -> [u8; 32] {
|
||||||
|
let a0 = (value >> 5) & 1;
|
||||||
|
let a1 = (value >> 4) & 1;
|
||||||
|
let a2 = (value >> 3) & 1;
|
||||||
|
let a3 = (value >> 2) & 1;
|
||||||
|
let a4 = (value >> 1) & 1;
|
||||||
|
let a5 = value & 1;
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
for idx in 0..32 {
|
||||||
|
let x1 = ((idx >> 4) & 1) as u8;
|
||||||
|
let x2 = ((idx >> 3) & 1) as u8;
|
||||||
|
let x3 = ((idx >> 2) & 1) as u8;
|
||||||
|
let x4 = ((idx >> 1) & 1) as u8;
|
||||||
|
let x5 = (idx & 1) as u8;
|
||||||
|
out[idx] = a0 ^ (a1 & x1) ^ (a2 & x2) ^ (a3 & x3) ^ (a4 & x4) ^ (a5 & x5);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn burst_rms(samples: &[Complex<f32>]) -> f32 {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let power = samples.iter().map(|sample| sample.norm_sqr()).sum::<f32>() / samples.len() as f32;
|
||||||
|
power.sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slice_pi4_qpsk_symbols(samples: &[Complex<f32>], sample_rate: f32) -> Vec<u8> {
|
||||||
|
if samples.len() < 2 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut phase_clock = 0.0_f32;
|
||||||
|
let mut prev = samples[0];
|
||||||
|
let mut symbols = Vec::with_capacity(((samples.len() as f32) * VDES_SYMBOL_RATE / sample_rate) as usize + 4);
|
||||||
|
|
||||||
|
for &sample in &samples[1..] {
|
||||||
|
phase_clock += VDES_SYMBOL_RATE;
|
||||||
|
let diff = sample * prev.conj();
|
||||||
|
prev = sample;
|
||||||
|
|
||||||
|
while phase_clock >= sample_rate {
|
||||||
|
phase_clock -= sample_rate;
|
||||||
|
symbols.push(quantize_pi4_qpsk(diff));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let trimmed = out.trim().trim_matches('@').trim().to_string();
|
symbols
|
||||||
if trimmed.is_empty() {
|
}
|
||||||
None
|
|
||||||
} else {
|
fn quantize_pi4_qpsk(sample: Complex<f32>) -> u8 {
|
||||||
Some(trimmed)
|
let angle = sample.im.atan2(sample.re);
|
||||||
|
let candidates = [
|
||||||
|
(std::f32::consts::FRAC_PI_4, 0b00),
|
||||||
|
(3.0 * std::f32::consts::FRAC_PI_4, 0b01),
|
||||||
|
(-3.0 * std::f32::consts::FRAC_PI_4, 0b11),
|
||||||
|
(-std::f32::consts::FRAC_PI_4, 0b10),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut best = 0b00;
|
||||||
|
let mut best_err = f32::MAX;
|
||||||
|
for (ref_angle, dibit) in candidates {
|
||||||
|
let mut err = angle - ref_angle;
|
||||||
|
while err > std::f32::consts::PI {
|
||||||
|
err -= std::f32::consts::TAU;
|
||||||
|
}
|
||||||
|
while err < -std::f32::consts::PI {
|
||||||
|
err += std::f32::consts::TAU;
|
||||||
|
}
|
||||||
|
let abs_err = err.abs();
|
||||||
|
if abs_err < best_err {
|
||||||
|
best_err = abs_err;
|
||||||
|
best = dibit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
best
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pack_dibits_msb(symbols: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity((symbols.len() + 3) / 4);
|
||||||
|
let mut byte = 0u8;
|
||||||
|
let mut count = 0usize;
|
||||||
|
|
||||||
|
for &dibit in symbols {
|
||||||
|
let shift = 6usize.saturating_sub((count % 4) * 2);
|
||||||
|
byte |= (dibit & 0b11) << shift;
|
||||||
|
count += 1;
|
||||||
|
if count % 4 == 0 {
|
||||||
|
out.push(byte);
|
||||||
|
byte = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count % 4 != 0 {
|
||||||
|
out.push(byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn payload_with_crc(payload: &[u8]) -> Vec<u8> {
|
fn phase(angle: f32) -> Complex<f32> {
|
||||||
let mut out = payload.to_vec();
|
Complex::new(angle.cos(), angle.sin())
|
||||||
out.extend_from_slice(&crc16ccitt(payload).to_le_bytes());
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bytes_to_lsb_bits(bytes: &[u8]) -> Vec<u8> {
|
|
||||||
let mut bits = Vec::with_capacity(bytes.len() * 8);
|
|
||||||
for &byte in bytes {
|
|
||||||
for shift in 0..8 {
|
|
||||||
bits.push((byte >> shift) & 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bits
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bitstuff(bits: &[u8]) -> Vec<u8> {
|
|
||||||
let mut out = Vec::with_capacity(bits.len() + bits.len() / 5);
|
|
||||||
let mut ones = 0u32;
|
|
||||||
for &bit in bits {
|
|
||||||
out.push(bit);
|
|
||||||
if bit == 1 {
|
|
||||||
ones += 1;
|
|
||||||
if ones == 5 {
|
|
||||||
out.push(0);
|
|
||||||
ones = 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ones = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nrzi_encode(bits: &[u8]) -> Vec<u8> {
|
|
||||||
let mut state = 0u8;
|
|
||||||
let mut out = Vec::with_capacity(bits.len());
|
|
||||||
for &bit in bits {
|
|
||||||
if bit == 0 {
|
|
||||||
state ^= 1;
|
|
||||||
}
|
|
||||||
out.push(state);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decodes_signed_coordinates() {
|
fn packs_dibits_msb_first() {
|
||||||
assert_eq!(decode_coord(60_000, 181.0), Some(0.1));
|
assert_eq!(pack_dibits_msb(&[0b00, 0b01, 0b10, 0b11]), vec![0b0001_1011]);
|
||||||
assert_eq!(decode_coord(-60_000, 181.0), Some(-0.1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decodes_sixbit_name() {
|
fn quantizes_pi_over_four_steps() {
|
||||||
let bytes = [0x10_u8, 0x41_u8, 0x11_u8, 0x92_u8, 0x08_u8, 0x00_u8];
|
assert_eq!(quantize_pi4_qpsk(phase(std::f32::consts::FRAC_PI_4)), 0b00);
|
||||||
let bits = bytes_to_msb_bits(&bytes);
|
assert_eq!(quantize_pi4_qpsk(phase(3.0 * std::f32::consts::FRAC_PI_4)), 0b01);
|
||||||
let text = decode_sixbit_text(&bits, 0, 36);
|
assert_eq!(quantize_pi4_qpsk(phase(-3.0 * std::f32::consts::FRAC_PI_4)), 0b11);
|
||||||
assert!(text.is_some());
|
assert_eq!(quantize_pi4_qpsk(phase(-std::f32::consts::FRAC_PI_4)), 0b10);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recovers_hdlc_frame_from_raw_nrzi_bits() {
|
fn slices_simple_symbol_stream() {
|
||||||
let payload = [0x11_u8, 0x22_u8, 0x7E_u8, 0x00_u8, 0xF0_u8];
|
let sample_rate = 96_000.0;
|
||||||
let frame_bytes = payload_with_crc(&payload);
|
let mut samples = Vec::new();
|
||||||
let mut hdlc_bits = bytes_to_lsb_bits(&[0x7E]);
|
let mut current = phase(0.0);
|
||||||
hdlc_bits.extend(bitstuff(&bytes_to_lsb_bits(&frame_bytes)));
|
for angle in [
|
||||||
hdlc_bits.extend(bytes_to_lsb_bits(&[0x7E]));
|
std::f32::consts::FRAC_PI_4,
|
||||||
let raw_bits = nrzi_encode(&hdlc_bits);
|
3.0 * std::f32::consts::FRAC_PI_4,
|
||||||
|
-3.0 * std::f32::consts::FRAC_PI_4,
|
||||||
let mut decoder = VdesDecoder::new(48_000);
|
-std::f32::consts::FRAC_PI_4,
|
||||||
for raw_bit in raw_bits {
|
] {
|
||||||
decoder.process_symbol(raw_bit);
|
current *= phase(angle);
|
||||||
|
samples.push(current);
|
||||||
|
samples.push(current);
|
||||||
}
|
}
|
||||||
|
let symbols = slice_pi4_qpsk_symbols(&samples, sample_rate);
|
||||||
|
assert!(!symbols.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
assert_eq!(decoder.frames.len(), 1);
|
#[test]
|
||||||
let frame = &decoder.frames[0];
|
fn extracts_candidate_frame_window() {
|
||||||
assert!(frame.crc_ok);
|
let mut symbols = vec![0u8; 40];
|
||||||
assert_eq!(frame.payload, payload);
|
symbols.extend((0..TER_MCS1_100_BURST_SYMBOLS).map(|idx| (idx % 4) as u8));
|
||||||
|
let frame = extract_candidate_frame(&symbols).expect("frame should be found");
|
||||||
|
assert!(frame.symbols.len() >= MIN_BURST_SYMBOLS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deinterleave_preserves_length() {
|
||||||
|
let symbols: Vec<u8> = (0..127).map(|idx| (idx % 4) as u8).collect();
|
||||||
|
let out = deinterleave_100khz_frame(&symbols);
|
||||||
|
assert_eq!(out.len(), symbols.len());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ function applyAuthRestrictions() {
|
|||||||
// Note: sig-clear-btn is allowed for RX (clears local measurements only)
|
// Note: sig-clear-btn is allowed for RX (clears local measurements only)
|
||||||
const pluginToggleBtns = [
|
const pluginToggleBtns = [
|
||||||
"ais-clear-btn",
|
"ais-clear-btn",
|
||||||
|
"vdes-clear-btn",
|
||||||
"ft8-decode-toggle-btn",
|
"ft8-decode-toggle-btn",
|
||||||
"wspr-decode-toggle-btn",
|
"wspr-decode-toggle-btn",
|
||||||
"cw-auto",
|
"cw-auto",
|
||||||
@@ -1208,8 +1209,11 @@ function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isAisMode(mode = modeEl ? modeEl.value : "") {
|
function isAisMode(mode = modeEl ? modeEl.value : "") {
|
||||||
const upper = String(mode || "").toUpperCase();
|
return String(mode || "").toUpperCase() === "AIS";
|
||||||
return upper === "AIS" || upper === "VDES";
|
}
|
||||||
|
|
||||||
|
function isVdesMode(mode = modeEl ? modeEl.value : "") {
|
||||||
|
return String(mode || "").toUpperCase() === "VDES";
|
||||||
}
|
}
|
||||||
|
|
||||||
function coverageSpanForMode(freqHz, bandwidthHz = coverageGuardBandwidthHz(), mode = modeEl ? modeEl.value : "") {
|
function coverageSpanForMode(freqHz, bandwidthHz = coverageGuardBandwidthHz(), mode = modeEl ? modeEl.value : "") {
|
||||||
@@ -2032,17 +2036,25 @@ function render(update) {
|
|||||||
}
|
}
|
||||||
const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : "";
|
const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : "";
|
||||||
const aisStatus = document.getElementById("ais-status");
|
const aisStatus = document.getElementById("ais-status");
|
||||||
|
const vdesStatus = document.getElementById("vdes-status");
|
||||||
const aprsStatus = document.getElementById("aprs-status");
|
const aprsStatus = document.getElementById("aprs-status");
|
||||||
const cwStatus = document.getElementById("cw-status");
|
const cwStatus = document.getElementById("cw-status");
|
||||||
const ft8Status = document.getElementById("ft8-status");
|
const ft8Status = document.getElementById("ft8-status");
|
||||||
const wsprStatus = document.getElementById("wspr-status");
|
const wsprStatus = document.getElementById("wspr-status");
|
||||||
setModeBoundDecodeStatus(
|
setModeBoundDecodeStatus(
|
||||||
aisStatus,
|
aisStatus,
|
||||||
["AIS", "VDES"],
|
["AIS"],
|
||||||
"Select AIS or VDES mode to decode",
|
"Select AIS mode to decode",
|
||||||
"Connected, listening for packets",
|
"Connected, listening for packets",
|
||||||
);
|
);
|
||||||
if (window.updateAisBar) window.updateAisBar();
|
if (window.updateAisBar) window.updateAisBar();
|
||||||
|
setModeBoundDecodeStatus(
|
||||||
|
vdesStatus,
|
||||||
|
["VDES"],
|
||||||
|
"Select VDES mode to decode",
|
||||||
|
"Connected, listening for bursts",
|
||||||
|
);
|
||||||
|
if (window.updateVdesBar) window.updateVdesBar();
|
||||||
setModeBoundDecodeStatus(
|
setModeBoundDecodeStatus(
|
||||||
aprsStatus,
|
aprsStatus,
|
||||||
["PKT"],
|
["PKT"],
|
||||||
@@ -2734,7 +2746,7 @@ const MODE_BW_DEFAULTS = {
|
|||||||
AM: [9_000, 500, 20_000, 500],
|
AM: [9_000, 500, 20_000, 500],
|
||||||
FM: [12_500, 2_500, 25_000, 500],
|
FM: [12_500, 2_500, 25_000, 500],
|
||||||
AIS: [25_000, 12_500, 50_000, 500],
|
AIS: [25_000, 12_500, 50_000, 500],
|
||||||
VDES: [25_000, 12_500, 50_000, 500],
|
VDES: [100_000, 25_000, 200_000, 1_000],
|
||||||
WFM: [180_000, 50_000,300_000,5_000],
|
WFM: [180_000, 50_000,300_000,5_000],
|
||||||
DIG: [3_000, 300, 6_000, 100],
|
DIG: [3_000, 300, 6_000, 100],
|
||||||
PKT: [25_000, 300, 50_000, 500],
|
PKT: [25_000, 300, 50_000, 500],
|
||||||
@@ -4328,10 +4340,13 @@ function setModeBoundDecodeStatus(el, activeModes, inactiveText, connectedText)
|
|||||||
}
|
}
|
||||||
function updateDecodeStatus(text) {
|
function updateDecodeStatus(text) {
|
||||||
const ais = document.getElementById("ais-status");
|
const ais = document.getElementById("ais-status");
|
||||||
|
const vdes = document.getElementById("vdes-status");
|
||||||
const aprs = document.getElementById("aprs-status");
|
const aprs = document.getElementById("aprs-status");
|
||||||
const cw = document.getElementById("cw-status");
|
const cw = document.getElementById("cw-status");
|
||||||
const ft8 = document.getElementById("ft8-status");
|
const ft8 = document.getElementById("ft8-status");
|
||||||
setModeBoundDecodeStatus(ais, ["AIS", "VDES"], "Select AIS or VDES mode to decode", text);
|
setModeBoundDecodeStatus(ais, ["AIS"], "Select AIS mode to decode", text);
|
||||||
|
const vdesText = text === "Connected, listening for packets" ? "Connected, listening for bursts" : text;
|
||||||
|
setModeBoundDecodeStatus(vdes, ["VDES"], "Select VDES mode to decode", vdesText);
|
||||||
setModeBoundDecodeStatus(aprs, ["PKT"], "Select PKT mode to decode", text);
|
setModeBoundDecodeStatus(aprs, ["PKT"], "Select PKT mode to decode", text);
|
||||||
if (cw && cw.textContent !== "Receiving") cw.textContent = text;
|
if (cw && cw.textContent !== "Receiving") cw.textContent = text;
|
||||||
if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text;
|
if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text;
|
||||||
@@ -4339,6 +4354,7 @@ function updateDecodeStatus(text) {
|
|||||||
function connectDecode() {
|
function connectDecode() {
|
||||||
if (decodeSource) { decodeSource.close(); }
|
if (decodeSource) { decodeSource.close(); }
|
||||||
if (window.resetAisHistoryView) window.resetAisHistoryView();
|
if (window.resetAisHistoryView) window.resetAisHistoryView();
|
||||||
|
if (window.resetVdesHistoryView) window.resetVdesHistoryView();
|
||||||
if (window.resetAprsHistoryView) window.resetAprsHistoryView();
|
if (window.resetAprsHistoryView) window.resetAprsHistoryView();
|
||||||
if (window.resetCwHistoryView) window.resetCwHistoryView();
|
if (window.resetCwHistoryView) window.resetCwHistoryView();
|
||||||
if (window.resetFt8HistoryView) window.resetFt8HistoryView();
|
if (window.resetFt8HistoryView) window.resetFt8HistoryView();
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
<canvas id="overview-canvas" aria-hidden="true"></canvas>
|
<canvas id="overview-canvas" aria-hidden="true"></canvas>
|
||||||
<div id="rds-ps-overlay" aria-live="polite" aria-label="RDS station name"></div>
|
<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="ais-bar-overlay" aria-live="polite" aria-label="Recent AIS messages"></div>
|
||||||
|
<div id="vdes-bar-overlay" aria-live="polite" aria-label="Recent VDES bursts"></div>
|
||||||
<div id="aprs-bar-overlay" aria-live="polite" aria-label="Recent APRS frames"></div>
|
<div id="aprs-bar-overlay" aria-live="polite" aria-label="Recent APRS frames"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="spectrum-bookmark-axis"></div>
|
<div id="spectrum-bookmark-axis"></div>
|
||||||
@@ -350,7 +351,8 @@
|
|||||||
<div id="tab-plugins" class="tab-panel" style="display:none;">
|
<div id="tab-plugins" class="tab-panel" style="display:none;">
|
||||||
<div class="sub-tab-bar">
|
<div class="sub-tab-bar">
|
||||||
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
||||||
<button class="sub-tab" data-subtab="ais">AIS/VDES</button>
|
<button class="sub-tab" data-subtab="ais">AIS</button>
|
||||||
|
<button class="sub-tab" data-subtab="vdes">VDES</button>
|
||||||
<button class="sub-tab" data-subtab="aprs">APRS</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="cw">CW</button>
|
||||||
<button class="sub-tab" data-subtab="ft8">FT8</button>
|
<button class="sub-tab" data-subtab="ft8">FT8</button>
|
||||||
@@ -359,9 +361,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="subtab-overview" class="sub-tab-panel">
|
<div id="subtab-overview" class="sub-tab-panel">
|
||||||
<div class="plugin-item">
|
<div class="plugin-item">
|
||||||
<strong>AIS / VDES Decoder</strong>
|
<strong>AIS Decoder</strong>
|
||||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||||
Decodes dual-channel AIS and VDES traffic from RX audio using 9.6 kbit/s GMSK and HDLC.
|
Decodes dual-channel AIS traffic from RX audio using 9.6 kbit/s GMSK and HDLC.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plugin-item">
|
||||||
|
<strong>VDES Decoder</strong>
|
||||||
|
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||||
|
Decodes single-channel 100 kHz VDES bursts from SDR IQ using the dedicated VDES path.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="plugin-item">
|
<div class="plugin-item">
|
||||||
@@ -442,6 +450,28 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="ais-messages"></div>
|
<div id="ais-messages"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="subtab-vdes" class="sub-tab-panel" style="display:none;">
|
||||||
|
<div class="aprs-controls">
|
||||||
|
<button id="vdes-clear-btn" type="button">Clear</button>
|
||||||
|
<input id="vdes-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. frame, RMS, payload)" />
|
||||||
|
<small id="vdes-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||||
|
</div>
|
||||||
|
<div class="ais-summary">
|
||||||
|
<div class="ais-summary-card">
|
||||||
|
<span class="ais-summary-label">Channel</span>
|
||||||
|
<span id="vdes-channel-summary" class="ais-summary-value">100 kHz centered on tuned frequency</span>
|
||||||
|
</div>
|
||||||
|
<div class="ais-summary-card">
|
||||||
|
<span class="ais-summary-label">Frames</span>
|
||||||
|
<span id="vdes-frame-count" class="ais-summary-value">0 bursts</span>
|
||||||
|
</div>
|
||||||
|
<div class="ais-summary-card">
|
||||||
|
<span class="ais-summary-label">Latest</span>
|
||||||
|
<span id="vdes-latest-seen" class="ais-summary-value">No traffic yet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="vdes-messages"></div>
|
||||||
|
</div>
|
||||||
<div id="subtab-aprs" class="sub-tab-panel" style="display:none;">
|
<div id="subtab-aprs" class="sub-tab-panel" style="display:none;">
|
||||||
<div class="aprs-controls">
|
<div class="aprs-controls">
|
||||||
<button id="aprs-pause-btn" type="button">Pause</button>
|
<button id="aprs-pause-btn" type="button">Pause</button>
|
||||||
@@ -528,7 +558,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="tab-map" class="tab-panel" style="display:none;">
|
<div id="tab-map" class="tab-panel" style="display:none;">
|
||||||
<div class="map-controls">
|
<div class="map-controls">
|
||||||
<label><input type="checkbox" id="map-filter-ais" checked /> AIS/VDES</label>
|
<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-aprs" checked /> APRS</label>
|
||||||
<label><input type="checkbox" id="map-filter-ft8" checked /> FT8</label>
|
<label><input type="checkbox" id="map-filter-ft8" checked /> FT8</label>
|
||||||
<label><input type="checkbox" id="map-filter-wspr" checked /> WSPR</label>
|
<label><input type="checkbox" id="map-filter-wspr" checked /> WSPR</label>
|
||||||
@@ -565,6 +595,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
<script src="/ais.js"></script>
|
<script src="/ais.js"></script>
|
||||||
|
<script src="/vdes.js"></script>
|
||||||
<script src="/aprs.js"></script>
|
<script src="/aprs.js"></script>
|
||||||
<script src="/ft8.js"></script>
|
<script src="/ft8.js"></script>
|
||||||
<script src="/wspr.js"></script>
|
<script src="/wspr.js"></script>
|
||||||
|
|||||||
@@ -14,15 +14,6 @@ const AIS_CHANNEL_SPACING_HZ = 50_000;
|
|||||||
let aisFilterText = "";
|
let aisFilterText = "";
|
||||||
let aisMessageHistory = [];
|
let aisMessageHistory = [];
|
||||||
|
|
||||||
function isAisLikeMode() {
|
|
||||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
|
||||||
return mode === "AIS" || mode === "VDES";
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentAisLikeModeLabel() {
|
|
||||||
return (document.getElementById("mode")?.value || "").toUpperCase() === "VDES" ? "VDES" : "AIS";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAisMhz(freqHz) {
|
function formatAisMhz(freqHz) {
|
||||||
return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
|
return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
|
||||||
}
|
}
|
||||||
@@ -39,17 +30,16 @@ function currentAisChannelPlan() {
|
|||||||
|
|
||||||
function aisChannelInfo(channel) {
|
function aisChannelInfo(channel) {
|
||||||
const plan = currentAisChannelPlan();
|
const plan = currentAisChannelPlan();
|
||||||
const modeLabel = currentAisLikeModeLabel();
|
|
||||||
const ch = String(channel || "").trim().toUpperCase();
|
const ch = String(channel || "").trim().toUpperCase();
|
||||||
if (ch === "B") {
|
if (ch === "B") {
|
||||||
return {
|
return {
|
||||||
label: `${modeLabel}-B`,
|
label: "AIS-B",
|
||||||
badgeClass: "ais-badge ais-badge-channel-b",
|
badgeClass: "ais-badge ais-badge-channel-b",
|
||||||
freqText: formatAisMhz(plan.bHz),
|
freqText: formatAisMhz(plan.bHz),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
label: `${modeLabel}-A`,
|
label: "AIS-A",
|
||||||
badgeClass: "ais-badge ais-badge-channel-a",
|
badgeClass: "ais-badge ais-badge-channel-a",
|
||||||
freqText: formatAisMhz(plan.aHz),
|
freqText: formatAisMhz(plan.aHz),
|
||||||
};
|
};
|
||||||
@@ -227,8 +217,7 @@ function updateAisBar() {
|
|||||||
if (!aisBarOverlay) return;
|
if (!aisBarOverlay) return;
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
|
|
||||||
const isAis = isAisLikeMode();
|
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||||
const modeLabel = currentAisLikeModeLabel();
|
|
||||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
|
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
|
||||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
@@ -238,7 +227,7 @@ function updateAisBar() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${modeLabel}</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 ${modeLabel} overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>`;
|
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) {
|
for (const msg of messages) {
|
||||||
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||||
const pin = msg.lat != null && msg.lon != null
|
const pin = msg.lat != null && msg.lon != null
|
||||||
@@ -305,10 +294,10 @@ function addAisMessage(msg) {
|
|||||||
if (aisClearBtn) {
|
if (aisClearBtn) {
|
||||||
aisClearBtn.addEventListener("click", async () => {
|
aisClearBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await postPath(currentAisLikeModeLabel() === "VDES" ? "/clear_vdes_decode" : "/clear_ais_decode");
|
await postPath("/clear_ais_decode");
|
||||||
window.resetAisHistoryView();
|
window.resetAisHistoryView();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("AIS/VDES clear failed", e);
|
console.error("AIS clear failed", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -338,22 +327,4 @@ window.onServerAis = function(msg) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
window.onServerVdes = 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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
// --- VDES Decoder Plugin (server-side decode) ---
|
||||||
|
const vdesStatus = document.getElementById("vdes-status");
|
||||||
|
const vdesMessagesEl = document.getElementById("vdes-messages");
|
||||||
|
const vdesFilterInput = document.getElementById("vdes-filter");
|
||||||
|
const vdesClearBtn = document.getElementById("vdes-clear-btn");
|
||||||
|
const vdesBarOverlay = document.getElementById("vdes-bar-overlay");
|
||||||
|
const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
|
||||||
|
const vdesFrameCountEl = document.getElementById("vdes-frame-count");
|
||||||
|
const vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
|
||||||
|
const VDES_MAX_MESSAGES = 200;
|
||||||
|
const VDES_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
let vdesFilterText = "";
|
||||||
|
let vdesMessageHistory = [];
|
||||||
|
|
||||||
|
function currentVdesCenterText() {
|
||||||
|
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||||
|
const hz = raw ? Number(raw) : 0;
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
|
||||||
|
return `100 kHz @ ${(hz / 1_000_000).toFixed(3)} MHz`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vdesAgeText(tsMs) {
|
||||||
|
if (!Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1000);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vdesHexPreview(rawBytes) {
|
||||||
|
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
||||||
|
return rawBytes
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((value) => Number(value).toString(16).padStart(2, "0"))
|
||||||
|
.join(" ")
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVdesSummary() {
|
||||||
|
if (vdesChannelSummaryEl) {
|
||||||
|
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||||
|
}
|
||||||
|
if (vdesFrameCountEl) {
|
||||||
|
const count = vdesMessageHistory.length;
|
||||||
|
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (vdesLatestSeenEl) {
|
||||||
|
const latest = vdesMessageHistory[0];
|
||||||
|
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVdesFilterToRow(row) {
|
||||||
|
if (!vdesFilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = row.dataset.filterText || "";
|
||||||
|
row.style.display = text.includes(vdesFilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVdesFilterToAll() {
|
||||||
|
if (!vdesMessagesEl) return;
|
||||||
|
vdesMessagesEl.querySelectorAll(".vdes-message").forEach((row) => applyVdesFilterToRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVdesRow(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "vdes-message";
|
||||||
|
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
const title = msg.vessel_name || "VDES Burst";
|
||||||
|
const label = msg.callsign || "VDES";
|
||||||
|
const info = msg.destination || "";
|
||||||
|
const rawHex = vdesHexPreview(msg.raw_bytes);
|
||||||
|
row.dataset.filterText = [title, label, info, rawHex, msg.message_type, msg.bit_len]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toUpperCase();
|
||||||
|
row.innerHTML =
|
||||||
|
`<div class="vdes-row-head">` +
|
||||||
|
`<span class="vdes-time">${ts}</span>` +
|
||||||
|
`<span class="vdes-call">${escapeMapHtml(title)}</span>` +
|
||||||
|
`<span class="vdes-badge">${escapeMapHtml(label)}</span>` +
|
||||||
|
`<span class="vdes-badge">T${escapeMapHtml(String(msg.message_type ?? "--"))}</span>` +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="vdes-row-meta">` +
|
||||||
|
`<span>${escapeMapHtml(currentVdesCenterText())}</span>` +
|
||||||
|
`<span>${escapeMapHtml(`${msg.bit_len || 0} bits`)}</span>` +
|
||||||
|
(info ? `<span>${escapeMapHtml(info)}</span>` : "") +
|
||||||
|
`<span>${escapeMapHtml(vdesAgeText(msg._tsMs))}</span>` +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="vdes-row-detail">` +
|
||||||
|
`<span class="vdes-raw">${escapeMapHtml(rawHex)}</span>` +
|
||||||
|
`</div>`;
|
||||||
|
applyVdesFilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVdesBar() {
|
||||||
|
if (!vdesBarOverlay) return;
|
||||||
|
updateVdesSummary();
|
||||||
|
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
||||||
|
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||||
|
const messages = vdesMessageHistory.filter((msg) => msg._tsMs >= cutoffMs).slice(0, 6);
|
||||||
|
if (!isVdes || messages.length === 0) {
|
||||||
|
vdesBarOverlay.style.display = "none";
|
||||||
|
vdesBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</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.clearVdesBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES 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 label = escapeMapHtml(msg.callsign || "VDES");
|
||||||
|
const title = escapeMapHtml(msg.vessel_name || "Burst");
|
||||||
|
const detail = [
|
||||||
|
`${msg.bit_len || 0} bits`,
|
||||||
|
msg.destination ? escapeMapHtml(msg.destination) : null,
|
||||||
|
escapeMapHtml(vdesAgeText(msg._tsMs)),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="vdes-call">${title}</span> <span class="vdes-badge">${label}</span>: ${detail}</div></div>`;
|
||||||
|
}
|
||||||
|
vdesBarOverlay.innerHTML = html;
|
||||||
|
vdesBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
window.updateVdesBar = updateVdesBar;
|
||||||
|
window.clearVdesBar = function() {
|
||||||
|
document.getElementById("vdes-clear-btn")?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.resetVdesHistoryView = function() {
|
||||||
|
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
|
||||||
|
vdesMessageHistory = [];
|
||||||
|
updateVdesBar();
|
||||||
|
};
|
||||||
|
|
||||||
|
function addVdesMessage(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",
|
||||||
|
});
|
||||||
|
|
||||||
|
vdesMessageHistory.unshift(msg);
|
||||||
|
if (vdesMessageHistory.length > VDES_MAX_MESSAGES) vdesMessageHistory.length = VDES_MAX_MESSAGES;
|
||||||
|
updateVdesBar();
|
||||||
|
|
||||||
|
if (vdesMessagesEl) {
|
||||||
|
const row = renderVdesRow(msg);
|
||||||
|
vdesMessagesEl.prepend(row);
|
||||||
|
while (vdesMessagesEl.children.length > VDES_MAX_MESSAGES) {
|
||||||
|
vdesMessagesEl.removeChild(vdesMessagesEl.lastChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vdesClearBtn) {
|
||||||
|
vdesClearBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await postPath("/clear_vdes_decode");
|
||||||
|
window.resetVdesHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("VDES clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vdesFilterInput) {
|
||||||
|
vdesFilterInput.addEventListener("input", () => {
|
||||||
|
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
|
||||||
|
applyVdesFilterToAll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onServerVdes = function(msg) {
|
||||||
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
|
addVdesMessage({
|
||||||
|
message_type: msg.message_type,
|
||||||
|
bit_len: msg.bit_len,
|
||||||
|
raw_bytes: msg.raw_bytes,
|
||||||
|
vessel_name: msg.vessel_name,
|
||||||
|
callsign: msg.callsign,
|
||||||
|
destination: msg.destination,
|
||||||
|
ts_ms: msg.ts_ms,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVdesSummary();
|
||||||
@@ -630,7 +630,8 @@ small { color: var(--text-muted); }
|
|||||||
background: color-mix(in srgb, var(--card-bg) 62%, transparent);
|
background: color-mix(in srgb, var(--card-bg) 62%, transparent);
|
||||||
}
|
}
|
||||||
#aprs-bar-overlay,
|
#aprs-bar-overlay,
|
||||||
#ais-bar-overlay {
|
#ais-bar-overlay,
|
||||||
|
#vdes-bar-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -1205,12 +1206,17 @@ small { color: var(--text-muted); }
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
#subtab-vdes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
#subtab-aprs {
|
#subtab-aprs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
#aprs-packets,
|
#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; }
|
#ais-messages,
|
||||||
|
#vdes-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-packets {
|
#aprs-packets {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
height: calc(100vh - 28rem);
|
height: calc(100vh - 28rem);
|
||||||
@@ -1223,6 +1229,12 @@ small { color: var(--text-muted); }
|
|||||||
min-height: 16rem;
|
min-height: 16rem;
|
||||||
max-height: calc(100vh - 24rem);
|
max-height: calc(100vh - 24rem);
|
||||||
}
|
}
|
||||||
|
#vdes-messages {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
height: calc(100vh - 24rem);
|
||||||
|
min-height: 16rem;
|
||||||
|
max-height: calc(100vh - 24rem);
|
||||||
|
}
|
||||||
.aprs-packet { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--border); line-height: 1.35; }
|
.aprs-packet { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--border); line-height: 1.35; }
|
||||||
.aprs-packet:last-child { border-bottom: none; }
|
.aprs-packet:last-child { border-bottom: none; }
|
||||||
.aprs-packet-new {
|
.aprs-packet-new {
|
||||||
@@ -1347,10 +1359,14 @@ small { color: var(--text-muted); }
|
|||||||
}
|
}
|
||||||
.ais-message { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--border); line-height: 1.35; }
|
.ais-message { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--border); line-height: 1.35; }
|
||||||
.ais-message:last-child { border-bottom: none; }
|
.ais-message:last-child { border-bottom: none; }
|
||||||
|
.vdes-message { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--border); line-height: 1.35; }
|
||||||
|
.vdes-message:last-child { border-bottom: none; }
|
||||||
.aprs-call { color: var(--accent-green); font-weight: 600; }
|
.aprs-call { color: var(--accent-green); font-weight: 600; }
|
||||||
.ais-call { color: var(--accent-red); font-weight: 600; }
|
.ais-call { color: var(--accent-red); font-weight: 600; }
|
||||||
|
.vdes-call { color: #8ec8ff; font-weight: 600; }
|
||||||
.aprs-time { color: var(--text-muted); margin-right: 0.5rem; }
|
.aprs-time { color: var(--text-muted); margin-right: 0.5rem; }
|
||||||
.ais-time { color: var(--text-muted); margin-right: 0.5rem; }
|
.ais-time { color: var(--text-muted); margin-right: 0.5rem; }
|
||||||
|
.vdes-time { color: var(--text-muted); margin-right: 0.5rem; }
|
||||||
.ais-row-head,
|
.ais-row-head,
|
||||||
.ais-row-meta,
|
.ais-row-meta,
|
||||||
.ais-row-detail {
|
.ais-row-detail {
|
||||||
@@ -1406,6 +1422,42 @@ small { color: var(--text-muted); }
|
|||||||
.ais-pos-link:hover {
|
.ais-pos-link:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
.vdes-row-head,
|
||||||
|
.vdes-row-meta,
|
||||||
|
.vdes-row-detail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.vdes-row-head + .vdes-row-meta,
|
||||||
|
.vdes-row-meta + .vdes-row-detail {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
.vdes-row-detail {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.vdes-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 1.2rem;
|
||||||
|
padding: 0.02rem 0.38rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid color-mix(in srgb, #8ec8ff 42%, transparent);
|
||||||
|
background: color-mix(in srgb, #8ec8ff 12%, transparent);
|
||||||
|
color: #8ec8ff;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.vdes-raw {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; }
|
.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 { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; }
|
||||||
.aprs-pos:hover { text-decoration: underline; }
|
.aprs-pos:hover { text-decoration: underline; }
|
||||||
@@ -1769,6 +1821,9 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
#subtab-ais {
|
#subtab-ais {
|
||||||
min-height: calc(100vh - 14rem);
|
min-height: calc(100vh - 14rem);
|
||||||
}
|
}
|
||||||
|
#subtab-vdes {
|
||||||
|
min-height: calc(100vh - 14rem);
|
||||||
|
}
|
||||||
#subtab-aprs {
|
#subtab-aprs {
|
||||||
min-height: calc(100vh - 14rem);
|
min-height: calc(100vh - 14rem);
|
||||||
}
|
}
|
||||||
@@ -1778,6 +1833,9 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
#ais-messages {
|
#ais-messages {
|
||||||
min-height: calc(100vh - 22rem);
|
min-height: calc(100vh - 22rem);
|
||||||
}
|
}
|
||||||
|
#vdes-messages {
|
||||||
|
min-height: calc(100vh - 22rem);
|
||||||
|
}
|
||||||
.aprs-details-grid {
|
.aprs-details-grid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 0.14rem;
|
gap: 0.14rem;
|
||||||
|
|||||||
@@ -993,6 +993,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(app_js)
|
.service(app_js)
|
||||||
.service(leaflet_ais_tracksymbol_js)
|
.service(leaflet_ais_tracksymbol_js)
|
||||||
.service(ais_js)
|
.service(ais_js)
|
||||||
|
.service(vdes_js)
|
||||||
.service(aprs_js)
|
.service(aprs_js)
|
||||||
.service(ft8_js)
|
.service(ft8_js)
|
||||||
.service(wspr_js)
|
.service(wspr_js)
|
||||||
@@ -1079,6 +1080,16 @@ async fn ais_js() -> impl Responder {
|
|||||||
.body(status::AIS_JS)
|
.body(status::AIS_JS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/vdes.js")]
|
||||||
|
async fn vdes_js() -> impl Responder {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header((
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
"application/javascript; charset=utf-8",
|
||||||
|
))
|
||||||
|
.body(status::VDES_JS)
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/ft8.js")]
|
#[get("/ft8.js")]
|
||||||
async fn ft8_js() -> impl Responder {
|
async fn ft8_js() -> impl Responder {
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub const APP_JS: &str = include_str!("../assets/web/app.js");
|
|||||||
pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str =
|
pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str =
|
||||||
include_str!("../assets/web/leaflet-ais-tracksymbol.js");
|
include_str!("../assets/web/leaflet-ais-tracksymbol.js");
|
||||||
pub const AIS_JS: &str = include_str!("../assets/web/plugins/ais.js");
|
pub const AIS_JS: &str = include_str!("../assets/web/plugins/ais.js");
|
||||||
|
pub const VDES_JS: &str = include_str!("../assets/web/plugins/vdes.js");
|
||||||
pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.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 FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");
|
||||||
pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
|
pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ dirs = "6"
|
|||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
|
num-complex = "0.4"
|
||||||
opus = "0.3"
|
opus = "0.3"
|
||||||
trx-app = { path = "../trx-app" }
|
trx-app = { path = "../trx-app" }
|
||||||
trx-backend = { path = "trx-backend", features = ["soapysdr"] }
|
trx-backend = { path = "trx-backend", features = ["soapysdr"] }
|
||||||
|
|||||||
+11
-31
@@ -11,6 +11,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use num_complex::Complex;
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
use tokio::sync::{broadcast, mpsc, watch};
|
use tokio::sync::{broadcast, mpsc, watch};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
@@ -978,16 +979,13 @@ pub async fn run_ais_decoder(
|
|||||||
/// Run the VDES decoder task. Only processes PCM when rig mode is VDES.
|
/// Run the VDES decoder task. Only processes PCM when rig mode is VDES.
|
||||||
pub async fn run_vdes_decoder(
|
pub async fn run_vdes_decoder(
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
channels: u16,
|
mut iq_rx: broadcast::Receiver<Vec<Complex<f32>>>,
|
||||||
mut pcm_a_rx: broadcast::Receiver<Vec<f32>>,
|
|
||||||
mut pcm_b_rx: broadcast::Receiver<Vec<f32>>,
|
|
||||||
mut state_rx: watch::Receiver<RigState>,
|
mut state_rx: watch::Receiver<RigState>,
|
||||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||||
histories: Arc<DecoderHistories>,
|
histories: Arc<DecoderHistories>,
|
||||||
) {
|
) {
|
||||||
info!("VDES decoder started ({}Hz, {} ch)", sample_rate, channels);
|
info!("VDES decoder started ({}Hz complex baseband)", sample_rate);
|
||||||
let mut decoder_a = VdesDecoder::new(sample_rate);
|
let mut decoder = VdesDecoder::new(sample_rate);
|
||||||
let mut decoder_b = VdesDecoder::new(sample_rate);
|
|
||||||
let mut was_active = false;
|
let mut was_active = false;
|
||||||
let mut active = matches!(state_rx.borrow().status.mode, RigMode::VDES);
|
let mut active = matches!(state_rx.borrow().status.mode, RigMode::VDES);
|
||||||
|
|
||||||
@@ -998,8 +996,7 @@ pub async fn run_vdes_decoder(
|
|||||||
let state = state_rx.borrow();
|
let state = state_rx.borrow();
|
||||||
active = matches!(state.status.mode, RigMode::VDES);
|
active = matches!(state.status.mode, RigMode::VDES);
|
||||||
if active {
|
if active {
|
||||||
pcm_a_rx = pcm_a_rx.resubscribe();
|
iq_rx = iq_rx.resubscribe();
|
||||||
pcm_b_rx = pcm_b_rx.resubscribe();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
@@ -1008,32 +1005,17 @@ pub async fn run_vdes_decoder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
recv = pcm_a_rx.recv() => {
|
recv = iq_rx.recv() => {
|
||||||
match recv {
|
match recv {
|
||||||
Ok(frame) => {
|
Ok(block) => {
|
||||||
was_active = true;
|
was_active = true;
|
||||||
for msg in decoder_a.process_samples(&downmix_if_needed(frame, channels), "A") {
|
for msg in decoder.process_samples(&block, "Main") {
|
||||||
histories.record_vdes_message(msg.clone());
|
histories.record_vdes_message(msg.clone());
|
||||||
let _ = decode_tx.send(DecodedMessage::Vdes(msg));
|
let _ = decode_tx.send(DecodedMessage::Vdes(msg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
warn!("VDES decoder A: dropped {} PCM frames", n);
|
warn!("VDES decoder: dropped {} IQ blocks", 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_vdes_message(msg.clone());
|
|
||||||
let _ = decode_tx.send(DecodedMessage::Vdes(msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
|
||||||
warn!("VDES decoder B: dropped {} PCM frames", n);
|
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Closed) => break,
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
}
|
}
|
||||||
@@ -1044,13 +1026,11 @@ pub async fn run_vdes_decoder(
|
|||||||
let state = state_rx.borrow();
|
let state = state_rx.borrow();
|
||||||
active = matches!(state.status.mode, RigMode::VDES);
|
active = matches!(state.status.mode, RigMode::VDES);
|
||||||
if !active && was_active {
|
if !active && was_active {
|
||||||
decoder_a.reset();
|
decoder.reset();
|
||||||
decoder_b.reset();
|
|
||||||
was_active = false;
|
was_active = false;
|
||||||
}
|
}
|
||||||
if active {
|
if active {
|
||||||
pcm_a_rx = pcm_a_rx.resubscribe();
|
iq_rx = iq_rx.resubscribe();
|
||||||
pcm_b_rx = pcm_b_rx.resubscribe();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
|
|||||||
+31
-12
@@ -245,7 +245,7 @@ fn default_audio_bandwidth_for_mode(mode: &trx_core::rig::state::RigMode) -> u32
|
|||||||
RigMode::FM => 12_500,
|
RigMode::FM => 12_500,
|
||||||
RigMode::WFM => 180_000,
|
RigMode::WFM => 180_000,
|
||||||
RigMode::AIS => 25_000,
|
RigMode::AIS => 25_000,
|
||||||
RigMode::VDES => 25_000,
|
RigMode::VDES => 100_000,
|
||||||
RigMode::Other(_) => 3_000,
|
RigMode::Other(_) => 3_000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,6 +282,7 @@ type SdrRigBuildResult = DynResult<(
|
|||||||
tokio::sync::broadcast::Receiver<Vec<f32>>,
|
tokio::sync::broadcast::Receiver<Vec<f32>>,
|
||||||
tokio::sync::broadcast::Receiver<Vec<f32>>,
|
tokio::sync::broadcast::Receiver<Vec<f32>>,
|
||||||
),
|
),
|
||||||
|
tokio::sync::broadcast::Receiver<Vec<num_complex::Complex<f32>>>,
|
||||||
)>;
|
)>;
|
||||||
|
|
||||||
type OptionalSdrRig = Option<Box<dyn trx_core::rig::RigCat>>;
|
type OptionalSdrRig = Option<Box<dyn trx_core::rig::RigCat>>;
|
||||||
@@ -290,6 +291,7 @@ type OptionalSdrAisPcmRx = Option<(
|
|||||||
broadcast::Receiver<Vec<f32>>,
|
broadcast::Receiver<Vec<f32>>,
|
||||||
broadcast::Receiver<Vec<f32>>,
|
broadcast::Receiver<Vec<f32>>,
|
||||||
)>;
|
)>;
|
||||||
|
type OptionalSdrVdesIqRx = Option<broadcast::Receiver<Vec<num_complex::Complex<f32>>>>;
|
||||||
|
|
||||||
/// Build a `SoapySdrRig` with full channel config from a `RigInstanceConfig`.
|
/// Build a `SoapySdrRig` with full channel config from a `RigInstanceConfig`.
|
||||||
#[cfg(feature = "soapysdr")]
|
#[cfg(feature = "soapysdr")]
|
||||||
@@ -350,7 +352,13 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
|
|||||||
sdr_rig.subscribe_pcm_channel(ais_channel_base_idx),
|
sdr_rig.subscribe_pcm_channel(ais_channel_base_idx),
|
||||||
sdr_rig.subscribe_pcm_channel(ais_channel_base_idx + 1),
|
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))
|
let vdes_iq = sdr_rig.subscribe_iq_channel(0);
|
||||||
|
Ok((
|
||||||
|
Box::new(sdr_rig) as Box<dyn trx_core::rig::RigCat>,
|
||||||
|
pcm_rx,
|
||||||
|
ais_pcm,
|
||||||
|
vdes_iq,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `RigTaskConfig` for a single rig instance.
|
/// Build a `RigTaskConfig` for a single rig instance.
|
||||||
@@ -429,6 +437,7 @@ fn spawn_rig_audio_stack(
|
|||||||
broadcast::Receiver<Vec<f32>>,
|
broadcast::Receiver<Vec<f32>>,
|
||||||
broadcast::Receiver<Vec<f32>>,
|
broadcast::Receiver<Vec<f32>>,
|
||||||
)>,
|
)>,
|
||||||
|
sdr_vdes_iq_rx: Option<broadcast::Receiver<Vec<num_complex::Complex<f32>>>>,
|
||||||
) -> Vec<JoinHandle<()>> {
|
) -> Vec<JoinHandle<()>> {
|
||||||
let mut handles: Vec<JoinHandle<()>> = Vec::new();
|
let mut handles: Vec<JoinHandle<()>> = Vec::new();
|
||||||
|
|
||||||
@@ -615,8 +624,6 @@ fn spawn_rig_audio_stack(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = sdr_ais_pcm_rx {
|
if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = sdr_ais_pcm_rx {
|
||||||
let vdes_a_pcm_rx = ais_a_pcm_rx.resubscribe();
|
|
||||||
let vdes_b_pcm_rx = ais_b_pcm_rx.resubscribe();
|
|
||||||
let ais_state_rx = state_rx.clone();
|
let ais_state_rx = state_rx.clone();
|
||||||
let ais_decode_tx = decode_tx.clone();
|
let ais_decode_tx = decode_tx.clone();
|
||||||
let ais_shutdown_rx = shutdown_rx.clone();
|
let ais_shutdown_rx = shutdown_rx.clone();
|
||||||
@@ -629,16 +636,17 @@ fn spawn_rig_audio_stack(
|
|||||||
_ = wait_for_shutdown(ais_shutdown_rx) => {}
|
_ = wait_for_shutdown(ais_shutdown_rx) => {}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(vdes_iq_rx) = sdr_vdes_iq_rx {
|
||||||
let vdes_state_rx = state_rx.clone();
|
let vdes_state_rx = state_rx.clone();
|
||||||
let vdes_decode_tx = decode_tx.clone();
|
let vdes_decode_tx = decode_tx.clone();
|
||||||
let vdes_shutdown_rx = shutdown_rx.clone();
|
let vdes_shutdown_rx = shutdown_rx.clone();
|
||||||
let vdes_histories = histories.clone();
|
let vdes_histories = histories.clone();
|
||||||
let vdes_sr = rig_cfg.audio.sample_rate;
|
let vdes_sr = (rig_cfg.sdr.sample_rate / (rig_cfg.sdr.sample_rate / 96_000).max(1)).max(1);
|
||||||
let vdes_ch = rig_cfg.audio.channels as u16;
|
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = audio::run_vdes_decoder(vdes_sr, vdes_ch, vdes_a_pcm_rx, vdes_b_pcm_rx, vdes_state_rx, vdes_decode_tx, vdes_histories) => {}
|
_ = audio::run_vdes_decoder(vdes_sr, vdes_iq_rx, vdes_state_rx, vdes_decode_tx, vdes_histories) => {}
|
||||||
_ = wait_for_shutdown(vdes_shutdown_rx) => {}
|
_ = wait_for_shutdown(vdes_shutdown_rx) => {}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -870,16 +878,26 @@ async fn main() -> DynResult<()> {
|
|||||||
|
|
||||||
// Build SDR rig when applicable.
|
// Build SDR rig when applicable.
|
||||||
#[cfg(feature = "soapysdr")]
|
#[cfg(feature = "soapysdr")]
|
||||||
let (sdr_prebuilt_rig, sdr_pcm_rx, sdr_ais_pcm_rx): (OptionalSdrRig, OptionalSdrPcmRx, OptionalSdrAisPcmRx) =
|
let (sdr_prebuilt_rig, sdr_pcm_rx, sdr_ais_pcm_rx, sdr_vdes_iq_rx): (
|
||||||
|
OptionalSdrRig,
|
||||||
|
OptionalSdrPcmRx,
|
||||||
|
OptionalSdrAisPcmRx,
|
||||||
|
OptionalSdrVdesIqRx,
|
||||||
|
) =
|
||||||
if rig_cfg.rig.access.access_type.as_deref() == Some("sdr") {
|
if rig_cfg.rig.access.access_type.as_deref() == Some("sdr") {
|
||||||
let (rig, pcm_rx, ais_pcm_rx) = build_sdr_rig_from_instance(rig_cfg)?;
|
let (rig, pcm_rx, ais_pcm_rx, vdes_iq_rx) = build_sdr_rig_from_instance(rig_cfg)?;
|
||||||
(Some(rig), Some(pcm_rx), Some(ais_pcm_rx))
|
(Some(rig), Some(pcm_rx), Some(ais_pcm_rx), Some(vdes_iq_rx))
|
||||||
} else {
|
} else {
|
||||||
(None, None, None)
|
(None, None, None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(not(feature = "soapysdr"))]
|
#[cfg(not(feature = "soapysdr"))]
|
||||||
let (sdr_prebuilt_rig, sdr_pcm_rx, sdr_ais_pcm_rx): (OptionalSdrRig, OptionalSdrPcmRx, OptionalSdrAisPcmRx) = (None, None, None);
|
let (sdr_prebuilt_rig, sdr_pcm_rx, sdr_ais_pcm_rx, sdr_vdes_iq_rx): (
|
||||||
|
OptionalSdrRig,
|
||||||
|
OptionalSdrPcmRx,
|
||||||
|
OptionalSdrAisPcmRx,
|
||||||
|
OptionalSdrVdesIqRx,
|
||||||
|
) = (None, None, None, None);
|
||||||
|
|
||||||
let histories = DecoderHistories::new();
|
let histories = DecoderHistories::new();
|
||||||
|
|
||||||
@@ -941,6 +959,7 @@ async fn main() -> DynResult<()> {
|
|||||||
audio_listen_override,
|
audio_listen_override,
|
||||||
sdr_pcm_rx,
|
sdr_pcm_rx,
|
||||||
sdr_ais_pcm_rx,
|
sdr_ais_pcm_rx,
|
||||||
|
sdr_vdes_iq_rx,
|
||||||
);
|
);
|
||||||
task_handles.extend(audio_handles);
|
task_handles.extend(audio_handles);
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ impl IqSource for MockIqSource {
|
|||||||
|
|
||||||
pub struct SdrPipeline {
|
pub struct SdrPipeline {
|
||||||
pub pcm_senders: Vec<broadcast::Sender<Vec<f32>>>,
|
pub pcm_senders: Vec<broadcast::Sender<Vec<f32>>>,
|
||||||
|
pub iq_senders: Vec<broadcast::Sender<Vec<Complex<f32>>>>,
|
||||||
pub channel_dsps: Vec<Arc<Mutex<ChannelDsp>>>,
|
pub channel_dsps: Vec<Arc<Mutex<ChannelDsp>>>,
|
||||||
/// Latest FFT magnitude bins (dBFS, FFT-shifted), updated ~10 Hz.
|
/// Latest FFT magnitude bins (dBFS, FFT-shifted), updated ~10 Hz.
|
||||||
pub spectrum_buf: Arc<Mutex<Option<Vec<f32>>>>,
|
pub spectrum_buf: Arc<Mutex<Option<Vec<f32>>>>,
|
||||||
@@ -113,10 +114,12 @@ impl SdrPipeline {
|
|||||||
const PCM_BROADCAST_CAPACITY: usize = 32;
|
const PCM_BROADCAST_CAPACITY: usize = 32;
|
||||||
|
|
||||||
let mut pcm_senders = Vec::with_capacity(channels.len());
|
let mut pcm_senders = Vec::with_capacity(channels.len());
|
||||||
|
let mut iq_senders = Vec::with_capacity(channels.len());
|
||||||
let mut channel_dsps: Vec<Arc<Mutex<ChannelDsp>>> = Vec::with_capacity(channels.len());
|
let mut channel_dsps: Vec<Arc<Mutex<ChannelDsp>>> = Vec::with_capacity(channels.len());
|
||||||
|
|
||||||
for &(channel_if_hz, ref mode, audio_bandwidth_hz, fir_taps) in channels {
|
for &(channel_if_hz, ref mode, audio_bandwidth_hz, fir_taps) in channels {
|
||||||
let (pcm_tx, _pcm_rx) = broadcast::channel::<Vec<f32>>(PCM_BROADCAST_CAPACITY);
|
let (pcm_tx, _pcm_rx) = broadcast::channel::<Vec<f32>>(PCM_BROADCAST_CAPACITY);
|
||||||
|
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
|
||||||
let dsp = ChannelDsp::new(
|
let dsp = ChannelDsp::new(
|
||||||
channel_if_hz,
|
channel_if_hz,
|
||||||
mode,
|
mode,
|
||||||
@@ -129,8 +132,10 @@ impl SdrPipeline {
|
|||||||
wfm_stereo,
|
wfm_stereo,
|
||||||
fir_taps,
|
fir_taps,
|
||||||
pcm_tx.clone(),
|
pcm_tx.clone(),
|
||||||
|
iq_tx.clone(),
|
||||||
);
|
);
|
||||||
pcm_senders.push(pcm_tx);
|
pcm_senders.push(pcm_tx);
|
||||||
|
iq_senders.push(iq_tx);
|
||||||
channel_dsps.push(Arc::new(Mutex::new(dsp)));
|
channel_dsps.push(Arc::new(Mutex::new(dsp)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +164,7 @@ impl SdrPipeline {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
pcm_senders,
|
pcm_senders,
|
||||||
|
iq_senders,
|
||||||
channel_dsps,
|
channel_dsps,
|
||||||
spectrum_buf,
|
spectrum_buf,
|
||||||
sdr_sample_rate,
|
sdr_sample_rate,
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
|
|||||||
RigMode::AM => 9_000,
|
RigMode::AM => 9_000,
|
||||||
RigMode::FM => 12_500,
|
RigMode::FM => 12_500,
|
||||||
RigMode::WFM => 180_000,
|
RigMode::WFM => 180_000,
|
||||||
RigMode::AIS | RigMode::VDES => 25_000,
|
RigMode::AIS => 25_000,
|
||||||
|
RigMode::VDES => 100_000,
|
||||||
RigMode::Other(_) => 3_000,
|
RigMode::Other(_) => 3_000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,14 +69,17 @@ pub struct ChannelDsp {
|
|||||||
frame_buf_offset: usize,
|
frame_buf_offset: usize,
|
||||||
pub frame_size: usize,
|
pub frame_size: usize,
|
||||||
pub pcm_tx: broadcast::Sender<Vec<f32>>,
|
pub pcm_tx: broadcast::Sender<Vec<f32>>,
|
||||||
|
pub iq_tx: broadcast::Sender<Vec<Complex<f32>>>,
|
||||||
scratch_mixed_i: Vec<f32>,
|
scratch_mixed_i: Vec<f32>,
|
||||||
scratch_mixed_q: Vec<f32>,
|
scratch_mixed_q: Vec<f32>,
|
||||||
scratch_filtered_i: Vec<f32>,
|
scratch_filtered_i: Vec<f32>,
|
||||||
scratch_filtered_q: Vec<f32>,
|
scratch_filtered_q: Vec<f32>,
|
||||||
scratch_decimated: Vec<Complex<f32>>,
|
scratch_decimated: Vec<Complex<f32>>,
|
||||||
|
scratch_iq_tap: Vec<Complex<f32>>,
|
||||||
pub mixer_phase: f64,
|
pub mixer_phase: f64,
|
||||||
pub mixer_phase_inc: f64,
|
pub mixer_phase_inc: f64,
|
||||||
decim_counter: usize,
|
decim_counter: usize,
|
||||||
|
iq_tap_counter: usize,
|
||||||
resample_phase: f64,
|
resample_phase: f64,
|
||||||
resample_phase_inc: f64,
|
resample_phase_inc: f64,
|
||||||
wfm_decoder: Option<WfmStereoDecoder>,
|
wfm_decoder: Option<WfmStereoDecoder>,
|
||||||
@@ -141,6 +145,7 @@ impl ChannelDsp {
|
|||||||
let rate_changed = self.decim_factor != next_decim_factor;
|
let rate_changed = self.decim_factor != next_decim_factor;
|
||||||
self.decim_factor = next_decim_factor;
|
self.decim_factor = next_decim_factor;
|
||||||
self.decim_counter = 0;
|
self.decim_counter = 0;
|
||||||
|
self.iq_tap_counter = 0;
|
||||||
self.resample_phase = 0.0;
|
self.resample_phase = 0.0;
|
||||||
self.resample_phase_inc = if self.sdr_sample_rate == 0 {
|
self.resample_phase_inc = if self.sdr_sample_rate == 0 {
|
||||||
1.0
|
1.0
|
||||||
@@ -181,6 +186,7 @@ impl ChannelDsp {
|
|||||||
wfm_stereo: bool,
|
wfm_stereo: bool,
|
||||||
fir_taps: usize,
|
fir_taps: usize,
|
||||||
pcm_tx: broadcast::Sender<Vec<f32>>,
|
pcm_tx: broadcast::Sender<Vec<f32>>,
|
||||||
|
iq_tx: broadcast::Sender<Vec<Complex<f32>>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let output_channels = output_channels.max(1);
|
let output_channels = output_channels.max(1);
|
||||||
let audio_bandwidth_hz = Self::clamp_bandwidth_for_mode(mode, audio_bandwidth_hz);
|
let audio_bandwidth_hz = Self::clamp_bandwidth_for_mode(mode, audio_bandwidth_hz);
|
||||||
@@ -224,14 +230,17 @@ impl ChannelDsp {
|
|||||||
frame_buf_offset: 0,
|
frame_buf_offset: 0,
|
||||||
frame_size,
|
frame_size,
|
||||||
pcm_tx,
|
pcm_tx,
|
||||||
|
iq_tx,
|
||||||
scratch_mixed_i: Vec::with_capacity(IQ_BLOCK_SIZE),
|
scratch_mixed_i: Vec::with_capacity(IQ_BLOCK_SIZE),
|
||||||
scratch_mixed_q: Vec::with_capacity(IQ_BLOCK_SIZE),
|
scratch_mixed_q: Vec::with_capacity(IQ_BLOCK_SIZE),
|
||||||
scratch_filtered_i: Vec::with_capacity(IQ_BLOCK_SIZE),
|
scratch_filtered_i: Vec::with_capacity(IQ_BLOCK_SIZE),
|
||||||
scratch_filtered_q: Vec::with_capacity(IQ_BLOCK_SIZE),
|
scratch_filtered_q: Vec::with_capacity(IQ_BLOCK_SIZE),
|
||||||
scratch_decimated: Vec::with_capacity(IQ_BLOCK_SIZE / decim_factor.max(1) + 1),
|
scratch_decimated: Vec::with_capacity(IQ_BLOCK_SIZE / decim_factor.max(1) + 1),
|
||||||
|
scratch_iq_tap: Vec::with_capacity(IQ_BLOCK_SIZE / decim_factor.max(1) + 1),
|
||||||
mixer_phase: 0.0,
|
mixer_phase: 0.0,
|
||||||
mixer_phase_inc,
|
mixer_phase_inc,
|
||||||
decim_counter: 0,
|
decim_counter: 0,
|
||||||
|
iq_tap_counter: 0,
|
||||||
resample_phase: 0.0,
|
resample_phase: 0.0,
|
||||||
resample_phase_inc: if sdr_sample_rate == 0 {
|
resample_phase_inc: if sdr_sample_rate == 0 {
|
||||||
1.0
|
1.0
|
||||||
@@ -357,6 +366,25 @@ impl ChannelDsp {
|
|||||||
self.scratch_decimated
|
self.scratch_decimated
|
||||||
.reserve(capacity - self.scratch_decimated.capacity());
|
.reserve(capacity - self.scratch_decimated.capacity());
|
||||||
}
|
}
|
||||||
|
if self.mode == RigMode::VDES && self.iq_tx.receiver_count() > 0 {
|
||||||
|
self.scratch_iq_tap.clear();
|
||||||
|
if self.scratch_iq_tap.capacity() < capacity {
|
||||||
|
self.scratch_iq_tap
|
||||||
|
.reserve(capacity - self.scratch_iq_tap.capacity());
|
||||||
|
}
|
||||||
|
for idx in 0..n {
|
||||||
|
self.iq_tap_counter += 1;
|
||||||
|
if self.iq_tap_counter >= self.decim_factor {
|
||||||
|
self.iq_tap_counter = 0;
|
||||||
|
let fi = filtered_i.get(idx).copied().unwrap_or(0.0);
|
||||||
|
let fq = filtered_q.get(idx).copied().unwrap_or(0.0);
|
||||||
|
self.scratch_iq_tap.push(Complex::new(fi, fq));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !self.scratch_iq_tap.is_empty() {
|
||||||
|
let _ = self.iq_tx.send(self.scratch_iq_tap.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
let decimated = &mut self.scratch_decimated;
|
let decimated = &mut self.scratch_decimated;
|
||||||
if self.wfm_decoder.is_some() {
|
if self.wfm_decoder.is_some() {
|
||||||
for idx in 0..n {
|
for idx in 0..n {
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ impl SoapySdrRig {
|
|||||||
fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
|
fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
|
||||||
match mode {
|
match mode {
|
||||||
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
|
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
|
||||||
RigMode::PKT | RigMode::AIS | RigMode::VDES => 25_000,
|
RigMode::PKT | RigMode::AIS => 25_000,
|
||||||
|
RigMode::VDES => 100_000,
|
||||||
RigMode::CW | RigMode::CWR => 500,
|
RigMode::CW | RigMode::CWR => 500,
|
||||||
RigMode::AM => 9_000,
|
RigMode::AM => 9_000,
|
||||||
RigMode::FM => 12_500,
|
RigMode::FM => 12_500,
|
||||||
@@ -297,6 +298,19 @@ impl SoapySdrRig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn subscribe_iq_channel(
|
||||||
|
&self,
|
||||||
|
channel_idx: usize,
|
||||||
|
) -> tokio::sync::broadcast::Receiver<Vec<num_complex::Complex<f32>>> {
|
||||||
|
if let Some(sender) = self.pipeline.iq_senders.get(channel_idx) {
|
||||||
|
sender.subscribe()
|
||||||
|
} else {
|
||||||
|
let (tx, rx) = tokio::sync::broadcast::channel(1);
|
||||||
|
drop(tx);
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -353,7 +367,7 @@ impl RigCat for SoapySdrRig {
|
|||||||
let half_span_hz = i128::from(self.pipeline.sdr_sample_rate) / 2;
|
let half_span_hz = i128::from(self.pipeline.sdr_sample_rate) / 2;
|
||||||
let current_center_hz = i128::from(self.center_hz);
|
let current_center_hz = i128::from(self.center_hz);
|
||||||
let target_lo_hz = i128::from(freq.hz);
|
let target_lo_hz = i128::from(freq.hz);
|
||||||
let target_hi_hz = if matches!(self.mode, RigMode::AIS | RigMode::VDES) {
|
let target_hi_hz = if self.mode == RigMode::AIS {
|
||||||
i128::from(freq.hz) + i128::from(AIS_CHANNEL_SPACING_HZ)
|
i128::from(freq.hz) + i128::from(AIS_CHANNEL_SPACING_HZ)
|
||||||
} else {
|
} else {
|
||||||
i128::from(freq.hz)
|
i128::from(freq.hz)
|
||||||
|
|||||||
Reference in New Issue
Block a user