[feat](trx-rs): add VDES hard-decision FEC stage
Improve the VDES decoder with sync/rotation metadata and a first hard-decision rate-1/2 Viterbi stage after deinterleaving, then surface the extra lock state in the VDES frontend. Also fix the strict clippy findings in AIS, frontend bookmarks, and the server audio stack signature. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -344,7 +344,7 @@ fn decode_coord(raw: i32, invalid_abs: f64) -> Option<f64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn decode_sixbit_text(bits: &[u8], start: usize, len: usize) -> Option<String> {
|
fn decode_sixbit_text(bits: &[u8], start: usize, len: usize) -> Option<String> {
|
||||||
if start.checked_add(len)? > bits.len() || len % 6 != 0 {
|
if start.checked_add(len)? > bits.len() || !len.is_multiple_of(6) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
//! - coarse symbol timing at the 76.8 ksps VDE-TER baseline
|
//! - coarse symbol timing at the 76.8 ksps VDE-TER baseline
|
||||||
//! - `pi/4`-QPSK quadrant slicing
|
//! - `pi/4`-QPSK quadrant slicing
|
||||||
//!
|
//!
|
||||||
//! It intentionally stops at a raw burst payload stage. Full M.2092-1 FEC,
|
//! It performs a first hard-decision FEC stage for the `TER-MCS-1.100` 1/2-rate
|
||||||
//! interleaving, link-layer parsing, and application payload decoding are not
|
//! path after deinterleaving, but full M.2092-1 turbo/puncture handling,
|
||||||
//! implemented yet.
|
//! link-layer parsing, and application payload decoding are not implemented yet.
|
||||||
|
|
||||||
use num_complex::Complex;
|
use num_complex::Complex;
|
||||||
use trx_core::decode::VdesMessage;
|
use trx_core::decode::VdesMessage;
|
||||||
@@ -28,6 +28,11 @@ const TER_MCS1_100_RAMP_SYMBOLS: usize = 32;
|
|||||||
const TER_MCS1_100_SYNC_SYMBOLS: usize = 27;
|
const TER_MCS1_100_SYNC_SYMBOLS: usize = 27;
|
||||||
const TER_MCS1_100_LINK_ID_SYMBOLS: usize = 16;
|
const TER_MCS1_100_LINK_ID_SYMBOLS: usize = 16;
|
||||||
const TER_MCS1_100_PAYLOAD_SYMBOLS: usize = 1_877;
|
const TER_MCS1_100_PAYLOAD_SYMBOLS: usize = 1_877;
|
||||||
|
const TER_MCS1_100_FEC_INPUT_SYMBOLS: usize = 1_872;
|
||||||
|
const TER_MCS1_100_FEC_OUTPUT_BITS: usize = 1_872;
|
||||||
|
const TER_MCS1_100_FEC_TAIL_BITS: usize = 10;
|
||||||
|
const TER_MCS1_100_SYNC_BITS: &[u8; TER_MCS1_100_SYNC_SYMBOLS] = b"111111001101010000011001010";
|
||||||
|
const PI4_QPSK_DIBITS: [u8; 4] = [0b00, 0b01, 0b11, 0b10];
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct VdesDecoder {
|
pub struct VdesDecoder {
|
||||||
@@ -115,12 +120,27 @@ impl VdesDecoder {
|
|||||||
let link_id = decode_link_id_from_symbols(&framed.symbols);
|
let link_id = decode_link_id_from_symbols(&framed.symbols);
|
||||||
let payload_symbols = framed.payload_symbols();
|
let payload_symbols = framed.payload_symbols();
|
||||||
let deinterleaved = deinterleave_100khz_frame(payload_symbols);
|
let deinterleaved = deinterleave_100khz_frame(payload_symbols);
|
||||||
let raw_bytes = pack_dibits_msb(&deinterleaved);
|
let (fec_input_symbols, fec_tail_symbols) = split_fec_frame(&deinterleaved);
|
||||||
|
let coded_bits = dibits_to_bits(fec_input_symbols);
|
||||||
|
let decoded_bits = viterbi_decode_rate_half(&coded_bits);
|
||||||
|
if decoded_bits.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let raw_bytes = pack_bits_msb(&decoded_bits);
|
||||||
let rms = burst_rms(&samples);
|
let rms = burst_rms(&samples);
|
||||||
let mode = classify_vdes_burst(framed.symbols.len());
|
let mode = classify_vdes_burst(framed.symbols.len());
|
||||||
let link_text = link_id
|
let link_text = link_id
|
||||||
.map(|value| format!("LID {}", value))
|
.map(|value| format!("LID {}", value))
|
||||||
.unwrap_or_else(|| "LID ?".to_string());
|
.unwrap_or_else(|| "LID ?".to_string());
|
||||||
|
let tail_zero_bits = dibits_to_bits(fec_tail_symbols)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|bit| *bit == 0)
|
||||||
|
.count();
|
||||||
|
let fec_state = format!(
|
||||||
|
"Hard-decision 1/2 Viterbi, tail {} / {} zero bits",
|
||||||
|
tail_zero_bits,
|
||||||
|
TER_MCS1_100_FEC_TAIL_BITS
|
||||||
|
);
|
||||||
|
|
||||||
Some(VdesMessage {
|
Some(VdesMessage {
|
||||||
ts_ms: None,
|
ts_ms: None,
|
||||||
@@ -129,7 +149,7 @@ impl VdesDecoder {
|
|||||||
repeat: 0,
|
repeat: 0,
|
||||||
mmsi: 0,
|
mmsi: 0,
|
||||||
crc_ok: false,
|
crc_ok: false,
|
||||||
bit_len: deinterleaved.len() * 2,
|
bit_len: decoded_bits.len(),
|
||||||
raw_bytes,
|
raw_bytes,
|
||||||
lat: None,
|
lat: None,
|
||||||
lon: None,
|
lon: None,
|
||||||
@@ -140,9 +160,16 @@ impl VdesDecoder {
|
|||||||
vessel_name: Some(format!("VDES Frame {} sym", framed.symbols.len())),
|
vessel_name: Some(format!("VDES Frame {} sym", framed.symbols.len())),
|
||||||
callsign: Some(format!("{} {} @{}", mode.label, link_text, framed.start_offset)),
|
callsign: Some(format!("{} {} @{}", mode.label, link_text, framed.start_offset)),
|
||||||
destination: Some(format!(
|
destination: Some(format!(
|
||||||
"TER-MCS-1.100 RMS {:.2} sync {:.2} turbo FEC pending",
|
"TER-MCS-1.100 RMS {:.2} sync {:.0}% rot {}",
|
||||||
rms, framed.preamble_score
|
rms,
|
||||||
|
framed.sync_score * 100.0,
|
||||||
|
framed.phase_rotation
|
||||||
)),
|
)),
|
||||||
|
link_id,
|
||||||
|
sync_score: Some(framed.sync_score),
|
||||||
|
sync_errors: Some(framed.sync_errors),
|
||||||
|
phase_rotation: Some(framed.phase_rotation),
|
||||||
|
fec_state: Some(fec_state),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +210,9 @@ struct BurstMode<'a> {
|
|||||||
|
|
||||||
struct FrameSlice {
|
struct FrameSlice {
|
||||||
start_offset: usize,
|
start_offset: usize,
|
||||||
preamble_score: f32,
|
sync_score: f32,
|
||||||
|
sync_errors: u8,
|
||||||
|
phase_rotation: u8,
|
||||||
symbols: Vec<u8>,
|
symbols: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,61 +242,63 @@ fn classify_vdes_burst(symbols: usize) -> BurstMode<'static> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn extract_candidate_frame(symbols: &[u8]) -> Option<FrameSlice> {
|
fn extract_candidate_frame(symbols: &[u8]) -> Option<FrameSlice> {
|
||||||
if symbols.len() < TER_MCS1_100_SYNC_SYMBOLS {
|
if symbols.len() < TER_MCS1_100_RAMP_SYMBOLS + TER_MCS1_100_SYNC_SYMBOLS {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let search_limit = symbols
|
let search_limit = symbols
|
||||||
.len()
|
.len()
|
||||||
.saturating_sub(TER_MCS1_100_BURST_SYMBOLS.saturating_sub(TER_MCS1_100_SYNC_SYMBOLS));
|
.saturating_sub(TER_MCS1_100_RAMP_SYMBOLS + TER_MCS1_100_SYNC_SYMBOLS);
|
||||||
let mut best_offset = 0usize;
|
let mut best_offset = 0usize;
|
||||||
let mut best_score = f32::MIN;
|
let mut best_score = 0.0_f32;
|
||||||
|
let mut best_errors = u8::MAX;
|
||||||
|
let mut best_rotation = 0u8;
|
||||||
|
|
||||||
for offset in 0..=search_limit {
|
for offset in 0..=search_limit {
|
||||||
let sync_offset = offset + TER_MCS1_100_RAMP_SYMBOLS;
|
let sync_offset = offset + TER_MCS1_100_RAMP_SYMBOLS;
|
||||||
if sync_offset >= symbols.len() {
|
let sync_window = &symbols[sync_offset..sync_offset + TER_MCS1_100_SYNC_SYMBOLS];
|
||||||
break;
|
for rotation in 0..4 {
|
||||||
}
|
let (score, errors) = syncword_score(sync_window, rotation);
|
||||||
let score = preamble_like_score(&symbols[sync_offset..]);
|
if score > best_score || (score == best_score && errors < best_errors) {
|
||||||
if score > best_score {
|
best_score = score;
|
||||||
best_score = score;
|
best_errors = errors;
|
||||||
best_offset = offset;
|
best_rotation = rotation;
|
||||||
|
best_offset = offset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if best_score <= 0.5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let available = symbols.len().saturating_sub(best_offset);
|
let available = symbols.len().saturating_sub(best_offset);
|
||||||
if available < MIN_BURST_SYMBOLS {
|
if available < MIN_BURST_SYMBOLS {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let take = available.min(TER_MCS1_100_BURST_SYMBOLS);
|
let take = available.min(TER_MCS1_100_BURST_SYMBOLS);
|
||||||
|
let rotated = rotate_pi4_stream(&symbols[best_offset..best_offset + take], best_rotation);
|
||||||
Some(FrameSlice {
|
Some(FrameSlice {
|
||||||
start_offset: best_offset,
|
start_offset: best_offset,
|
||||||
preamble_score: best_score,
|
sync_score: best_score,
|
||||||
symbols: symbols[best_offset..best_offset + take].to_vec(),
|
sync_errors: best_errors,
|
||||||
|
phase_rotation: best_rotation,
|
||||||
|
symbols: rotated,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn preamble_like_score(symbols: &[u8]) -> f32 {
|
fn syncword_score(symbols: &[u8], rotation: u8) -> (f32, u8) {
|
||||||
if symbols.len() < TER_MCS1_100_SYNC_SYMBOLS {
|
if symbols.len() < TER_MCS1_100_SYNC_SYMBOLS {
|
||||||
return f32::MIN;
|
return (0.0, u8::MAX);
|
||||||
}
|
}
|
||||||
let window = &symbols[..TER_MCS1_100_SYNC_SYMBOLS];
|
let mut bit_errors = 0u8;
|
||||||
let mut score = 0.0_f32;
|
for (idx, &dibit) in symbols.iter().take(TER_MCS1_100_SYNC_SYMBOLS).enumerate() {
|
||||||
for (idx, &dibit) in window.iter().enumerate() {
|
let rotated = rotate_pi4_dibit(dibit, rotation);
|
||||||
if dibit == 0b00 || dibit == 0b11 {
|
let expected = sync_reference_dibit(idx);
|
||||||
score += 1.0;
|
bit_errors = bit_errors.saturating_add(dibit_bit_distance(rotated, expected) as u8);
|
||||||
} else {
|
|
||||||
score -= 1.5;
|
|
||||||
}
|
|
||||||
if idx > 0 {
|
|
||||||
if dibit != window[idx - 1] {
|
|
||||||
score += 0.4;
|
|
||||||
} else {
|
|
||||||
score -= 0.2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
score / TER_MCS1_100_SYNC_SYMBOLS as f32
|
let max_bits = (TER_MCS1_100_SYNC_SYMBOLS * 2) as f32;
|
||||||
|
let score = 1.0 - (bit_errors as f32 / max_bits);
|
||||||
|
(score.clamp(0.0, 1.0), bit_errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deinterleave_100khz_frame(symbols: &[u8]) -> Vec<u8> {
|
fn deinterleave_100khz_frame(symbols: &[u8]) -> Vec<u8> {
|
||||||
@@ -290,6 +321,84 @@ fn deinterleave_100khz_frame(symbols: &[u8]) -> Vec<u8> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn split_fec_frame(symbols: &[u8]) -> (&[u8], &[u8]) {
|
||||||
|
let input_end = symbols.len().min(TER_MCS1_100_FEC_INPUT_SYMBOLS);
|
||||||
|
let tail_end = symbols
|
||||||
|
.len()
|
||||||
|
.min(TER_MCS1_100_FEC_INPUT_SYMBOLS + (TER_MCS1_100_FEC_TAIL_BITS / 2));
|
||||||
|
(&symbols[..input_end], &symbols[input_end..tail_end])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn viterbi_decode_rate_half(coded_bits: &[u8]) -> Vec<u8> {
|
||||||
|
if coded_bits.len() < 2 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let pair_count = coded_bits.len() / 2;
|
||||||
|
let mut metrics = [u16::MAX; 64];
|
||||||
|
let mut next_metrics = [u16::MAX; 64];
|
||||||
|
let mut predecessors = vec![[0u8; 64]; pair_count];
|
||||||
|
metrics[0] = 0;
|
||||||
|
|
||||||
|
for step in 0..pair_count {
|
||||||
|
next_metrics.fill(u16::MAX);
|
||||||
|
let recv0 = coded_bits[step * 2] & 1;
|
||||||
|
let recv1 = coded_bits[step * 2 + 1] & 1;
|
||||||
|
|
||||||
|
for (state, &metric) in metrics.iter().enumerate() {
|
||||||
|
if metric == u16::MAX {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for input_bit in 0..=1u8 {
|
||||||
|
let reg = ((state as u8) << 1) | input_bit;
|
||||||
|
let out = conv_encode_output(reg);
|
||||||
|
let branch = dibit_bit_distance(out, (recv0 << 1) | recv1) as u16;
|
||||||
|
let next_state = (reg & 0x3f) as usize;
|
||||||
|
let candidate = metric.saturating_add(branch);
|
||||||
|
if candidate < next_metrics[next_state] {
|
||||||
|
next_metrics[next_state] = candidate;
|
||||||
|
predecessors[step][next_state] = state as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics = next_metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut best_state = 0usize;
|
||||||
|
let mut best_metric = u16::MAX;
|
||||||
|
for (state, &metric) in metrics.iter().enumerate() {
|
||||||
|
if metric < best_metric {
|
||||||
|
best_metric = metric;
|
||||||
|
best_state = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best_metric == u16::MAX {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut decoded = vec![0u8; pair_count];
|
||||||
|
let mut state = best_state;
|
||||||
|
for step in (0..pair_count).rev() {
|
||||||
|
let bit = (state as u8) & 1;
|
||||||
|
decoded[step] = bit;
|
||||||
|
state = predecessors[step][state] as usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded.truncate(TER_MCS1_100_FEC_OUTPUT_BITS.min(decoded.len()));
|
||||||
|
decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conv_encode_output(reg: u8) -> u8 {
|
||||||
|
let g0 = parity6_7(reg & 0o171);
|
||||||
|
let g1 = parity6_7(reg & 0o133);
|
||||||
|
(g0 << 1) | g1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parity6_7(value: u8) -> u8 {
|
||||||
|
(value.count_ones() as u8) & 1
|
||||||
|
}
|
||||||
|
|
||||||
fn decode_link_id_from_symbols(symbols: &[u8]) -> Option<u8> {
|
fn decode_link_id_from_symbols(symbols: &[u8]) -> Option<u8> {
|
||||||
let start = TER_MCS1_100_RAMP_SYMBOLS + TER_MCS1_100_SYNC_SYMBOLS;
|
let start = TER_MCS1_100_RAMP_SYMBOLS + TER_MCS1_100_SYNC_SYMBOLS;
|
||||||
let end = start + TER_MCS1_100_LINK_ID_SYMBOLS;
|
let end = start + TER_MCS1_100_LINK_ID_SYMBOLS;
|
||||||
@@ -303,6 +412,35 @@ fn decode_link_id_from_symbols(symbols: &[u8]) -> Option<u8> {
|
|||||||
decode_rm_1_5(&bits)
|
decode_rm_1_5(&bits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_reference_dibit(idx: usize) -> u8 {
|
||||||
|
match TER_MCS1_100_SYNC_BITS[idx] {
|
||||||
|
b'1' => 0b11,
|
||||||
|
_ => 0b00,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_pi4_dibit(dibit: u8, rotation: u8) -> u8 {
|
||||||
|
let pos = PI4_QPSK_DIBITS
|
||||||
|
.iter()
|
||||||
|
.position(|candidate| *candidate == (dibit & 0b11))
|
||||||
|
.unwrap_or(0);
|
||||||
|
PI4_QPSK_DIBITS[(pos + rotation as usize) % PI4_QPSK_DIBITS.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_pi4_stream(symbols: &[u8], rotation: u8) -> Vec<u8> {
|
||||||
|
if rotation == 0 {
|
||||||
|
return symbols.to_vec();
|
||||||
|
}
|
||||||
|
symbols
|
||||||
|
.iter()
|
||||||
|
.map(|dibit| rotate_pi4_dibit(*dibit, rotation))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dibit_bit_distance(a: u8, b: u8) -> usize {
|
||||||
|
((a ^ b) & 0b11).count_ones() as usize
|
||||||
|
}
|
||||||
|
|
||||||
fn dibits_to_bits(symbols: &[u8]) -> Vec<u8> {
|
fn dibits_to_bits(symbols: &[u8]) -> Vec<u8> {
|
||||||
let mut out = Vec::with_capacity(symbols.len() * 2);
|
let mut out = Vec::with_capacity(symbols.len() * 2);
|
||||||
for &dibit in symbols {
|
for &dibit in symbols {
|
||||||
@@ -312,6 +450,18 @@ fn dibits_to_bits(symbols: &[u8]) -> Vec<u8> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bits_to_dibits(bits: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(bits.len().div_ceil(2));
|
||||||
|
let mut idx = 0usize;
|
||||||
|
while idx < bits.len() {
|
||||||
|
let hi = bits[idx] & 1;
|
||||||
|
let lo = bits.get(idx + 1).copied().unwrap_or(0) & 1;
|
||||||
|
out.push((hi << 1) | lo);
|
||||||
|
idx += 2;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn decode_rm_1_5(bits: &[u8]) -> Option<u8> {
|
fn decode_rm_1_5(bits: &[u8]) -> Option<u8> {
|
||||||
if bits.len() != 32 {
|
if bits.len() != 32 {
|
||||||
return None;
|
return None;
|
||||||
@@ -345,13 +495,13 @@ fn rm_1_5_codeword(value: u8) -> [u8; 32] {
|
|||||||
let a4 = (value >> 1) & 1;
|
let a4 = (value >> 1) & 1;
|
||||||
let a5 = value & 1;
|
let a5 = value & 1;
|
||||||
let mut out = [0u8; 32];
|
let mut out = [0u8; 32];
|
||||||
for idx in 0..32 {
|
for (idx, slot) in out.iter_mut().enumerate() {
|
||||||
let x1 = ((idx >> 4) & 1) as u8;
|
let x1 = ((idx >> 4) & 1) as u8;
|
||||||
let x2 = ((idx >> 3) & 1) as u8;
|
let x2 = ((idx >> 3) & 1) as u8;
|
||||||
let x3 = ((idx >> 2) & 1) as u8;
|
let x3 = ((idx >> 2) & 1) as u8;
|
||||||
let x4 = ((idx >> 1) & 1) as u8;
|
let x4 = ((idx >> 1) & 1) as u8;
|
||||||
let x5 = (idx & 1) as u8;
|
let x5 = (idx & 1) as u8;
|
||||||
out[idx] = a0 ^ (a1 & x1) ^ (a2 & x2) ^ (a3 & x3) ^ (a4 & x4) ^ (a5 & x5);
|
*slot = a0 ^ (a1 & x1) ^ (a2 & x2) ^ (a3 & x3) ^ (a4 & x4) ^ (a5 & x5);
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -417,7 +567,7 @@ fn quantize_pi4_qpsk(sample: Complex<f32>) -> u8 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn pack_dibits_msb(symbols: &[u8]) -> Vec<u8> {
|
fn pack_dibits_msb(symbols: &[u8]) -> Vec<u8> {
|
||||||
let mut out = Vec::with_capacity((symbols.len() + 3) / 4);
|
let mut out = Vec::with_capacity(symbols.len().div_ceil(4));
|
||||||
let mut byte = 0u8;
|
let mut byte = 0u8;
|
||||||
let mut count = 0usize;
|
let mut count = 0usize;
|
||||||
|
|
||||||
@@ -425,19 +575,24 @@ fn pack_dibits_msb(symbols: &[u8]) -> Vec<u8> {
|
|||||||
let shift = 6usize.saturating_sub((count % 4) * 2);
|
let shift = 6usize.saturating_sub((count % 4) * 2);
|
||||||
byte |= (dibit & 0b11) << shift;
|
byte |= (dibit & 0b11) << shift;
|
||||||
count += 1;
|
count += 1;
|
||||||
if count % 4 == 0 {
|
if count.is_multiple_of(4) {
|
||||||
out.push(byte);
|
out.push(byte);
|
||||||
byte = 0;
|
byte = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if count % 4 != 0 {
|
if !count.is_multiple_of(4) {
|
||||||
out.push(byte);
|
out.push(byte);
|
||||||
}
|
}
|
||||||
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pack_bits_msb(bits: &[u8]) -> Vec<u8> {
|
||||||
|
let dibits = bits_to_dibits(bits);
|
||||||
|
pack_dibits_msb(&dibits)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -486,10 +641,40 @@ mod tests {
|
|||||||
assert!(frame.symbols.len() >= MIN_BURST_SYMBOLS);
|
assert!(frame.symbols.len() >= MIN_BURST_SYMBOLS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn syncword_score_prefers_correct_rotation() {
|
||||||
|
let sync: Vec<u8> = (0..TER_MCS1_100_SYNC_SYMBOLS)
|
||||||
|
.map(sync_reference_dibit)
|
||||||
|
.collect();
|
||||||
|
let rotated = rotate_pi4_stream(&sync, 2);
|
||||||
|
let (wrong_score, wrong_errors) = syncword_score(&rotated, 0);
|
||||||
|
let (right_score, right_errors) = syncword_score(&rotated, 2);
|
||||||
|
assert!(right_score > wrong_score);
|
||||||
|
assert!(right_errors < wrong_errors);
|
||||||
|
assert_eq!(right_errors, 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deinterleave_preserves_length() {
|
fn deinterleave_preserves_length() {
|
||||||
let symbols: Vec<u8> = (0..127).map(|idx| (idx % 4) as u8).collect();
|
let symbols: Vec<u8> = (0..127).map(|idx| (idx % 4) as u8).collect();
|
||||||
let out = deinterleave_100khz_frame(&symbols);
|
let out = deinterleave_100khz_frame(&symbols);
|
||||||
assert_eq!(out.len(), symbols.len());
|
assert_eq!(out.len(), symbols.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn viterbi_decodes_k7_rate_half_stream() {
|
||||||
|
let input: Vec<u8> = (0..TER_MCS1_100_FEC_OUTPUT_BITS)
|
||||||
|
.map(|idx| ((idx * 5 + 1) % 2) as u8)
|
||||||
|
.collect();
|
||||||
|
let mut state = 0u8;
|
||||||
|
let mut coded = Vec::with_capacity(input.len() * 2);
|
||||||
|
for &bit in &input {
|
||||||
|
state = ((state << 1) | bit) & 0x7f;
|
||||||
|
let dibit = conv_encode_output(state);
|
||||||
|
coded.push((dibit >> 1) & 1);
|
||||||
|
coded.push(dibit & 1);
|
||||||
|
}
|
||||||
|
let decoded = viterbi_decode_rate_half(&coded);
|
||||||
|
assert_eq!(decoded, input);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,8 +79,23 @@ function renderVdesRow(msg) {
|
|||||||
const title = msg.vessel_name || "VDES Burst";
|
const title = msg.vessel_name || "VDES Burst";
|
||||||
const label = msg.callsign || "VDES";
|
const label = msg.callsign || "VDES";
|
||||||
const info = msg.destination || "";
|
const info = msg.destination || "";
|
||||||
|
const linkText = Number.isFinite(msg.link_id) ? `LID ${msg.link_id}` : "";
|
||||||
|
const syncText = Number.isFinite(msg.sync_score) ? `Sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : "";
|
||||||
|
const phaseText = Number.isFinite(msg.phase_rotation) ? `R${Number(msg.phase_rotation)}` : "";
|
||||||
|
const fecText = msg.fec_state || "";
|
||||||
const rawHex = vdesHexPreview(msg.raw_bytes);
|
const rawHex = vdesHexPreview(msg.raw_bytes);
|
||||||
row.dataset.filterText = [title, label, info, rawHex, msg.message_type, msg.bit_len]
|
row.dataset.filterText = [
|
||||||
|
title,
|
||||||
|
label,
|
||||||
|
info,
|
||||||
|
linkText,
|
||||||
|
syncText,
|
||||||
|
phaseText,
|
||||||
|
fecText,
|
||||||
|
rawHex,
|
||||||
|
msg.message_type,
|
||||||
|
msg.bit_len,
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
@@ -89,12 +104,16 @@ function renderVdesRow(msg) {
|
|||||||
`<span class="vdes-time">${ts}</span>` +
|
`<span class="vdes-time">${ts}</span>` +
|
||||||
`<span class="vdes-call">${escapeMapHtml(title)}</span>` +
|
`<span class="vdes-call">${escapeMapHtml(title)}</span>` +
|
||||||
`<span class="vdes-badge">${escapeMapHtml(label)}</span>` +
|
`<span class="vdes-badge">${escapeMapHtml(label)}</span>` +
|
||||||
|
(linkText ? `<span class="vdes-badge">${escapeMapHtml(linkText)}</span>` : "") +
|
||||||
|
(syncText ? `<span class="vdes-badge">${escapeMapHtml(syncText)}</span>` : "") +
|
||||||
|
(phaseText ? `<span class="vdes-badge">${escapeMapHtml(phaseText)}</span>` : "") +
|
||||||
`<span class="vdes-badge">T${escapeMapHtml(String(msg.message_type ?? "--"))}</span>` +
|
`<span class="vdes-badge">T${escapeMapHtml(String(msg.message_type ?? "--"))}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="vdes-row-meta">` +
|
`<div class="vdes-row-meta">` +
|
||||||
`<span>${escapeMapHtml(currentVdesCenterText())}</span>` +
|
`<span>${escapeMapHtml(currentVdesCenterText())}</span>` +
|
||||||
`<span>${escapeMapHtml(`${msg.bit_len || 0} bits`)}</span>` +
|
`<span>${escapeMapHtml(`${msg.bit_len || 0} bits`)}</span>` +
|
||||||
(info ? `<span>${escapeMapHtml(info)}</span>` : "") +
|
(info ? `<span>${escapeMapHtml(info)}</span>` : "") +
|
||||||
|
(fecText ? `<span>${escapeMapHtml(fecText)}</span>` : "") +
|
||||||
`<span>${escapeMapHtml(vdesAgeText(msg._tsMs))}</span>` +
|
`<span>${escapeMapHtml(vdesAgeText(msg._tsMs))}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="vdes-row-detail">` +
|
`<div class="vdes-row-detail">` +
|
||||||
@@ -123,6 +142,9 @@ function updateVdesBar() {
|
|||||||
const title = escapeMapHtml(msg.vessel_name || "Burst");
|
const title = escapeMapHtml(msg.vessel_name || "Burst");
|
||||||
const detail = [
|
const detail = [
|
||||||
`${msg.bit_len || 0} bits`,
|
`${msg.bit_len || 0} bits`,
|
||||||
|
Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null,
|
||||||
|
Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null,
|
||||||
|
Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
|
||||||
msg.destination ? escapeMapHtml(msg.destination) : null,
|
msg.destination ? escapeMapHtml(msg.destination) : null,
|
||||||
escapeMapHtml(vdesAgeText(msg._tsMs)),
|
escapeMapHtml(vdesAgeText(msg._tsMs)),
|
||||||
]
|
]
|
||||||
@@ -193,6 +215,11 @@ window.onServerVdes = function(msg) {
|
|||||||
vessel_name: msg.vessel_name,
|
vessel_name: msg.vessel_name,
|
||||||
callsign: msg.callsign,
|
callsign: msg.callsign,
|
||||||
destination: msg.destination,
|
destination: msg.destination,
|
||||||
|
link_id: msg.link_id,
|
||||||
|
sync_score: msg.sync_score,
|
||||||
|
sync_errors: msg.sync_errors,
|
||||||
|
phase_rotation: msg.phase_rotation,
|
||||||
|
fec_state: msg.fec_state,
|
||||||
ts_ms: msg.ts_ms,
|
ts_ms: msg.ts_ms,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ impl BookmarkStore {
|
|||||||
/// Returns true if any bookmark (other than `exclude_id`) has `freq_hz`.
|
/// Returns true if any bookmark (other than `exclude_id`) has `freq_hz`.
|
||||||
pub fn freq_taken(&self, freq_hz: u64, exclude_id: Option<&str>) -> bool {
|
pub fn freq_taken(&self, freq_hz: u64, exclude_id: Option<&str>) -> bool {
|
||||||
self.list().into_iter().any(|bm| {
|
self.list().into_iter().any(|bm| {
|
||||||
bm.freq_hz == freq_hz && exclude_id.map_or(true, |ex| bm.id != ex)
|
bm.freq_hz == freq_hz && exclude_id.is_none_or(|ex| bm.id != ex)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,16 @@ pub struct VdesMessage {
|
|||||||
pub callsign: Option<String>,
|
pub callsign: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub destination: Option<String>,
|
pub destination: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub link_id: Option<u8>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sync_score: Option<f32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sync_errors: Option<u8>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub phase_rotation: Option<u8>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub fec_state: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -432,12 +432,9 @@ fn spawn_rig_audio_stack(
|
|||||||
latitude: Option<f64>,
|
latitude: Option<f64>,
|
||||||
longitude: Option<f64>,
|
longitude: Option<f64>,
|
||||||
listen_override: Option<IpAddr>,
|
listen_override: Option<IpAddr>,
|
||||||
sdr_pcm_rx: Option<broadcast::Receiver<Vec<f32>>>,
|
sdr_pcm_rx: OptionalSdrPcmRx,
|
||||||
sdr_ais_pcm_rx: Option<(
|
sdr_ais_pcm_rx: OptionalSdrAisPcmRx,
|
||||||
broadcast::Receiver<Vec<f32>>,
|
sdr_vdes_iq_rx: OptionalSdrVdesIqRx,
|
||||||
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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user