Add NEON (aarch64) vectorized paths mirroring the existing AVX2 paths:
- demod/math_arm.rs: replace the no-op placeholder with a full NEON FM
discriminator that processes 4 samples per iteration using a 7th-order
minimax atan polynomial and branchless atan2 with argument reduction,
matching the accuracy of the AVX2 path (max error ~2.4e-7 rad).
32-bit ARM retains the scalar fallback.
- dsp/filter.rs: add mul_freq_domain_neon() that deinterleaves 4 complex
pairs via vuzpq/vzipq, performs complex multiply with vmulq/vaddq/vsubq,
then reinterleaves. On aarch64 this path is always taken (NEON is
mandatory); scalar fallback remains for other targets.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
clamp_bandwidth_for_mode now floors AMC bandwidth at 9 kHz so that both
the sum (L+R) and difference (L−R) sidebands are always captured,
regardless of what the user or a set_filter call requests.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
RigMode::AMC was implemented but omitted from the supported_modes vec,
so the HTTP frontend never received it in capabilities and the mode
selector did not show AMC-QUAM.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
- remote_client tests: add missing server_connected field and import
AtomicBool in the test module
- pskreporter: replace map_or(true, …) with is_none_or and
repeat(x).take(n) with repeat_n(x, n)
- dsp.rs, scheduler.rs: suppress intentional too_many_arguments with
#[allow(clippy::too_many_arguments)]
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
Add CquamDemod (demod/amcquam.rs) with first-order IIR carrier phase
tracker (τ = 50 ms) that rotates baseband IQ to align I with the sum
audio and Q with the difference audio, then DC-blocks each channel to
yield L/R stereo PCM.
Wire AmCQuam into the Demodulator enum, add ChannelDsp::cquam_decoder
field initialized for RigMode::AMC, and insert the C-QUAM audio path
between the WFM and fallback branches in process_block. Update all
mode-dispatch tables (agc_for_mode, iq_agc_for_mode, dc_for_mode,
default_bandwidth_for_mode, lib.rs, vchan_impl.rs) with AMC arms.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
Replace the static fir_taps parameter with auto_taps(cutoff_norm) which
computes ceil(3.32 / cutoff_norm).clamp(63, 16383). This ensures the
filter transition band equals one passband width regardless of SDR sample
rate, giving correct image rejection when the user sets audio_bandwidth_hz.
At 912 ksps with 3 kHz audio bandwidth this yields ~2018 taps instead
of the previous hardcoded 64, eliminating the 114 kHz stopband gap that
caused adjacent-band signals to alias into the audio output.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
Add read_named_gain to IqSource (default: None) and implement it in
RealIqSource via Device::gain_element. Read the "LNA" element before
boxing the source so the initial sdr_lna_gain_db reflects the actual
hardware state, making the UI control visible and correct on first
connect. Devices without an LNA element (e.g. RTL-SDR with "TUNER")
return None and the control stays hidden.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
Add set_named_gain to the IqSource trait and implement it in
RealIqSource via soapysdr Device::set_gain_element. Wire a
lna_gain_cmd channel through SdrPipeline so the IQ read loop applies
LNA gain changes on the next iteration. Add set_sdr_lna_gain to
SoapySdrRig and expose the current value via filter_state.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
Add hardware AGC on/off control for SoapySDR backend, wired through the
full stack from RigCommand to the web UI:
- RigCommand::SetSdrAgc(bool) + ClientCommand::SetSdrAgc in protocol
- set_sdr_agc() on RigCat trait (not-supported default)
- SoapySdrRig: agc_enabled field, set_sdr_agc() via pipeline agc_cmd,
sdr_agc_enabled in filter_state(); removes the "not yet implemented"
warning — gain_mode="auto" now properly enables hardware AGC via
SoapySDR set_gain_mode()
- IqSource::set_gain_mode() trait method; RealIqSource implements it
- SdrPipeline: agc_cmd channel, read loop applies it each iteration
- POST /set_sdr_agc endpoint in trx-frontend-http
- New "SDR settings" full-row in index.html with Hardware AGC checkbox
and RF Gain (moved out of WFM controls); row hidden when
show_sdr_gain_control is false
- app.js: AGC checkbox handler, disables RF gain input while AGC is on,
syncs checkbox state from filter.sdr_agc_enabled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Three issues caused audible distortion on AM reception:
1. DC blocker shared r=0.9999 (τ≈1.25 s at 8 kHz) across all modes.
For AM the envelope detector outputs A_c+m(t) — always positive —
so the blocker needs to track the carrier bias quickly. AM now uses
r=0.999 (τ≈125 ms), 10× faster, while keeping the highpass cutoff
below 2 Hz so speech is unaffected.
2. Audio AGC time constants were inverted relative to good AM AGC design:
attack=200 ms (should be fast to prevent overload) and
release=3500 ms (unreasonably sluggish). Changed to attack=5 ms /
release=200 ms, target=0.5, max_gain=36 dB.
3. No IQ AGC before envelope detection meant carrier amplitude variation
went directly into the audio chain, forcing the slow audio AGC to
handle both RF level and audio level simultaneously. Added an AM IQ
AGC (attack=0.5 ms, release=50 ms, target=0.7, max=30 dB) that
normalizes carrier power before demod_am, so the DC blocker always
sees the same steady-state bias regardless of signal strength.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Three bugs caused USB/LSB to sound like AM:
1. The IQ low-pass filter was symmetric (passband ±BW/2), so both
sidebands were passed equally — taking .re then produced DSB-SC
rather than SSB audio.
2. cutoff_hz was computed as bandwidth_hz/2, halving the usable audio
bandwidth (1500 Hz for a 3 kHz USB channel).
3. demod_lsb claimed spectrum inversion was "handled upstream by
negating channel_if_hz", but that negation was never applied; USB
and LSB were functionally identical.
Fix: add a shift_norm parameter to build_fir_kernel / BlockFirFilterPair
that complex-modulates the time-domain FIR coefficients by
e^{j·2π·shift_norm·n}, shifting the passband in the frequency domain.
A new ssb_shift_norm() helper returns +cutoff_norm for USB/CW/DIG
([0, BW] Hz passband) and -cutoff_norm for LSB/CWR ([-BW, 0] Hz
passband); all other modes get 0.0 (symmetric LPF unchanged).
After the one-sided filter, taking .re correctly reconstructs the
selected sideband. No IF negation is needed for LSB.
Also fix two unit tests missing the force_mono_pcm argument introduced
after they were last updated.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Add [workspace.package] version = "0.1.0" to the root Cargo.toml and
switch all 21 member crates to version.workspace = true so the entire
workspace is versioned from a single place.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Two bugs introduced by 60697bb:
1. dsp.rs passed `channel_idx == 0` as force_mono_pcm, which forced the
primary pipeline channel to output mono samples. The Opus encoder was
configured for stereo, so it received half the expected frame data,
causing distortion for all connected audio clients.
Fixed by passing `false` — hidden virtual channels already set
force_mono_pcm=true via set_force_mono_pcm() in vchan_impl.rs.
2. main.rs short-circuited channel conversion when no audio clients were
connected, sending raw frames to pcm_tx (decoders). When clients then
connected, decoders switched to receiving stereo-interleaved frames,
making decoder input format dependent on client presence.
Fixed by always performing the channel conversion before sending to
pcm_tx; the no-client skip now only bypasses Opus encode + rx_audio_tx.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Handle AUDIO_MSG_VCHAN_BW in the audio server path and apply per-channel filter bandwidth through the SoapySDR virtual channel manager.
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
MARINE was a composite mode that ran both AIS and VDES decoders
simultaneously. It is now fully replaced by allocating two virtual
channels — one tuned to the AIS frequencies and one to VDES — each
decoded independently.
- trx-core/state: remove RigMode::MARINE variant
- trx-protocol/codec: remove MARINE parse/serialize
- trx-backend-ft817: remove MARINE from unsupported-mode guard
- trx-backend-ft450d: remove MARINE from FM CAT code mapping
- trx-backend-soapysdr: remove MARINE from bandwidth table, supported
modes list, AIS channel activity check, parse_rig_mode, vchan_impl
bandwidth table, demod selection, dsp/channel bandwidth / sample-rate
/ IQ-tap guards
- trx-server/audio: remove MARINE from AIS and VDES decoder activation
- trx-server/rig_task: remove MARINE from audio-streaming mode list
- trx-server/main: remove MARINE from bandwidth table, mode parser,
VDES channel subscription match
- app.js: remove isMarineMode(), MARINE entry in MODE_BW_SPECS, MARINE
bandwidth specs block in visibleBandwidthSpecs(), MARINE from
decoder status mode lists, MARINE BW-edge drag guard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Allow users to allocate multiple virtual channels independently of
browser tab count. Channels survive SDR center-frequency retuning as
long as they stay within the capture bandwidth; channels that fall
outside the SDR span are automatically destroyed.
Changes:
- trx-core: add AUDIO_MSG_VCHAN_DESTROYED (0x12) wire constant;
add default subscribe_destroyed() to VirtualChannelManager trait
- trx-backend-soapysdr: update_center_hz() detects OOB channels,
removes them, fires destroyed_tx broadcast; add destroyed_sender()
and subscribe_destroyed() override
- trx-server/audio: recv_destroyed() helper avoids select! busy-loop
for non-SDR backends; send AUDIO_MSG_VCHAN_DESTROYED to client when
a channel is evicted server-side
- trx-client/audio_client: persist active_subs across TCP reconnects,
re-subscribe on reconnect; handle AUDIO_MSG_VCHAN_DESTROYED by
pruning vchan_audio map and forwarding UUID via vchan_destroyed_tx
- trx-frontend/lib: add vchan_destroyed broadcast field to
FrontendRuntimeContext
- trx-client/main: wire vchan_destroyed_tx into audio client and
frontend runtime context
- trx-frontend-http/vchan: remove per-session one-channel limit in
allocate(); replace auto-evict in release_session_on_rig() with
subscriber-count-only update; add remove_by_uuid() for server-
triggered OOB destruction (skips redundant VChanAudioCmd::Remove)
- trx-frontend-http/server: spawn background task that forwards
vchan_destroyed broadcast to ClientChannelManager.remove_by_uuid()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
- VirtualSquelchConfig is only used in tests; gate import with #[cfg(test)]
- fixed_slot_count is reserved for future use; mark #[allow(dead_code)]
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Add VirtualChannelManager trait in trx-core::vchan with types VChannelInfo,
VChanError, and SharedVChanManager alias. Re-export from trx-backend::vchan.
Implement SdrVirtualChannelManager in trx-backend-soapysdr:
- Wraps Arc<SdrPipeline> + shared AtomicI64 center_hz
- add_channel / remove_channel / set_channel_freq / set_channel_mode
- Slot-stability: on remove, shifts pipeline_slot for surviving channels
- update_center_hz: recomputes IF offsets for all virtual channels on retune
- update_primary_meta: keeps channel-0 freq/mode in sync for API consumers
Wire into SoapySdrRig (holds Arc<SdrVirtualChannelManager>, exposes
channel_manager()), SdrPipeline (shared_center_hz AtomicI64), and RigHandle
(vchan_manager: Option<SharedVChanManager>). main.rs extracts the manager
before boxing the SDR rig and stores it in the handle.
Add max_virtual_channels to SdrConfig (default 4, TOML-configurable).
Add 5 unit tests: add, remove, permanent guard, cap, out-of-bandwidth.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Replace the fixed channel_dsps Vec (thread-owned) with an
Arc<RwLock<Vec<...>>> shared between the IQ read thread and the async
side, enabling live add/remove of virtual DSP channels.
Cache audio construction params in SdrPipeline so add_virtual_channel()
can build ChannelDsp instances without being re-passed them. Add:
- SdrPipeline::add_virtual_channel() / remove_virtual_channel()
- SoapySdrRig::virtual_channel_add/remove/set_freq/set_mode()
- SoapySdrRig::center_hz() / half_span_hz() accessors
The IQ read loop holds a brief read lock (~2 ms per block) while
processing all channels; write lock for add/remove waits at most one
block. All 27 existing tests continue to pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Two problems prevented reliable recovery after a persistent overflow:
1. Restart storm: once read_error_streak >= 3, every subsequent read
failure triggered a deactivate/activate cycle, potentially preventing
the hardware from stabilising. Fix: after a successful restart, reset
read_error_streak to 1 so the stream gets 2 more failures before the
next restart attempt.
2. Stuck-deactivated stream: if activate() failed after overflow, the
stream was left deactivated. Subsequent reads returned non-overflow
errors which handle_read_error ignored (Ok(false)), so the stream was
never reactivated. Fix: add a high-streak fallback (>= 10 consecutive
errors of any kind) that also attempts a full deactivate/activate
restart, covering the stuck-deactivated case.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Add per-channel processing gating and disable hidden AIS channel DSP unless mode is AIS or MARINE, reducing continuous IQ read-loop CPU load in normal operation.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Skip IQ fanout buffer cloning when no subscribers exist and apply backoff on repeated zero-length reads to avoid hot-loop CPU spikes.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Raise the AM audio AGC target and headroom again so the restored envelope demod path plays at a stronger level.
Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
Retune the AM audio AGC target, timing, and headroom so the restored envelope demod path plays back at a stronger level.
Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
Revert the recent AM coherent detector experiment and restore the original envelope detector path.
Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
Remove half-wave clipping from the AM coherent detector output and slow the carrier-reference tracking to reduce audible distortion.
Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
Replace the raw AM envelope detector with a limiter-derived coherent detector and update backend tests for the current channel DSP constructor.
Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
Keep the SoapySDR VDES and MARINE IQ path at a much higher channel sample rate instead of collapsing toward the normal audio rate, so the decoder receives usable complex baseband for the 76.8 ksps VDES signal.
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
Add an FT8 live overlay bar, align APRS top controls with the other decoder tabs, advertise MARINE in the SoapySDR mode list, and make the VDES decoder emit raw unsynced diagnostic frames instead of dropping weak bursts outright.
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
Keep weak VDES bursts visible by emitting unsynced diagnostic frames instead of dropping them, remove receiver badges from FT8 and WSPR history rows, and carry the current MARINE composite-mode scaffolding through the shared mode enums and backend mappings.
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
Add a dedicated VDES plugin tab and live bar, stop reusing the AIS vessel UI, and serve a separate VDES frontend script. Rework the SDR backend so VDES receives a single 100 kHz IQ tap, then replace the fake AIS-clone decoder path with an early M.2092-1 oriented complex-baseband scaffold using burst detection, coarse pi/4-QPSK slicing, and TER-MCS-1.100 frame heuristics.
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
Add a new trx-vdes decoder path alongside AIS, wire VDES through the server/frontend decode pipeline, and fix the web map so AIS vessel symbols load correctly and the TRX receiver marker appears when location data arrives.
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Add dual-channel AIS decode support across the SoapySDR backend, server decode pipeline, and frontend plugins, including the new AIS tab, live bar, and map filtering.
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>