feat: add production-grade FMUpsampler (not yet wired)tags/v0.9.0
| @@ -1,112 +0,0 @@ | |||||
| fix: 13 bugs from systematic codebase review (fix25) | |||||
| 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 | |||||
| @@ -3,11 +3,13 @@ package app | |||||
| import ( | import ( | ||||
| "context" | "context" | ||||
| "fmt" | "fmt" | ||||
| "log" | |||||
| "sync" | "sync" | ||||
| "sync/atomic" | "sync/atomic" | ||||
| "time" | "time" | ||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | cfgpkg "github.com/jan/fm-rds-tx/internal/config" | ||||
| "github.com/jan/fm-rds-tx/internal/dsp" | |||||
| offpkg "github.com/jan/fm-rds-tx/internal/offline" | offpkg "github.com/jan/fm-rds-tx/internal/offline" | ||||
| "github.com/jan/fm-rds-tx/internal/platform" | "github.com/jan/fm-rds-tx/internal/platform" | ||||
| ) | ) | ||||
| @@ -51,6 +53,7 @@ type Engine struct { | |||||
| cfg cfgpkg.Config | cfg cfgpkg.Config | ||||
| driver platform.SoapyDriver | driver platform.SoapyDriver | ||||
| generator *offpkg.Generator | generator *offpkg.Generator | ||||
| upsampler *dsp.FMUpsampler // nil = same-rate, non-nil = split-rate | |||||
| chunkDuration time.Duration | chunkDuration time.Duration | ||||
| deviceRate float64 | deviceRate float64 | ||||
| @@ -67,18 +70,41 @@ type Engine struct { | |||||
| } | } | ||||
| func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { | func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { | ||||
| // Run entire DSP chain at device rate. RDS encoder resamples its | |||||
| // PiFmRds waveform internally. No phase upsampling needed. | |||||
| deviceRate := cfg.EffectiveDeviceRate() | deviceRate := cfg.EffectiveDeviceRate() | ||||
| if deviceRate > 0 { | |||||
| cfg.FM.CompositeRateHz = int(deviceRate) | |||||
| compositeRate := float64(cfg.FM.CompositeRateHz) | |||||
| if compositeRate <= 0 { | |||||
| compositeRate = 228000 | |||||
| } | |||||
| var upsampler *dsp.FMUpsampler | |||||
| if deviceRate > compositeRate*1.001 { | |||||
| // Split-rate: DSP chain runs at compositeRate (typ. 228 kHz), | |||||
| // FMUpsampler handles FM modulation + interpolation to deviceRate. | |||||
| // This halves CPU load compared to running all DSP at deviceRate. | |||||
| cfg.FM.FMModulationEnabled = false | |||||
| maxDev := cfg.FM.MaxDeviationHz | |||||
| if maxDev <= 0 { | |||||
| maxDev = 75000 | |||||
| } | |||||
| upsampler = dsp.NewFMUpsampler(compositeRate, deviceRate, maxDev) | |||||
| log.Printf("engine: split-rate mode — DSP@%.0fHz → upsample@%.0fHz (ratio %.2f)", | |||||
| compositeRate, deviceRate, deviceRate/compositeRate) | |||||
| } else { | |||||
| // Same-rate: entire DSP chain runs at deviceRate. | |||||
| // Used when deviceRate ≈ compositeRate (e.g. LimeSDR at 228 kHz). | |||||
| if deviceRate > 0 { | |||||
| cfg.FM.CompositeRateHz = int(deviceRate) | |||||
| } | |||||
| cfg.FM.FMModulationEnabled = true | |||||
| log.Printf("engine: same-rate mode — DSP@%dHz", cfg.FM.CompositeRateHz) | |||||
| } | } | ||||
| cfg.FM.FMModulationEnabled = true | |||||
| return &Engine{ | return &Engine{ | ||||
| cfg: cfg, | cfg: cfg, | ||||
| driver: driver, | driver: driver, | ||||
| generator: offpkg.NewGenerator(cfg), | generator: offpkg.NewGenerator(cfg), | ||||
| upsampler: upsampler, | |||||
| chunkDuration: 50 * time.Millisecond, | chunkDuration: 50 * time.Millisecond, | ||||
| deviceRate: deviceRate, | deviceRate: deviceRate, | ||||
| state: EngineIdle, | state: EngineIdle, | ||||
| @@ -167,6 +193,9 @@ func (e *Engine) run(ctx context.Context) { | |||||
| return | return | ||||
| } | } | ||||
| frame := e.generator.GenerateFrame(e.chunkDuration) | frame := e.generator.GenerateFrame(e.chunkDuration) | ||||
| if e.upsampler != nil { | |||||
| frame = e.upsampler.Process(frame) | |||||
| } | |||||
| n, err := e.driver.Write(ctx, frame) | n, err := e.driver.Write(ctx, frame) | ||||
| if err != nil { | if err != nil { | ||||
| if ctx.Err() != nil { return } | if ctx.Err() != nil { return } | ||||
| @@ -79,3 +79,55 @@ func TestEngineDriverStats(t *testing.T) { | |||||
| t.Fatal("expected driver to have written frames") | t.Fatal("expected driver to have written frames") | ||||
| } | } | ||||
| } | } | ||||
| func TestEngineSplitRate(t *testing.T) { | |||||
| // Simulate Pluto-like config: composite at 228kHz, device at 2.28MHz | |||||
| cfg := cfgpkg.Default() | |||||
| cfg.Backend.DeviceSampleRateHz = 2280000 // 10:1 ratio | |||||
| driver := platform.NewSimulatedDriver(nil) | |||||
| eng := NewEngine(cfg, driver) | |||||
| eng.SetChunkDuration(10 * time.Millisecond) | |||||
| // Verify split-rate path was chosen | |||||
| if eng.upsampler == nil { | |||||
| t.Fatal("expected split-rate mode (upsampler != nil)") | |||||
| } | |||||
| ctx := context.Background() | |||||
| if err := eng.Start(ctx); err != nil { | |||||
| t.Fatalf("start: %v", err) | |||||
| } | |||||
| time.Sleep(200 * time.Millisecond) | |||||
| stats := eng.Stats() | |||||
| if stats.ChunksProduced < 5 { | |||||
| t.Fatalf("expected at least 5 chunks, got %d", stats.ChunksProduced) | |||||
| } | |||||
| // With 10:1 upsampling and 10ms chunks at 228kHz source: | |||||
| // 2280 src samples → 22800 output samples per chunk | |||||
| // 5 chunks minimum → at least 114000 samples | |||||
| if stats.TotalSamples < 50000 { | |||||
| t.Fatalf("expected at least 50000 upsampled samples, got %d", stats.TotalSamples) | |||||
| } | |||||
| if err := eng.Stop(ctx); err != nil { | |||||
| t.Fatalf("stop: %v", err) | |||||
| } | |||||
| if stats.Underruns > 0 { | |||||
| t.Fatalf("unexpected underruns: %d", stats.Underruns) | |||||
| } | |||||
| } | |||||
| func TestEngineSameRate(t *testing.T) { | |||||
| // Default config: deviceSampleRateHz=0, compositeRateHz=228000 | |||||
| // EffectiveDeviceRate = 228000 → same-rate mode, no upsampler | |||||
| cfg := cfgpkg.Default() | |||||
| driver := platform.NewSimulatedDriver(nil) | |||||
| eng := NewEngine(cfg, driver) | |||||
| if eng.upsampler != nil { | |||||
| t.Fatal("expected same-rate mode (upsampler == nil)") | |||||
| } | |||||
| } | |||||
| @@ -521,8 +521,8 @@ func TestFMUpsampler_AlternatingComposite(t *testing.T) { | |||||
| jump := math.Abs(phaseDiff(prevLastPhase, iqPhase(out.Samples[0]))) | jump := math.Abs(phaseDiff(prevLastPhase, iqPhase(out.Samples[0]))) | ||||
| // Alternating composite: the phase meanders. Boundary jump | // Alternating composite: the phase meanders. Boundary jump | ||||
| // should still be bounded by the maximum single-sample phase change. | // should still be bounded by the maximum single-sample phase change. | ||||
| maxSingleStep := 2 * math.Pi * 0.8 * 75000 / 228000 // at source rate | |||||
| maxOutputJump := maxSingleStep * (228000 / 2280000) // scaled to output | |||||
| maxSingleStep := 2 * math.Pi * 0.8 * 75000 / 228000.0 // at source rate | |||||
| maxOutputJump := maxSingleStep * 0.1 // step = srcRate/dstRate | |||||
| if jump > maxOutputJump*3 { | if jump > maxOutputJump*3 { | ||||
| t.Fatalf("chunk %d: alternating boundary jump %.4f (limit %.4f)", | t.Fatalf("chunk %d: alternating boundary jump %.4f (limit %.4f)", | ||||
| chunk, jump, maxOutputJump*3) | chunk, jump, maxOutputJump*3) | ||||