[feat](trx-frontend): improve AIS decode and decoder views
Improve the AIS decoder timing recovery, add AIS vessel linking and map trails, and make the AIS/APRS decoder panels behave like mode-bound views with full-height history panes. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
+133
-17
@@ -50,10 +50,15 @@ struct RawFrame {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AisDecoder {
|
||||
sample_rate: f32,
|
||||
symbol_phase: f32,
|
||||
samples_per_symbol: f32,
|
||||
sample_clock: f32,
|
||||
dc_state: f32,
|
||||
lp_state: f32,
|
||||
lp_fast: 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,
|
||||
@@ -63,12 +68,18 @@ pub struct AisDecoder {
|
||||
|
||||
impl AisDecoder {
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
let sample_rate = sample_rate.max(1) as f32;
|
||||
Self {
|
||||
sample_rate: sample_rate.max(1) as f32,
|
||||
symbol_phase: 0.0,
|
||||
sample_rate,
|
||||
samples_per_symbol: sample_rate / AIS_BAUD,
|
||||
sample_clock: 0.0,
|
||||
dc_state: 0.0,
|
||||
lp_state: 0.0,
|
||||
lp_fast: 0.0,
|
||||
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,
|
||||
@@ -78,10 +89,15 @@ impl AisDecoder {
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.symbol_phase = 0.0;
|
||||
self.samples_per_symbol = self.sample_rate / AIS_BAUD;
|
||||
self.sample_clock = 0.0;
|
||||
self.dc_state = 0.0;
|
||||
self.lp_state = 0.0;
|
||||
self.lp_fast = 0.0;
|
||||
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;
|
||||
@@ -109,25 +125,59 @@ impl AisDecoder {
|
||||
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);
|
||||
// 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.02 * (self.lp_state.abs() - self.env_state);
|
||||
self.env_state += 0.015 * (shaped.abs() - self.env_state);
|
||||
let normalized = if self.env_state > 1e-4 {
|
||||
self.lp_state / self.env_state
|
||||
shaped / self.env_state
|
||||
} else {
|
||||
self.lp_state
|
||||
shaped
|
||||
};
|
||||
|
||||
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 };
|
||||
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 / AIS_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;
|
||||
@@ -146,7 +196,7 @@ impl AisDecoder {
|
||||
}
|
||||
|
||||
if self.ones == 6 {
|
||||
if self.in_frame && self.frame_bits.len() >= 256 {
|
||||
if self.in_frame {
|
||||
if let Some(frame) = self.bits_to_frame() {
|
||||
self.frames.push(frame);
|
||||
}
|
||||
@@ -373,6 +423,52 @@ fn decode_sixbit_text(bits: &[u8], start: usize, len: usize) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn payload_with_crc(payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = payload.to_vec();
|
||||
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]
|
||||
fn decodes_signed_coordinates() {
|
||||
assert_eq!(decode_coord(60_000, 181.0), Some(0.1));
|
||||
@@ -386,4 +482,24 @@ mod tests {
|
||||
let text = decode_sixbit_text(&bits, 0, 36);
|
||||
assert!(text.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_hdlc_frame_from_raw_nrzi_bits() {
|
||||
let payload = [0x11_u8, 0x22_u8, 0x7E_u8, 0x00_u8, 0xF0_u8];
|
||||
let frame_bytes = payload_with_crc(&payload);
|
||||
let mut hdlc_bits = bytes_to_lsb_bits(&[0x7E]);
|
||||
hdlc_bits.extend(bitstuff(&bytes_to_lsb_bits(&frame_bytes)));
|
||||
hdlc_bits.extend(bytes_to_lsb_bits(&[0x7E]));
|
||||
let raw_bits = nrzi_encode(&hdlc_bits);
|
||||
|
||||
let mut decoder = AisDecoder::new(48_000);
|
||||
for raw_bit in raw_bits {
|
||||
decoder.process_symbol(raw_bit);
|
||||
}
|
||||
|
||||
assert_eq!(decoder.frames.len(), 1);
|
||||
let frame = &decoder.frames[0];
|
||||
assert!(frame.crc_ok);
|
||||
assert_eq!(frame.payload, payload);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user