=== 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.