package dsp import ( "math" "github.com/jan/fm-rds-tx/internal/output" ) // FMUpsampler converts a composite baseband signal at a low source rate into // FM-modulated IQ samples at a higher device rate, via phase-domain // interpolation. // // Architecture: accumulate FM phase at source rate (cheap, few trig ops), // then linearly interpolate the phase to device rate and emit sin/cos. // This is mathematically equivalent to running the full FMModulator at device // rate, but needs trig only at the output rate — saving all the DSP that // would otherwise run at the higher rate (stereo encode, RDS, limiter etc.). // // Cross-chunk boundary: the upsampler carries over prevPhase and srcPos // between calls. The interpolation coordinate system places prevPhase at // virtual index 0 and srcPhases[0..N-1] at indices 1..N. This guarantees // smooth phase transitions at every chunk boundary with zero discontinuity. // // Zero-allocation in steady state: all buffers are pre-allocated on first // call and reused. The returned CompositeFrame is an internal buffer — // valid only until the next Process() call. type FMUpsampler struct { srcRate float64 // composite rate (e.g. 228000) dstRate float64 // device rate (e.g. 2280000) maxDeviation float64 // peak FM deviation in Hz (e.g. 75000) step float64 // source-samples per output-sample = srcRate/dstRate // Persistent state across Process() calls phase float64 // accumulated FM phase in radians, continuous across chunks prevPhase float64 // phase at end of previous chunk (virtual index 0) srcPos float64 // fractional source position carry-over into next chunk seeded bool // true after first Process() call // Pre-allocated buffers — grown once, never shrunk srcPhases []float64 outBuf []output.IQSample outFrame output.CompositeFrame } // NewFMUpsampler creates a phase-domain upsampler. // // srcRate: composite DSP rate (typ. 228000 Hz) // dstRate: device output rate (typ. 2280000 Hz) // maxDeviation: FM peak deviation (typ. 75000 Hz) func NewFMUpsampler(srcRate, dstRate, maxDeviation float64) *FMUpsampler { return &FMUpsampler{ srcRate: srcRate, dstRate: dstRate, maxDeviation: maxDeviation, step: srcRate / dstRate, } } // Process takes a CompositeFrame where Samples[i].I contains the composite // baseband value (FM modulation must be OFF in the generator; Q is ignored). // Returns an FM-modulated IQ frame at dstRate. // // The returned frame is an internal buffer — valid until the next Process() // call. The caller must consume or copy the data before calling again. func (u *FMUpsampler) Process(frame *output.CompositeFrame) *output.CompositeFrame { if frame == nil || len(frame.Samples) == 0 { return frame } srcLen := len(frame.Samples) // --- Phase accumulation at source rate --- // Grow srcPhases buffer if needed if cap(u.srcPhases) < srcLen { u.srcPhases = make([]float64, srcLen) } srcPhases := u.srcPhases[:srcLen] phaseInc := 2 * math.Pi * u.maxDeviation / u.srcRate for i, s := range frame.Samples { u.phase += float64(s.I) * phaseInc srcPhases[i] = u.phase } // Phase wrapping — symmetric, shift prevPhase in lockstep if u.phase > math.Pi || u.phase < -math.Pi { offset := 2 * math.Pi * math.Floor((u.phase+math.Pi)/(2*math.Pi)) u.phase -= offset for i := range srcPhases { srcPhases[i] -= offset } if u.seeded { u.prevPhase -= offset } } // Seed prevPhase on very first call if !u.seeded { // Extrapolate backwards from first two phases to get a virtual "previous" // phase, so the first chunk's boundary interpolation is smooth. if srcLen >= 2 { u.prevPhase = 2*srcPhases[0] - srcPhases[1] } else { u.prevPhase = srcPhases[0] } u.srcPos = 0 u.seeded = true } // --- Interpolation coordinate system --- // // Virtual index 0 = prevPhase (end of previous chunk) // Virtual index 1 = srcPhases[0] // Virtual index 2 = srcPhases[1] // ... // Virtual index N = srcPhases[N-1] // // srcPos ranges from 0 (carry-over) to N (= srcLen). // We generate output samples while srcPos < srcLen (virtual index srcLen). // phaseAt returns the phase at a virtual index. phaseAt := func(vi int) float64 { if vi <= 0 { return u.prevPhase } if vi > srcLen { return srcPhases[srcLen-1] } return srcPhases[vi-1] } // Calculate output count: from srcPos to srcLen, stepping by u.step. // +1 for safety margin; we clamp below. maxOut := int(math.Ceil(float64(srcLen)-u.srcPos)/u.step) + 1 if maxOut < 0 { maxOut = 0 } // Grow output buffer if needed if cap(u.outBuf) < maxOut { u.outBuf = make([]output.IQSample, maxOut) } out := u.outBuf[:maxOut] // --- Generate output samples --- pos := u.srcPos n := 0 for pos < float64(srcLen) && n < maxOut { vi := int(pos) // virtual index (integer part) frac := pos - float64(vi) pA := phaseAt(vi) pB := phaseAt(vi + 1) p := pA + frac*(pB-pA) out[n] = output.IQSample{ I: float32(math.Cos(p)), Q: float32(math.Sin(p)), } n++ pos += u.step } // Carry state for next chunk u.prevPhase = srcPhases[srcLen-1] u.srcPos = pos - float64(srcLen) // Package output u.outFrame.Samples = out[:n] u.outFrame.SampleRateHz = u.dstRate u.outFrame.Timestamp = frame.Timestamp u.outFrame.Sequence = frame.Sequence return &u.outFrame } // Reset clears all accumulated state, as if freshly constructed. func (u *FMUpsampler) Reset() { u.phase = 0 u.prevPhase = 0 u.srcPos = 0 u.seeded = false } // Stats returns internal state for diagnostics/testing. func (u *FMUpsampler) Stats() (phase, prevPhase, srcPos float64) { return u.phase, u.prevPhase, u.srcPos }