Compare commits
5 Commits
83e9ba9fb1
...
1911a109e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
1911a109e2
|
|||
| 7178ebeb23 | |||
| c92428b78b | |||
| 5de972dd61 | |||
| aed9483659 |
Generated
+2
@@ -3178,6 +3178,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"actix-web",
|
"actix-web",
|
||||||
"actix-ws",
|
"actix-ws",
|
||||||
|
"base64",
|
||||||
"brotli 7.0.0",
|
"brotli 7.0.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -3261,6 +3262,7 @@ dependencies = [
|
|||||||
name = "trx-server"
|
name = "trx-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ pub struct WefaxConfig {
|
|||||||
pub output_dir: Option<String>,
|
pub output_dir: Option<String>,
|
||||||
/// Whether to emit line-by-line progress events.
|
/// Whether to emit line-by-line progress events.
|
||||||
pub emit_progress: bool,
|
pub emit_progress: bool,
|
||||||
/// Whether to continuously track and correct sample-clock drift
|
|
||||||
/// (line-to-line cross-correlation) to remove image slant.
|
|
||||||
pub slant_correction: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for WefaxConfig {
|
impl Default for WefaxConfig {
|
||||||
@@ -33,7 +30,6 @@ impl Default for WefaxConfig {
|
|||||||
deviation_hz: 400.0,
|
deviation_hz: 400.0,
|
||||||
output_dir: None,
|
output_dir: None,
|
||||||
emit_progress: true,
|
emit_progress: true,
|
||||||
slant_correction: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,26 +43,11 @@ const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2;
|
|||||||
/// fldigi's line-to-line correlation check for automatic stop.
|
/// fldigi's line-to-line correlation check for automatic stop.
|
||||||
const LINE_CORR_NOISE_LINES: u32 = 30;
|
const LINE_CORR_NOISE_LINES: u32 = 30;
|
||||||
|
|
||||||
/// Pearson correlation above which adjacent lines are considered good
|
|
||||||
/// evidence of real image content. Used to verify unverified auto-starts.
|
|
||||||
const LINE_CORR_IMAGE_THRESHOLD: f32 = 0.5;
|
|
||||||
|
|
||||||
/// Number of consecutive well-correlated lines that verify an unverified
|
|
||||||
/// reception (i.e. an auto-start from variance detection). Low enough to
|
|
||||||
/// engage quickly on real imagery.
|
|
||||||
const VERIFY_HIGH_CORR_STREAK: u32 = 5;
|
|
||||||
|
|
||||||
/// Maximum number of scan lines the verifier waits for before giving up on
|
|
||||||
/// an unverified reception. Roughly 20 s at 120 LPM. If no high-correlation
|
|
||||||
/// streak appears by then, the buffered content is dropped and we return
|
|
||||||
/// to Idle without saving anything.
|
|
||||||
const VERIFY_TIMEOUT_LINES: u32 = 40;
|
|
||||||
|
|
||||||
/// Maximum number of scan-line-equivalent sample windows to wait for phasing
|
/// Maximum number of scan-line-equivalent sample windows to wait for phasing
|
||||||
/// lock before falling through to Receiving (unverified). Typical WEFAX
|
/// lock before falling through to Receiving. Typical WEFAX phasing lasts
|
||||||
/// phasing lasts ~30 s; if the phasing detector hasn't converged by then
|
/// ~30 s; if the phasing detector hasn't converged by then we give up on
|
||||||
/// we give up on alignment and let the correlation verifier decide whether
|
/// alignment and let the carrier-loss watchdog decide whether the content
|
||||||
/// the content that follows is a real image. At 120 LPM this is ~30 s.
|
/// that follows is real imagery. At 120 LPM this is ~30 s.
|
||||||
const PHASING_TIMEOUT_LINES: u32 = 60;
|
const PHASING_TIMEOUT_LINES: u32 = 60;
|
||||||
|
|
||||||
/// WEFAX decoder output event.
|
/// WEFAX decoder output event.
|
||||||
@@ -117,18 +102,10 @@ pub struct WefaxDecoder {
|
|||||||
/// `LINE_CORR_NOISE_LINES` the decoder auto-finalizes the in-progress
|
/// `LINE_CORR_NOISE_LINES` the decoder auto-finalizes the in-progress
|
||||||
/// image (carrier dropped / tx ended without an APT stop tone).
|
/// image (carrier dropped / tx ended without an APT stop tone).
|
||||||
low_corr_lines: u32,
|
low_corr_lines: u32,
|
||||||
/// `true` once the current reception has been confirmed to contain real
|
|
||||||
/// image content. Set immediately for phasing-driven entries (the APT
|
|
||||||
/// start tone + phasing pulses already proved the signal); set later
|
|
||||||
/// by the correlation verifier for variance-driven auto-starts.
|
|
||||||
verified: bool,
|
|
||||||
/// Rolling count of consecutive well-correlated lines, used to confirm
|
|
||||||
/// an unverified reception.
|
|
||||||
high_corr_streak: u32,
|
|
||||||
/// Number of luminance samples processed while in `State::Phasing`.
|
/// Number of luminance samples processed while in `State::Phasing`.
|
||||||
/// When this exceeds the equivalent of `PHASING_TIMEOUT_LINES` lines,
|
/// When this exceeds the equivalent of `PHASING_TIMEOUT_LINES` lines,
|
||||||
/// the decoder falls through to Receiving (unverified) so a noisy or
|
/// the decoder falls through to Receiving so a noisy or partial
|
||||||
/// partial phasing signal doesn't wedge the state machine.
|
/// phasing signal doesn't wedge the state machine.
|
||||||
phasing_samples: u64,
|
phasing_samples: u64,
|
||||||
/// Current rig dial frequency in Hz (for image filenames).
|
/// Current rig dial frequency in Hz (for image filenames).
|
||||||
freq_hz: u64,
|
freq_hz: u64,
|
||||||
@@ -157,8 +134,6 @@ impl WefaxDecoder {
|
|||||||
signal_detect_count: 0,
|
signal_detect_count: 0,
|
||||||
signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2),
|
signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2),
|
||||||
low_corr_lines: 0,
|
low_corr_lines: 0,
|
||||||
verified: false,
|
|
||||||
high_corr_streak: 0,
|
|
||||||
phasing_samples: 0,
|
phasing_samples: 0,
|
||||||
freq_hz: 0,
|
freq_hz: 0,
|
||||||
mode: String::new(),
|
mode: String::new(),
|
||||||
@@ -267,7 +242,7 @@ impl WefaxDecoder {
|
|||||||
.as_millis() as i64,
|
.as_millis() as i64,
|
||||||
);
|
);
|
||||||
self.signal_detect_buf.clear();
|
self.signal_detect_buf.clear();
|
||||||
events.push(self.transition_to_receiving(ioc, lpm, 0, false));
|
events.push(self.transition_to_receiving(ioc, lpm, 0));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,12 +272,12 @@ impl WefaxDecoder {
|
|||||||
|
|
||||||
if let Some(ref mut phasing) = self.phasing {
|
if let Some(ref mut phasing) = self.phasing {
|
||||||
if let Some(offset) = phasing.process(&luminance) {
|
if let Some(offset) = phasing.process(&luminance) {
|
||||||
events.push(self.transition_to_receiving(ioc, lpm, offset, true));
|
events.push(self.transition_to_receiving(ioc, lpm, offset));
|
||||||
} else {
|
} else {
|
||||||
// Phasing timeout: if alignment doesn't converge in
|
// Phasing timeout: if alignment doesn't converge in
|
||||||
// ~PHASING_TIMEOUT_LINES lines, fall through to
|
// ~PHASING_TIMEOUT_LINES lines, fall through to
|
||||||
// Receiving (unverified) and let the correlation
|
// Receiving and let the carrier-loss watchdog decide
|
||||||
// verifier decide.
|
// whether the content that follows is real imagery.
|
||||||
self.phasing_samples += luminance.len() as u64;
|
self.phasing_samples += luminance.len() as u64;
|
||||||
let spl = WefaxConfig::samples_per_line(lpm, INTERNAL_RATE) as u64;
|
let spl = WefaxConfig::samples_per_line(lpm, INTERNAL_RATE) as u64;
|
||||||
if self.phasing_samples >= spl * PHASING_TIMEOUT_LINES as u64 {
|
if self.phasing_samples >= spl * PHASING_TIMEOUT_LINES as u64 {
|
||||||
@@ -310,7 +285,7 @@ impl WefaxDecoder {
|
|||||||
ioc,
|
ioc,
|
||||||
lpm, "WEFAX: phasing timeout — falling through to receiving"
|
lpm, "WEFAX: phasing timeout — falling through to receiving"
|
||||||
);
|
);
|
||||||
events.push(self.transition_to_receiving(ioc, lpm, 0, false));
|
events.push(self.transition_to_receiving(ioc, lpm, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,66 +302,36 @@ impl WefaxDecoder {
|
|||||||
|
|
||||||
// Feed luminance to line slicer.
|
// Feed luminance to line slicer.
|
||||||
let mut carrier_lost = false;
|
let mut carrier_lost = false;
|
||||||
let mut verify_failed = false;
|
|
||||||
if let Some(ref mut slicer) = self.slicer {
|
if let Some(ref mut slicer) = self.slicer {
|
||||||
let new_lines = slicer.process(&luminance);
|
let new_lines = slicer.process(&luminance);
|
||||||
for line in new_lines {
|
for line in new_lines {
|
||||||
if let Some(ref mut image) = self.image {
|
if let Some(ref mut image) = self.image {
|
||||||
// Line-to-line Pearson correlation classifies the
|
// Carrier-loss watchdog: real imagery has highly
|
||||||
// new line as image-like, noise-like, or flat.
|
// correlated adjacent lines; pure noise does not.
|
||||||
// fldigi-style: real imagery has highly correlated
|
// After LINE_CORR_NOISE_LINES consecutive low-
|
||||||
// adjacent lines; pure noise does not.
|
// correlation lines we finalize (fldigi-style
|
||||||
|
// automatic stop).
|
||||||
if let Some(r) = image.correlation_with_last(&line) {
|
if let Some(r) = image.correlation_with_last(&line) {
|
||||||
if r >= LINE_CORR_IMAGE_THRESHOLD {
|
if r < LINE_CORR_NOISE_THRESHOLD {
|
||||||
self.high_corr_streak += 1;
|
|
||||||
self.low_corr_lines = 0;
|
|
||||||
if !self.verified
|
|
||||||
&& self.high_corr_streak >= VERIFY_HIGH_CORR_STREAK
|
|
||||||
{
|
|
||||||
self.verified = true;
|
|
||||||
debug!(
|
|
||||||
lines = image.line_count(),
|
|
||||||
"WEFAX: reception verified from line correlation"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if r < LINE_CORR_NOISE_THRESHOLD {
|
|
||||||
self.low_corr_lines += 1;
|
self.low_corr_lines += 1;
|
||||||
self.high_corr_streak = 0;
|
|
||||||
trace!(
|
trace!(
|
||||||
r = format!("{:.3}", r),
|
r = format!("{:.3}", r),
|
||||||
count = self.low_corr_lines,
|
count = self.low_corr_lines,
|
||||||
"WEFAX low line-correlation"
|
"WEFAX low line-correlation"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Middle zone — reset high streak, hold
|
self.low_corr_lines = 0;
|
||||||
// low-corr counter.
|
|
||||||
self.high_corr_streak = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Flat lines (correlation == None) don't advance
|
// Flat lines (correlation == None) don't advance
|
||||||
// either counter — solid bands in real imagery
|
// the counter but also don't reset it — an image
|
||||||
// shouldn't be scored as noise OR as evidence.
|
// with a solid band surrounded by noise still
|
||||||
|
// trips the watchdog once the noise resumes.
|
||||||
|
|
||||||
image.push_line(line);
|
image.push_line(line);
|
||||||
let count = image.line_count();
|
let count = image.line_count();
|
||||||
|
|
||||||
// Unverified timeout: if we got here from a
|
if self.low_corr_lines >= LINE_CORR_NOISE_LINES {
|
||||||
// variance auto-start and line correlation never
|
|
||||||
// took hold, the "signal" wasn't real WEFAX.
|
|
||||||
// Abandon without saving.
|
|
||||||
if !self.verified && count >= VERIFY_TIMEOUT_LINES {
|
|
||||||
debug!(
|
|
||||||
lines = count,
|
|
||||||
"WEFAX: failed to verify image content — abandoning"
|
|
||||||
);
|
|
||||||
verify_failed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carrier-loss watchdog — only active once the
|
|
||||||
// reception has been verified (otherwise it
|
|
||||||
// double-counts with the verify timeout).
|
|
||||||
if self.verified && self.low_corr_lines >= LINE_CORR_NOISE_LINES {
|
|
||||||
debug!(
|
debug!(
|
||||||
lines = count,
|
lines = count,
|
||||||
"WEFAX: line correlation lost — auto-finalizing image"
|
"WEFAX: line correlation lost — auto-finalizing image"
|
||||||
@@ -418,15 +363,6 @@ impl WefaxDecoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if verify_failed {
|
|
||||||
// Drop buffered content without saving — this was a
|
|
||||||
// false auto-start (tone, noise burst, etc.).
|
|
||||||
self.image = None;
|
|
||||||
self.reception_start_ms = None;
|
|
||||||
self.transition_to_idle();
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
if carrier_lost {
|
if carrier_lost {
|
||||||
events.extend(self.finalize_image(ioc, lpm));
|
events.extend(self.finalize_image(ioc, lpm));
|
||||||
self.transition_to_idle();
|
self.transition_to_idle();
|
||||||
@@ -465,8 +401,6 @@ impl WefaxDecoder {
|
|||||||
self.signal_detect_count = 0;
|
self.signal_detect_count = 0;
|
||||||
self.signal_detect_buf.clear();
|
self.signal_detect_buf.clear();
|
||||||
self.low_corr_lines = 0;
|
self.low_corr_lines = 0;
|
||||||
self.verified = false;
|
|
||||||
self.high_corr_streak = 0;
|
|
||||||
self.phasing_samples = 0;
|
self.phasing_samples = 0;
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
@@ -522,30 +456,13 @@ impl WefaxDecoder {
|
|||||||
self.state_event("Phasing", ioc, lpm)
|
self.state_event("Phasing", ioc, lpm)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transition_to_receiving(
|
fn transition_to_receiving(&mut self, ioc: u16, lpm: u16, phase_offset: usize) -> WefaxEvent {
|
||||||
&mut self,
|
debug!(ioc, lpm, phase_offset, "WEFAX: entering receiving");
|
||||||
ioc: u16,
|
|
||||||
lpm: u16,
|
|
||||||
phase_offset: usize,
|
|
||||||
verified: bool,
|
|
||||||
) -> WefaxEvent {
|
|
||||||
debug!(
|
|
||||||
ioc,
|
|
||||||
lpm, phase_offset, verified, "WEFAX: entering receiving"
|
|
||||||
);
|
|
||||||
let ppl = WefaxConfig::pixels_per_line(ioc) as usize;
|
let ppl = WefaxConfig::pixels_per_line(ioc) as usize;
|
||||||
self.slicer = Some(LineSlicer::with_slant(
|
self.slicer = Some(LineSlicer::new(lpm, ioc, INTERNAL_RATE, phase_offset));
|
||||||
lpm,
|
|
||||||
ioc,
|
|
||||||
INTERNAL_RATE,
|
|
||||||
phase_offset,
|
|
||||||
self.config.slant_correction,
|
|
||||||
));
|
|
||||||
self.image = Some(ImageAssembler::new(ppl));
|
self.image = Some(ImageAssembler::new(ppl));
|
||||||
self.tone_detector.reset();
|
self.tone_detector.reset();
|
||||||
self.low_corr_lines = 0;
|
self.low_corr_lines = 0;
|
||||||
self.verified = verified;
|
|
||||||
self.high_corr_streak = 0;
|
|
||||||
self.state = State::Receiving { ioc, lpm };
|
self.state = State::Receiving { ioc, lpm };
|
||||||
self.state_event("Receiving", ioc, lpm)
|
self.state_event("Receiving", ioc, lpm)
|
||||||
}
|
}
|
||||||
@@ -559,8 +476,6 @@ impl WefaxDecoder {
|
|||||||
self.signal_detect_count = 0;
|
self.signal_detect_count = 0;
|
||||||
self.signal_detect_buf.clear();
|
self.signal_detect_buf.clear();
|
||||||
self.low_corr_lines = 0;
|
self.low_corr_lines = 0;
|
||||||
self.verified = false;
|
|
||||||
self.high_corr_streak = 0;
|
|
||||||
self.phasing_samples = 0;
|
self.phasing_samples = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,12 +489,23 @@ impl WefaxDecoder {
|
|||||||
|
|
||||||
let ppl = WefaxConfig::pixels_per_line(ioc);
|
let ppl = WefaxConfig::pixels_per_line(ioc);
|
||||||
let mut path_str = None;
|
let mut path_str = None;
|
||||||
|
let mut png_data = None;
|
||||||
|
|
||||||
// Save PNG if output directory is configured.
|
// Save PNG if output directory is configured.
|
||||||
if let Some(ref dir) = self.config.output_dir {
|
if let Some(ref dir) = self.config.output_dir {
|
||||||
let output_path = PathBuf::from(dir);
|
let output_path = PathBuf::from(dir);
|
||||||
match image.save_png(&output_path, self.freq_hz, &self.mode) {
|
match image.save_png(&output_path, self.freq_hz, &self.mode) {
|
||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
|
// Read back the PNG bytes for remote client transfer.
|
||||||
|
match std::fs::read(&p) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
png_data =
|
||||||
|
Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("WEFAX: failed to read PNG for transfer: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
path_str = Some(p.to_string_lossy().into_owned());
|
path_str = Some(p.to_string_lossy().into_owned());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -597,6 +523,7 @@ impl WefaxDecoder {
|
|||||||
ioc,
|
ioc,
|
||||||
pixels_per_line: ppl,
|
pixels_per_line: ppl,
|
||||||
path: path_str,
|
path: path_str,
|
||||||
|
png_data,
|
||||||
complete: true,
|
complete: true,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,21 +7,9 @@
|
|||||||
//! Once the phasing detector has established a line-start phase offset,
|
//! Once the phasing detector has established a line-start phase offset,
|
||||||
//! the line slicer accumulates demodulated luminance samples and extracts
|
//! the line slicer accumulates demodulated luminance samples and extracts
|
||||||
//! complete image lines at the configured LPM rate.
|
//! complete image lines at the configured LPM rate.
|
||||||
//!
|
|
||||||
//! When `slant_correction` is enabled, the slicer tracks line-to-line
|
|
||||||
//! drift via cross-correlation with the previous line and nudges the
|
|
||||||
//! extraction cursor by ±`MAX_DRIFT_SAMPLES` per line. This compensates
|
|
||||||
//! for the small mismatch between the transmitter's and receiver's
|
|
||||||
//! sample clocks that would otherwise skew the assembled image.
|
|
||||||
|
|
||||||
use crate::config::WefaxConfig;
|
use crate::config::WefaxConfig;
|
||||||
|
|
||||||
/// Maximum per-line drift (in samples at the internal rate) searched for
|
|
||||||
/// when slant correction is enabled. At 120 LPM / 11025 Hz there are
|
|
||||||
/// ~5512 samples per line, so ±6 samples is ~0.1% drift per line — more
|
|
||||||
/// than enough for any real-world sample-clock mismatch.
|
|
||||||
const MAX_DRIFT_SAMPLES: usize = 6;
|
|
||||||
|
|
||||||
/// Line slicer for WEFAX image assembly.
|
/// Line slicer for WEFAX image assembly.
|
||||||
pub struct LineSlicer {
|
pub struct LineSlicer {
|
||||||
/// Samples per line at the internal sample rate.
|
/// Samples per line at the internal sample rate.
|
||||||
@@ -30,34 +18,14 @@ pub struct LineSlicer {
|
|||||||
pixels_per_line: usize,
|
pixels_per_line: usize,
|
||||||
/// Phase offset in samples from the phasing detector.
|
/// Phase offset in samples from the phasing detector.
|
||||||
phase_offset: usize,
|
phase_offset: usize,
|
||||||
/// Accumulated luminance samples. While `slant_correction` is on,
|
/// Accumulated luminance samples.
|
||||||
/// the buffer anchor is the *start of the previous line* (so the
|
|
||||||
/// first `samples_per_line` samples are the reference for drift
|
|
||||||
/// tracking). Without slant correction the anchor is simply the
|
|
||||||
/// start of the next line to extract.
|
|
||||||
buffer: Vec<f32>,
|
buffer: Vec<f32>,
|
||||||
/// Whether we have aligned to the phase offset yet.
|
/// Whether we have aligned to the phase offset yet.
|
||||||
aligned: bool,
|
aligned: bool,
|
||||||
/// Whether a reference (previous) line is held at the buffer anchor.
|
|
||||||
has_reference: bool,
|
|
||||||
/// Enable line-to-line drift tracking.
|
|
||||||
slant_correction: bool,
|
|
||||||
/// Cumulative drift applied so far (samples). Diagnostic.
|
|
||||||
pub(crate) total_drift: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LineSlicer {
|
impl LineSlicer {
|
||||||
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self {
|
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self {
|
||||||
Self::with_slant(lpm, ioc, sample_rate, phase_offset, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_slant(
|
|
||||||
lpm: u16,
|
|
||||||
ioc: u16,
|
|
||||||
sample_rate: u32,
|
|
||||||
phase_offset: usize,
|
|
||||||
slant_correction: bool,
|
|
||||||
) -> Self {
|
|
||||||
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
|
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
|
||||||
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
|
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
|
||||||
|
|
||||||
@@ -65,11 +33,8 @@ impl LineSlicer {
|
|||||||
samples_per_line,
|
samples_per_line,
|
||||||
pixels_per_line,
|
pixels_per_line,
|
||||||
phase_offset,
|
phase_offset,
|
||||||
buffer: Vec::with_capacity(samples_per_line * 3),
|
buffer: Vec::with_capacity(samples_per_line * 2),
|
||||||
aligned: false,
|
aligned: false,
|
||||||
has_reference: false,
|
|
||||||
slant_correction,
|
|
||||||
total_drift: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,56 +55,16 @@ impl LineSlicer {
|
|||||||
self.aligned = true;
|
self.aligned = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let spl = self.samples_per_line;
|
// Extract complete lines (single drain at the end to avoid O(n²)).
|
||||||
|
let mut offset = 0;
|
||||||
if !self.slant_correction {
|
while offset + self.samples_per_line <= self.buffer.len() {
|
||||||
// Simple fixed-period extraction.
|
let line_samples = &self.buffer[offset..offset + self.samples_per_line];
|
||||||
let mut offset = 0;
|
let pixels = self.resample_line(line_samples);
|
||||||
while offset + spl <= self.buffer.len() {
|
|
||||||
let line_samples = &self.buffer[offset..offset + spl];
|
|
||||||
let pixels = self.resample_line(line_samples);
|
|
||||||
lines.push(pixels);
|
|
||||||
offset += spl;
|
|
||||||
}
|
|
||||||
if offset > 0 {
|
|
||||||
self.buffer.drain(..offset);
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slant-corrected extraction.
|
|
||||||
let max_shift = MAX_DRIFT_SAMPLES;
|
|
||||||
|
|
||||||
// Bootstrap: the very first line has no previous reference.
|
|
||||||
// Extract it naively and keep it in the buffer as the reference.
|
|
||||||
if !self.has_reference {
|
|
||||||
if self.buffer.len() < spl {
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
let first = self.buffer[0..spl].to_vec();
|
|
||||||
let pixels = self.resample_line(&first);
|
|
||||||
lines.push(pixels);
|
lines.push(pixels);
|
||||||
self.has_reference = true;
|
offset += self.samples_per_line;
|
||||||
// Do NOT drain: the first `spl` samples remain as the
|
|
||||||
// reference for the next line's drift search.
|
|
||||||
}
|
}
|
||||||
|
if offset > 0 {
|
||||||
// Subsequent lines: for each iteration, buffer[0..spl] is the
|
self.buffer.drain(..offset);
|
||||||
// reference line, and we search for the best starting position
|
|
||||||
// of the NEXT line in the range [spl - max_shift, spl + max_shift].
|
|
||||||
while self.buffer.len() >= 2 * spl + max_shift {
|
|
||||||
let prev = &self.buffer[0..spl];
|
|
||||||
let (best_d, _best_r) = search_best_shift(prev, &self.buffer, spl, max_shift);
|
|
||||||
|
|
||||||
let start = (spl as i32 + best_d) as usize;
|
|
||||||
let next_line = self.buffer[start..start + spl].to_vec();
|
|
||||||
let pixels = self.resample_line(&next_line);
|
|
||||||
lines.push(pixels);
|
|
||||||
|
|
||||||
// Advance the anchor to the start of the line we just
|
|
||||||
// emitted — it becomes the reference for the next iteration.
|
|
||||||
self.buffer.drain(..start);
|
|
||||||
self.total_drift += best_d as i64;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lines
|
lines
|
||||||
@@ -149,16 +74,9 @@ impl LineSlicer {
|
|||||||
self.pixels_per_line
|
self.pixels_per_line
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Samples per line at the internal rate (for diagnostics).
|
|
||||||
pub fn samples_per_line(&self) -> usize {
|
|
||||||
self.samples_per_line
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.buffer.clear();
|
self.buffer.clear();
|
||||||
self.aligned = false;
|
self.aligned = false;
|
||||||
self.has_reference = false;
|
|
||||||
self.total_drift = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resample a line's worth of luminance samples to the target pixel count
|
/// Resample a line's worth of luminance samples to the target pixel count
|
||||||
@@ -189,82 +107,6 @@ impl LineSlicer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search for the drift `d ∈ [-max_shift, +max_shift]` that maximises
|
|
||||||
/// the Pearson correlation between `reference` and
|
|
||||||
/// `buffer[spl+d .. spl+d+spl]`.
|
|
||||||
///
|
|
||||||
/// Returns `(best_d, best_r)`. A correlation-peak deadband prefers
|
|
||||||
/// `d = 0` when the peak is only marginally better than at zero, which
|
|
||||||
/// keeps tracking stable on quiet lines.
|
|
||||||
fn search_best_shift(
|
|
||||||
reference: &[f32],
|
|
||||||
buffer: &[f32],
|
|
||||||
spl: usize,
|
|
||||||
max_shift: usize,
|
|
||||||
) -> (i32, f32) {
|
|
||||||
debug_assert!(buffer.len() >= 2 * spl + max_shift);
|
|
||||||
debug_assert_eq!(reference.len(), spl);
|
|
||||||
|
|
||||||
// Pre-compute reference mean + variance.
|
|
||||||
let n = spl as f32;
|
|
||||||
let mean_r = reference.iter().sum::<f32>() / n;
|
|
||||||
let mut var_r = 0.0f32;
|
|
||||||
for &v in reference {
|
|
||||||
let d = v - mean_r;
|
|
||||||
var_r += d * d;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard against a flat reference line — drift tracking is useless.
|
|
||||||
const MIN_VAR: f32 = 32.0;
|
|
||||||
if var_r < MIN_VAR {
|
|
||||||
return (0, 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ms = max_shift as i32;
|
|
||||||
let mut best_d = 0i32;
|
|
||||||
let mut best_r = f32::NEG_INFINITY;
|
|
||||||
let mut r_at_zero = 0.0f32;
|
|
||||||
|
|
||||||
for d in -ms..=ms {
|
|
||||||
let start = (spl as i32 + d) as usize;
|
|
||||||
let candidate = &buffer[start..start + spl];
|
|
||||||
|
|
||||||
let mean_c = candidate.iter().sum::<f32>() / n;
|
|
||||||
let mut var_c = 0.0f32;
|
|
||||||
let mut cov = 0.0f32;
|
|
||||||
for (i, &v) in candidate.iter().enumerate() {
|
|
||||||
let dr = reference[i] - mean_r;
|
|
||||||
let dc = v - mean_c;
|
|
||||||
cov += dr * dc;
|
|
||||||
var_c += dc * dc;
|
|
||||||
}
|
|
||||||
|
|
||||||
let r = if var_c < MIN_VAR {
|
|
||||||
// Skip flat candidate slices.
|
|
||||||
f32::NEG_INFINITY
|
|
||||||
} else {
|
|
||||||
cov / (var_r.sqrt() * var_c.sqrt())
|
|
||||||
};
|
|
||||||
|
|
||||||
if d == 0 {
|
|
||||||
r_at_zero = r;
|
|
||||||
}
|
|
||||||
if r > best_r {
|
|
||||||
best_r = r;
|
|
||||||
best_d = d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deadband: if the peak is only marginally better than `d = 0`,
|
|
||||||
// stick with zero. This avoids per-line jitter when drift is small.
|
|
||||||
const DEADBAND: f32 = 0.01;
|
|
||||||
if r_at_zero.is_finite() && best_r - r_at_zero < DEADBAND {
|
|
||||||
return (0, r_at_zero);
|
|
||||||
}
|
|
||||||
|
|
||||||
(best_d, best_r)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -277,8 +119,7 @@ mod tests {
|
|||||||
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
||||||
let ppl = WefaxConfig::pixels_per_line(ioc) as usize;
|
let ppl = WefaxConfig::pixels_per_line(ioc) as usize;
|
||||||
|
|
||||||
// Slant correction off for deterministic line count.
|
let mut slicer = LineSlicer::new(lpm, ioc, sr, 0);
|
||||||
let mut slicer = LineSlicer::with_slant(lpm, ioc, sr, 0, false);
|
|
||||||
// Feed exactly 3 lines worth of white.
|
// Feed exactly 3 lines worth of white.
|
||||||
let samples = vec![1.0f32; spl * 3];
|
let samples = vec![1.0f32; spl * 3];
|
||||||
let lines = slicer.process(&samples);
|
let lines = slicer.process(&samples);
|
||||||
@@ -295,7 +136,7 @@ mod tests {
|
|||||||
let sr = 11025;
|
let sr = 11025;
|
||||||
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
||||||
|
|
||||||
let mut slicer = LineSlicer::with_slant(lpm, ioc, sr, 0, false);
|
let mut slicer = LineSlicer::new(lpm, ioc, sr, 0);
|
||||||
// Feed a linear ramp from 0.0 to 1.0.
|
// Feed a linear ramp from 0.0 to 1.0.
|
||||||
let samples: Vec<f32> = (0..spl).map(|i| i as f32 / spl as f32).collect();
|
let samples: Vec<f32> = (0..spl).map(|i| i as f32 / spl as f32).collect();
|
||||||
let lines = slicer.process(&samples);
|
let lines = slicer.process(&samples);
|
||||||
@@ -304,92 +145,4 @@ mod tests {
|
|||||||
assert!(lines[0][0] < 5);
|
assert!(lines[0][0] < 5);
|
||||||
assert!(lines[0].last().copied().unwrap_or(0) > 250);
|
assert!(lines[0].last().copied().unwrap_or(0) > 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synthesise a noisy-ish gradient line that repeats with a small
|
|
||||||
/// per-line offset, simulating a sample-clock mismatch. The slant
|
|
||||||
/// tracker should follow the drift.
|
|
||||||
#[test]
|
|
||||||
fn slant_tracker_follows_drift() {
|
|
||||||
let lpm = 120;
|
|
||||||
let ioc = 576;
|
|
||||||
let sr = 11025;
|
|
||||||
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
|
||||||
|
|
||||||
// Build a signal where each real line is `spl + 3` samples long
|
|
||||||
// (i.e. transmitter clock is slower than expected → positive drift
|
|
||||||
// of +3 samples per line). The content needs high-frequency
|
|
||||||
// structure for a few-sample shift to be detectable against the
|
|
||||||
// deadband.
|
|
||||||
let true_line_len = spl + 3;
|
|
||||||
let mut signal: Vec<f32> = Vec::new();
|
|
||||||
let base: Vec<f32> = (0..true_line_len)
|
|
||||||
.map(|i| {
|
|
||||||
// Pseudo-random-but-repeatable content with a narrow
|
|
||||||
// bright stripe — sharp features make sub-line shifts
|
|
||||||
// easy to localise.
|
|
||||||
let x = ((i as u32).wrapping_mul(2654435761)) >> 16;
|
|
||||||
let noise = (x & 0xff) as f32 / 255.0;
|
|
||||||
let stripe = if i == true_line_len / 3 { 1.0 } else { 0.0 };
|
|
||||||
0.3 + 0.4 * noise + stripe
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
// 20 lines, each identical.
|
|
||||||
for _ in 0..20 {
|
|
||||||
signal.extend_from_slice(&base);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut slicer = LineSlicer::with_slant(lpm, ioc, sr, 0, true);
|
|
||||||
let lines = slicer.process(&signal);
|
|
||||||
|
|
||||||
// Expect ~ (20*true_line_len - spl) / (spl+drift) lines with
|
|
||||||
// drift absorbing the extra 2 samples per line.
|
|
||||||
assert!(
|
|
||||||
lines.len() >= 15,
|
|
||||||
"slant-corrected slicer produced only {} lines",
|
|
||||||
lines.len()
|
|
||||||
);
|
|
||||||
// Should have tracked positive drift.
|
|
||||||
assert!(
|
|
||||||
slicer.total_drift > 0,
|
|
||||||
"expected positive drift, got {}",
|
|
||||||
slicer.total_drift
|
|
||||||
);
|
|
||||||
// Roughly +3 per line (after the first bootstrap line); allow wide tolerance.
|
|
||||||
let per_line = slicer.total_drift as f32 / (lines.len() - 1) as f32;
|
|
||||||
assert!(
|
|
||||||
per_line > 1.5 && per_line < 4.0,
|
|
||||||
"per-line drift {:.2} out of range (total {}, lines {})",
|
|
||||||
per_line,
|
|
||||||
slicer.total_drift,
|
|
||||||
lines.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn slant_tracker_deadband_on_no_drift() {
|
|
||||||
let lpm = 120;
|
|
||||||
let ioc = 576;
|
|
||||||
let sr = 11025;
|
|
||||||
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
|
||||||
|
|
||||||
// Perfectly aligned lines → drift should stay at zero.
|
|
||||||
let line: Vec<f32> = (0..spl)
|
|
||||||
.map(|i| {
|
|
||||||
let t = i as f32 / spl as f32;
|
|
||||||
0.5 + 0.4 * (t * 9.0 * std::f32::consts::PI).sin()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let mut signal = Vec::new();
|
|
||||||
for _ in 0..10 {
|
|
||||||
signal.extend_from_slice(&line);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut slicer = LineSlicer::with_slant(lpm, ioc, sr, 0, true);
|
|
||||||
let _ = slicer.process(&signal);
|
|
||||||
// Deadband should keep drift at 0.
|
|
||||||
assert_eq!(
|
|
||||||
slicer.total_drift, 0,
|
|
||||||
"no drift expected for identical lines"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ tokio = { workspace = true, features = ["full"] }
|
|||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
base64 = "0.22"
|
||||||
actix-web = "4.4"
|
actix-web = "4.4"
|
||||||
actix-ws = "0.3"
|
actix-ws = "0.3"
|
||||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
|
|||||||
@@ -4485,7 +4485,16 @@ function _initMapWhenReady() {
|
|||||||
if (loadingEl) loadingEl.classList.add("is-hidden");
|
if (loadingEl) loadingEl.classList.add("is-hidden");
|
||||||
window.trx.map.initAprsMap();
|
window.trx.map.initAprsMap();
|
||||||
window.trx.map.sizeAprsMapToViewport();
|
window.trx.map.sizeAprsMapToViewport();
|
||||||
if (window.trx.map.aprsMap) setTimeout(() => window.trx.map.aprsMap.invalidateSize(), 50);
|
// The map panel was just made visible (display:none → ""); the browser
|
||||||
|
// may not have laid it out yet, so getBoundingClientRect() can return
|
||||||
|
// stale/zero dimensions. Double-rAF ensures a full layout pass has
|
||||||
|
// completed before we re-measure and tell Leaflet about its real size.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.trx.map.sizeAprsMapToViewport();
|
||||||
|
if (window.trx.map.aprsMap) window.trx.map.aprsMap.invalidateSize();
|
||||||
|
});
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Not ready yet — show overlay and poll until both are available.
|
// Not ready yet — show overlay and poll until both are available.
|
||||||
@@ -7006,9 +7015,18 @@ function stopSpectrumStreaming() {
|
|||||||
|
|
||||||
// ── /meter (fast signal-strength) streaming ─────────────────────────────────
|
// ── /meter (fast signal-strength) streaming ─────────────────────────────────
|
||||||
// Dedicated SSE channel pushed at ~30 Hz by trx-server; bypasses /events so
|
// Dedicated SSE channel pushed at ~30 Hz by trx-server; bypasses /events so
|
||||||
// meter frames are never gated by full-RigState diffing. Synchronous DOM
|
// meter frames are never gated by full-RigState diffing.
|
||||||
// write per frame — no rAF coalescing, per user requirement that it "feel
|
//
|
||||||
// instant" on the frontend.
|
// Client-side asymmetric EMA smoothing (GQRX-style ballistics):
|
||||||
|
// attack τ ≈ 400 ms — rises in ~12 frames at 30 Hz
|
||||||
|
// decay τ ≈ 1.0 s — falls in ~30 frames, readable
|
||||||
|
// DOM updates are coalesced via requestAnimationFrame so the bar
|
||||||
|
// animates at display refresh rate, not SSE rate.
|
||||||
|
const METER_ATTACK_ALPHA = 0.08; // per-frame at ~30 Hz ≈ 400 ms τ
|
||||||
|
const METER_DECAY_ALPHA = 0.03; // per-frame at ~30 Hz ≈ 1.0 s τ
|
||||||
|
let meterSmoothedDbm = null;
|
||||||
|
let meterRafPending = false;
|
||||||
|
|
||||||
function scheduleMeterReconnect() {
|
function scheduleMeterReconnect() {
|
||||||
if (meterReconnectTimer !== null) return;
|
if (meterReconnectTimer !== null) return;
|
||||||
meterReconnectTimer = setTimeout(() => {
|
meterReconnectTimer = setTimeout(() => {
|
||||||
@@ -7019,6 +7037,24 @@ function scheduleMeterReconnect() {
|
|||||||
|
|
||||||
function applyMeterSample(dbm) {
|
function applyMeterSample(dbm) {
|
||||||
if (typeof dbm !== "number" || !Number.isFinite(dbm)) return;
|
if (typeof dbm !== "number" || !Number.isFinite(dbm)) return;
|
||||||
|
// Asymmetric EMA: fast attack, slow decay.
|
||||||
|
if (meterSmoothedDbm === null) {
|
||||||
|
meterSmoothedDbm = dbm;
|
||||||
|
} else {
|
||||||
|
const alpha = dbm > meterSmoothedDbm ? METER_ATTACK_ALPHA : METER_DECAY_ALPHA;
|
||||||
|
meterSmoothedDbm += alpha * (dbm - meterSmoothedDbm);
|
||||||
|
}
|
||||||
|
// Coalesce DOM writes to display refresh rate.
|
||||||
|
if (!meterRafPending) {
|
||||||
|
meterRafPending = true;
|
||||||
|
requestAnimationFrame(flushMeterDom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushMeterDom() {
|
||||||
|
meterRafPending = false;
|
||||||
|
const dbm = meterSmoothedDbm;
|
||||||
|
if (dbm === null) return;
|
||||||
prevRenderData.sigDbm = dbm;
|
prevRenderData.sigDbm = dbm;
|
||||||
const sUnits = dbmToSUnits(dbm);
|
const sUnits = dbmToSUnits(dbm);
|
||||||
sigLastSUnits = sUnits;
|
sigLastSUnits = sUnits;
|
||||||
@@ -7059,6 +7095,7 @@ function stopMeterStreaming() {
|
|||||||
clearTimeout(meterReconnectTimer);
|
clearTimeout(meterReconnectTimer);
|
||||||
meterReconnectTimer = null;
|
meterReconnectTimer = null;
|
||||||
}
|
}
|
||||||
|
meterSmoothedDbm = null; // reset so next rig starts fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rendering ────────────────────────────────────────────────────────────────
|
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1848,10 +1848,13 @@
|
|||||||
initAprsMap();
|
initAprsMap();
|
||||||
sizeAprsMapToViewport();
|
sizeAprsMapToViewport();
|
||||||
if (aprsMap) {
|
if (aprsMap) {
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
aprsMap.invalidateSize();
|
requestAnimationFrame(() => {
|
||||||
aprsMap.setView([lat, lon], 13);
|
sizeAprsMapToViewport();
|
||||||
}, 50);
|
aprsMap.invalidateSize();
|
||||||
|
aprsMap.setView([lat, lon], 13);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1896,6 +1899,7 @@
|
|||||||
const center = locatorMarkerCenter(marker);
|
const center = locatorMarkerCenter(marker);
|
||||||
const focusMarker = () => {
|
const focusMarker = () => {
|
||||||
if (!aprsMap || !marker) return;
|
if (!aprsMap || !marker) return;
|
||||||
|
sizeAprsMapToViewport();
|
||||||
aprsMap.invalidateSize();
|
aprsMap.invalidateSize();
|
||||||
if (center) {
|
if (center) {
|
||||||
const targetZoom = Math.max(aprsMap.getZoom() || 0, 7);
|
const targetZoom = Math.max(aprsMap.getZoom() || 0, 7);
|
||||||
@@ -1910,7 +1914,9 @@
|
|||||||
if (typeof marker.openPopup === "function") marker.openPopup();
|
if (typeof marker.openPopup === "function") marker.openPopup();
|
||||||
};
|
};
|
||||||
focusMarker();
|
focusMarker();
|
||||||
setTimeout(focusMarker, 60);
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(focusMarker);
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1356,7 +1356,7 @@ small { color: var(--text-muted); }
|
|||||||
.band-tag { display: inline-block; padding: 2px 6px; border-radius: 6px; background: var(--btn-bg); color: var(--text); font-size: 0.82rem; border: 1px solid var(--border-light); margin-left: 6px; }
|
.band-tag { display: inline-block; padding: 2px 6px; border-radius: 6px; background: var(--btn-bg); color: var(--text); font-size: 0.82rem; border: 1px solid var(--border-light); margin-left: 6px; }
|
||||||
.signal { display: flex; gap: 0.6rem; align-items: center; }
|
.signal { display: flex; gap: 0.6rem; align-items: center; }
|
||||||
.signal-bar { flex: 1 1 auto; height: 12px; border-radius: 999px; background: var(--btn-bg); border: 1px solid var(--border-light); overflow: hidden; }
|
.signal-bar { flex: 1 1 auto; height: 12px; border-radius: 999px; background: var(--btn-bg); border: 1px solid var(--border-light); overflow: hidden; }
|
||||||
.signal-bar-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent-green), var(--accent-yellow), var(--accent-red)); transition: width 150ms ease; }
|
.signal-bar-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent-green), var(--accent-yellow), var(--accent-red)); transition: width 300ms ease-out; }
|
||||||
.signal-value { font-size: 0.95rem; color: var(--text-heading); min-width: 48px; text-align: right; }
|
.signal-value { font-size: 0.95rem; color: var(--text-heading); min-width: 48px; text-align: right; }
|
||||||
.meter { display: flex; gap: 0.6rem; align-items: center; }
|
.meter { display: flex; gap: 0.6rem; align-items: center; }
|
||||||
.meter-bar { flex: 1 1 auto; height: 12px; border-radius: 999px; background: var(--btn-bg); border: 1px solid var(--border-light); overflow: hidden; }
|
.meter-bar { flex: 1 1 auto; height: 12px; border-radius: 999px; background: var(--btn-bg); border: 1px solid var(--border-light); overflow: hidden; }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use actix_web::{get, web, Error, HttpRequest, HttpResponse};
|
use actix_web::{get, web, Error, HttpRequest, HttpResponse};
|
||||||
use actix_ws::Message;
|
use actix_ws::Message;
|
||||||
|
use base64::Engine as _;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
@@ -297,7 +298,30 @@ fn record_wspr(context: &FrontendRuntimeContext, msg: WsprMessage) {
|
|||||||
prune_wspr_history(context, &mut history);
|
prune_wspr_history(context, &mut history);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn record_wefax(context: &FrontendRuntimeContext, msg: WefaxMessage) {
|
fn record_wefax(context: &FrontendRuntimeContext, mut msg: WefaxMessage) {
|
||||||
|
// If the server sent PNG data, save it to the local cache so the
|
||||||
|
// `/images/` endpoint can serve it.
|
||||||
|
if let Some(ref data) = msg.png_data {
|
||||||
|
if let Some(ref path) = msg.path {
|
||||||
|
if let Some(filename) = std::path::Path::new(path).file_name() {
|
||||||
|
let dir = dirs::cache_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||||
|
.join("trx-rs")
|
||||||
|
.join("wefax");
|
||||||
|
if std::fs::create_dir_all(&dir).is_ok() {
|
||||||
|
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
|
||||||
|
let local_path = dir.join(filename);
|
||||||
|
if let Err(e) = std::fs::write(&local_path, &bytes) {
|
||||||
|
tracing::warn!("WEFAX: failed to save local image: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strip bulk data before storing in memory.
|
||||||
|
msg.png_data = None;
|
||||||
|
|
||||||
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
|
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
|
||||||
let mut history = context
|
let mut history = context
|
||||||
.decode_history
|
.decode_history
|
||||||
|
|||||||
@@ -291,6 +291,10 @@ pub struct WefaxMessage {
|
|||||||
/// Filesystem path to saved PNG (set on completion).
|
/// Filesystem path to saved PNG (set on completion).
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
|
/// Base64-encoded PNG data for transfer to remote clients.
|
||||||
|
/// Populated by the server when sending, stripped before storing in history.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub png_data: Option<String>,
|
||||||
/// True when image is complete (stop tone received).
|
/// True when image is complete (stop tone received).
|
||||||
pub complete: bool,
|
pub complete: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ ft2 = ["trx-ftx/ft2", "trx-protocol/ft2"]
|
|||||||
soapysdr = ["trx-backend/soapysdr"]
|
soapysdr = ["trx-backend/soapysdr"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
flate2 = { workspace = true }
|
flate2 = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
tokio-serial = { workspace = true }
|
tokio-serial = { workspace = true }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use flate2::write::GzEncoder;
|
use flate2::write::GzEncoder;
|
||||||
use flate2::Compression;
|
use flate2::Compression;
|
||||||
@@ -709,6 +710,8 @@ impl DecoderHistories {
|
|||||||
if msg.ts_ms.is_none() {
|
if msg.ts_ms.is_none() {
|
||||||
msg.ts_ms = Some(current_timestamp_ms());
|
msg.ts_ms = Some(current_timestamp_ms());
|
||||||
}
|
}
|
||||||
|
// Strip bulk PNG data before storing in memory/persistence.
|
||||||
|
msg.png_data = None;
|
||||||
let mut h = lock_or_recover(&self.wefax, "wefax_history");
|
let mut h = lock_or_recover(&self.wefax, "wefax_history");
|
||||||
let before = h.len();
|
let before = h.len();
|
||||||
h.push_back((Instant::now(), msg));
|
h.push_back((Instant::now(), msg));
|
||||||
@@ -722,7 +725,21 @@ impl DecoderHistories {
|
|||||||
let before = h.len();
|
let before = h.len();
|
||||||
Self::prune_wefax(&mut h);
|
Self::prune_wefax(&mut h);
|
||||||
self.adjust_total_count(before, h.len());
|
self.adjust_total_count(before, h.len());
|
||||||
h.iter().map(|(_, msg)| msg.clone()).collect()
|
h.iter()
|
||||||
|
.map(|(_, msg)| {
|
||||||
|
let mut m = msg.clone();
|
||||||
|
// Re-read PNG from disk so remote clients can save a local copy.
|
||||||
|
if m.png_data.is_none() {
|
||||||
|
if let Some(ref path) = m.path {
|
||||||
|
if let Ok(bytes) = std::fs::read(path) {
|
||||||
|
m.png_data =
|
||||||
|
Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_wefax_history(&self) {
|
pub fn clear_wefax_history(&self) {
|
||||||
|
|||||||
@@ -308,20 +308,22 @@ pub struct ChannelDsp {
|
|||||||
impl ChannelDsp {
|
impl ChannelDsp {
|
||||||
/// Compute asymmetric IIR coefficients for S-meter envelope tracking.
|
/// Compute asymmetric IIR coefficients for S-meter envelope tracking.
|
||||||
///
|
///
|
||||||
/// Attack: ~50 ms time constant (responsive but visually stable).
|
/// Attack: ~400 ms — rises over ~12 frames at 30 Hz.
|
||||||
/// Decay: ~300 ms time constant (slow fall for stable reading).
|
/// Decay: ~1.0 s — falls over ~30 frames, readable.
|
||||||
///
|
///
|
||||||
/// Note: these alphas are applied once per decimated *block*, not per
|
/// Modelled after GQRX meter ballistics. Deliberately slower than
|
||||||
/// sample, with block-rate correction (`1 − (1−α)^N`). The 50 ms
|
/// the IARU analog-meter spec because a digital bar at 30 fps is
|
||||||
/// attack gives ~3-frame settling at 30 Hz meter refresh — fast
|
/// visually noisier than a physical needle with mechanical inertia.
|
||||||
/// enough to follow signal changes, smooth enough to avoid jitter.
|
///
|
||||||
|
/// Note: alphas are applied once per decimated *block*, not per
|
||||||
|
/// sample, with block-rate correction (`1 − (1−α)^N`).
|
||||||
fn smeter_alphas(channel_sample_rate: u32) -> (f32, f32) {
|
fn smeter_alphas(channel_sample_rate: u32) -> (f32, f32) {
|
||||||
if channel_sample_rate == 0 {
|
if channel_sample_rate == 0 {
|
||||||
return (0.3, 0.01);
|
return (0.3, 0.01);
|
||||||
}
|
}
|
||||||
let sr = channel_sample_rate as f32;
|
let sr = channel_sample_rate as f32;
|
||||||
let attack = (1.0 - (-1.0 / (sr * 0.050)).exp()).min(1.0); // τ = 50 ms
|
let attack = (1.0 - (-1.0 / (sr * 0.400)).exp()).min(1.0); // τ = 400 ms
|
||||||
let decay = (1.0 - (-1.0 / (sr * 0.300)).exp()).min(1.0); // τ = 300 ms
|
let decay = (1.0 - (-1.0 / (sr * 1.000)).exp()).min(1.0); // τ = 1.0 s
|
||||||
(attack, decay)
|
(attack, decay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user