Add an explicit fm.watermarkEnabled switch, validate fm.stereoMode values, and only persist /config snapshots after a live update succeeds. Also separate evaluation-jingle licensing behavior from watermark handling.
Extend the RDS feature set with LPS and eRT support, add initial RDS2/logo-transfer wiring, update the control UI and refresh the PlutoSDR example configuration and reference docs.
Add experimental single-sideband and vestigial-sideband stereo encoding
modes alongside the standard DSB-SC stereo subcarrier path.
- add encoder mode selection (DSB, SSB, VSB)
- add Hilbert-transform based quadrature path for SSB generation
- add vestigial split with low-band DSB and high-band SSB handling
- wire the mode through config, control, engine, generator, and fmrtx
- add test coverage for the SSB path
The default mode remains the standard DSB stereo encoder.
The StereoLimiter previously used instantaneous attack — gain was reduced
in the same sample that exceeded the ceiling. While this guarantees zero
overshoot, it suppresses transient peaks that the human auditory system
cannot resolve anyway, reducing perceived loudness and causing audible
gain pumping on percussive material.
Changed to a 2ms exponential attack based on psychoacoustic burst masking
research (O. Bonello, "Multiband audio processing and its influence on
the coverage area of FM stereo transmission", JAES 2007):
- The ear-brain system needs ~50ms to resolve distortion in a signal.
For bursts shorter than 5ms, masking thresholds increase by up to
36 dB compared to steady-state (burst masking).
- With 2ms attack, initial transient peaks pass through the limiter
unattenuated and are caught by the downstream HardClip. The clip
artifacts last <5ms (63% reduction in 2ms, 95% in 6ms), falling
within the burst masking window.
- The limiter no longer reacts to micro-transients that were already
inaudible, raising average modulation level without increasing
perceived distortion.
Signal chain interaction:
Audio → Drive → StereoLimiter (2ms attack, 150ms release)
→ HardClip (safety net, catches the <5ms transient peaks)
→ Cleanup LPF → HardClip
→ Stereo Encode → Composite Clipper
The HardClip after the limiter remains as the compliance safety net.
Peak modulation is guaranteed by the clip, not by the limiter. The
limiter's job is average level management; the clipper handles peaks.
Release time reduced from 200ms to 150ms for slightly faster recovery
on sustained passages without audible pumping.
Add an STFT watermark path inspired by Kirovski & Malvar, including the frequency-domain embedder/decoder, FFT support, and round-trip coverage. Wire the generator and CLI tools to use the new analysis/synthesis flow for watermark experiments on the watermark-rework branch.
Wire tone frequency, tone amplitude, and audio gain through the live control path so the UI's live-update behavior matches the engine.
This changes the generator live params to carry tone and gain values, propagates them through Engine.UpdateConfig and txBridge.UpdateConfig, and extends the control-plane patch types accordingly.
It also refines the control API behavior:
- avoid holding the server config mutex across tx.UpdateConfig()
- report live=true only when a request contains at least one genuinely live-applicable field
Together these fixes align the UI semantics with the actual runtime behavior and remove a lock hazard in the config update path.
Introduce an optional ITU-R BS.412 MPX power limiter, tighten the low-pass/notch filter design around the protected composite path, and document the full DSP architecture and recommended Pluto configuration in detail.
Rework the DSP chain to a clip-filter-clip architecture with cascaded 14 kHz low-pass stages, double 19/57 kHz protection notches, fixed pilot/RDS injection semantics, and explicit MPX gain calibration support. Update config defaults and tests to match the new broadcast-style modulation budgeting and protected composite path.
Upgrade the audio path to a broadcast-style chain with a 4th-order 15 kHz low-pass, 19 kHz pilot notch, audio-only composite clipping, and post-clip protection notches at 19/57 kHz before adding clean pilot and RDS carriers. This reorders the processing so pilot and RDS stay fixed and unclipped while audio dynamics are constrained within the available modulation budget.
Add a lock-free stdin PCM ingest path, streaming resampler, stereo-linked limiting and pre-MPX audio filtering, plus the engine/control wiring needed to drive live audio into TX mode. Also document the ingest API and include a helper batch script for piping ffmpeg audio into fmrtx.
All major TX parameters are now hot-swappable during transmission:
- DSP params (drive, stereo, pilot, RDS levels, limiter) via
atomic.Pointer[LiveParams], loaded once per chunk (~50ms)
- RDS text (PS, RT) via atomic.Value in encoder, applied at
RDS group boundaries (~88ms)
- TX frequency via driver.Tune(), applied between chunks
Zero locks in DSP path. HTTP handler writes atomics, run loop
reads them. Validation happens before store.
New: SoapyDriver.Tune() for live frequency changes.
New: LiveConfigUpdate/LivePatch types for type-safe patching.
New: 4 engine tests for live DSP/freq/RDS updates + validation.
=== SIGNAL PATH (fixes audible/measurable on HW) ===
[CRITICAL] stereo: fix 38kHz subcarrier 1-sample phase offset
Encode() called Sample() before capturing Phase(), so the 38kHz
subcarrier used phase_{n+1} while pilot used phase_n — a constant
60° phase error at 228kHz, degrading stereo separation to ~50%.
Now captures phase BEFORE tick; stores lastPhase for coherent
RDS carrier derivation.
[HIGH] rds: phase-lock 57kHz carrier to pilot via StereoEncoder
RDS encoder had its own free-running 57kHz oscillator instead of
deriving from 3×pilot. Two independent float64 oscillators drift
apart over hours. Added NextSampleWithCarrier(carrier float64),
generator now passes stereoEncoder.RDSCarrier() (sin(3·pilotPhase))
so all subcarriers share one phase reference. NextSample() remains
as backward-compat fallback with internal carrier.
[MEDIUM] dsp/fmmod: fix phase wrapping for negative phase
Wrap condition only triggered on phase > pi. Negative phase from
negative-biased composite could grow unbounded, losing float64
precision after extended runtime. Now wraps on |phase| > pi.
[MEDIUM] dsp/oscillator: phase wrapping handles both directions
Same pattern as fmmod — only wrapped positive overflow. Now wraps
on phase >= 1 OR phase < 0 for defensive robustness.
=== DAUERLAUF / SBC STABILITY ===
[HIGH] offline/generator: eliminate per-chunk allocation (912KB @ 2.28MHz)
GenerateFrame() allocated a new []IQSample every call. At Pluto
rate (2.28MHz, 50ms chunks) that's 114k*8=912KB * 20/sec = 18MB/s
garbage, causing GC pauses and hardware underruns on Raspi.
Now pre-allocates frameBuf, reuses across calls. Grow-only policy,
never shrinks. Safe because driver.Write() is blocking.
[MEDIUM] output/file: batch-write entire frame in one syscall
Was writing 8 bytes per sample = 114k syscalls per chunk on Pluto.
Now serializes into a reusable byte buffer, single file.Write().
Orders of magnitude faster on Raspi SD-card I/O.
[MEDIUM] app/engine: add error backoff in TX loop
run() tight-looped at 100% CPU on persistent driver errors
(hardware disconnect, USB reset). Now backs off by chunkDuration
per error using select with ctx.Done() for clean cancellation.
[MEDIUM] app/engine: replace time.Sleep shutdown with sync.WaitGroup
Stop() used time.Sleep(2*chunkDuration) hoping run() would exit.
Race condition if hardware Write() stalls. Now uses wg.Wait() for
deterministic goroutine join before Flush/Stop.
=== CORRECTNESS / HYGIENE ===
[LOW] rds/normalize: filter to ASCII, prevent mid-rune truncation
normalizePS truncated at 8 bytes, not 8 characters. UTF-8 input
(e.g. umlauts) could split mid-rune producing corrupt RDS bitstream.
Now replaces non-ASCII with space before truncation.
[LOW] offline/generator: increment frame sequence counter
Sequence was hardcoded to 1 on every frame, useless for debugging.
Now increments per GenerateFrame() call.
[LOW] control: document that config PATCH doesn't reach running engine
Added TODO noting that POST /config updates server's copy only;
running Engine/Generator holds its own snapshot.
[COSMETIC] dsp/preemphasis: remove dead fields y1, a1
PreEmphasis.y1 and .a1 were never read in Process(). Removed from
struct, constructor, and Reset(). Fixed misleading doc comment on
PreEmphasizedSource (claims audio-rate, actually composite-rate).
Files changed:
internal/stereo/encoder.go - phase coherence fix
internal/rds/encoder.go - pilot-locked carrier API
internal/rds/normalize.go - ASCII safety
internal/offline/generator.go - buffer reuse + sequence + doc
internal/dsp/fmmod.go - bidirectional phase wrap
internal/dsp/oscillator.go - bidirectional phase wrap
internal/dsp/preemphasis.go - dead field removal
internal/output/file.go - batch write
internal/app/engine.go - WaitGroup + backoff
internal/control/control.go - TODO config propagation
Keep frame sequence numbers monotonic, lock the RDS carrier to the exact pilot phase used by the stereo encoder, and apply the accompanying DSP/control cleanups needed for stable live transmission behaviour.
Keep DSP state persistent across generated frames, move the RDS encoder to arbitrary sample-rate operation, and tune the Pluto profile to the working 2.28 MHz path with the levels that finally decode reliably.
Replace the pilot-derived RDS path with a PiFmRds-style 228 kHz shaped biphase generator, resample it into the composite loop, and retune Pluto example levels plus spectral thresholds around the new RDS behaviour.
Drive RDS in the offline/MPX path from the pilot-locked 57 kHz carrier with biphase symbol timing, and adjust Pluto example levels plus spectral thresholds for the new multiplex behaviour.