Compare commits
5 Commits
| 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",
|
||||||
|
|||||||
@@ -489,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) => {
|
||||||
@@ -512,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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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