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.
Embed a browser-based control surface into the HTTP server and document the live-control API. The new UI exposes TX start/stop, runtime telemetry, frequency, levels, toggles, and RDS text updates against the existing live-config endpoints.
Document the HTTP API surface in detail, including runtime control, live config updates, split-rate behaviour, and the current hot-reload semantics for on-air parameter changes.
Cache altvoltage1 during Configure(), clear it in cleanup(), and use the cached channel for Tune() instead of calling findChannel on every retune. Tune() now checks the direct iio_channel_attr_write_longlong return code and reports LO write failures back to the caller with the failing frequency and libiio error code.
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.
The previous commit message said the FMUpsampler was not yet wired, but the current code already uses it in NewEngine()/run() when deviceRate exceeds compositeRate. Add an explicit correction commit instead of rewriting history.
feat: add production-grade FMUpsampler (not yet wired)
=== 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 deg 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 3x 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).
=== NEW: FMUpsampler (not wired, for future split-rate path) ===
dsp/fmupsample.go — production-grade phase-domain FM upsampler.
Accumulates FM phase at source rate (228kHz), linearly interpolates
to device rate (2.28MHz), emits IQ via sin/cos. Zero-allocation
steady state. Cross-chunk boundary via prevPhase + srcPos carry.
Symmetric phase wrapping. Virtual index coordinate system places
prevPhase at vi=0, srcPhases at vi=1..N for seamless boundaries.
dsp/fmupsample_test.go — 16 tests + 1 benchmark:
- Zero/DC/varying composite signals
- Output count for exact and non-integer ratios
- Phase continuity across chunk boundaries (critical)
- Non-integer ratio boundary continuity (50 chunks)
- Phase wrapping over 100 chunks at full deviation
- Negative deviation (tests symmetric wrapping)
- Equivalence with FMModulator via frequency comparison
- Buffer reuse verification (pointer identity)
- Reset state clearing
- Tiny chunks (1 sample), alternating composite (stress)
- Long-run: 72000 chunks simulating 1 hour
- Benchmark with ReportAllocs
NOT wired into Engine — will be integrated when split-rate path
is enabled for Pluto/HackRF on Raspi.
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/dsp/fmupsample.go - NEW: production FM upsampler
internal/dsp/fmupsample_test.go - NEW: 16 tests + benchmark
internal/output/file.go - batch write
internal/app/engine.go - WaitGroup + backoff
internal/control/control.go - TODO config propagation
=== 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.
Write PlutoSDR TX samples via per-channel buffer pointers from iio_buffer_first instead of assuming a fixed interleaved layout. Also derive the 38 kHz stereo subcarrier directly from the pilot phase to guarantee phase coherence for the FM stereo multiplex.
Clarify that outputDrive controls only the composite signal path while PlutoSDR hardware gain stays fixed at 0 dB. Relax outputDrive validation to allow stronger composite drive during hardware tuning and update the Pluto example config accordingly.
Introduce CLI TX mode, hardware driver selection, IQ resampling to device rate, and platform-specific PlutoSDR/SoapySDR integrations. Update engine pacing for blocking hardware writes and refresh docs/release notes for the hardware-ready v0.6.0-pre milestone.