package dsp import ( "math" "github.com/jan/fm-rds-tx/internal/output" ) // FMPhaseUpsampler performs FM modulation + upsampling via phase-domain // interpolation. Maintains continuous phase across successive calls. type FMPhaseUpsampler struct { srcRate float64 dstRate float64 maxDeviation float64 ratio float64 phase float64 // persistent across calls } func NewFMPhaseUpsampler(srcRate, dstRate, maxDeviation float64) *FMPhaseUpsampler { return &FMPhaseUpsampler{ srcRate: srcRate, dstRate: dstRate, maxDeviation: maxDeviation, ratio: dstRate / srcRate, } } func (u *FMPhaseUpsampler) Process(frame *output.CompositeFrame) *output.CompositeFrame { if frame == nil || len(frame.Samples) == 0 { return frame } srcLen := len(frame.Samples) // Accumulate phase at source rate, continuing from previous chunk phases := make([]float64, srcLen) for i, s := range frame.Samples { u.phase += 2 * math.Pi * float64(s.I) * u.maxDeviation / u.srcRate phases[i] = u.phase } // Keep phase bounded if u.phase > 1e9 { offset := math.Floor(u.phase/(2*math.Pi)) * 2 * math.Pi u.phase -= offset for i := range phases { phases[i] -= offset } } // Interpolate phase to target rate dstLen := int(float64(srcLen) * u.ratio) dst := make([]output.IQSample, dstLen) step := 1.0 / u.ratio pos := 0.0 for i := 0; i < dstLen; i++ { idx := int(pos) frac := pos - float64(idx) var p float64 if idx+1 < srcLen { p = phases[idx]*(1-frac) + phases[idx+1]*frac } else if idx < srcLen { p = phases[idx] } dst[i] = output.IQSample{ I: float32(math.Cos(p)), Q: float32(math.Sin(p)), } pos += step } return &output.CompositeFrame{ Samples: dst, SampleRateHz: u.dstRate, Timestamp: frame.Timestamp, Sequence: frame.Sequence, } } // UpsampleFMPhase is the stateless version for offline/test use. func UpsampleFMPhase(frame *output.CompositeFrame, targetRate, maxDeviation float64) *output.CompositeFrame { u := NewFMPhaseUpsampler(frame.SampleRateHz, targetRate, maxDeviation) return u.Process(frame) }