package stereo import ( "math" "github.com/jan/fm-rds-tx/internal/audio" "github.com/jan/fm-rds-tx/internal/dsp" ) const ssbHilbertTaps = 127 // Mode selects the stereo subcarrier modulation method. type Mode int const ( ModeDSB Mode = iota // Standard DSB-SC (FCC §73.322 compliant) ModeSSB // SSB-SC LSB only (Foti/Tarsio, experimental) ModeVSB // Vestigial SB (0-200Hz DSB, above SSB; experimental) ) // ParseMode converts a string to Mode. Returns ModeDSB for unknown values. func ParseMode(s string) Mode { switch s { case "SSB", "ssb": return ModeSSB case "VSB", "vsb": return ModeVSB default: return ModeDSB } } // String returns the mode name. func (m Mode) String() string { switch m { case ModeSSB: return "SSB" case ModeVSB: return "VSB" default: return "DSB" } } // Components holds the individual MPX components produced by the stereo encoder. // All outputs are unity-normalized. The combiner controls actual injection levels. type Components struct { Mono float64 // (L+R)/2 baseband Stereo float64 // (L-R)/2 modulated onto 38 kHz subcarrier Pilot float64 // sin(pilotPhase), unity amplitude } // sampleDelay is a simple fixed-sample delay line. type sampleDelay struct { buf []float64 pos int } func newSampleDelay(samples int) *sampleDelay { if samples <= 0 { return nil } return &sampleDelay{buf: make([]float64, samples)} } func (d *sampleDelay) Process(in float64) float64 { if d == nil || len(d.buf) == 0 { return in } out := d.buf[d.pos] d.buf[d.pos] = in d.pos++ if d.pos >= len(d.buf) { d.pos = 0 } return out } func (d *sampleDelay) Reset() { if d == nil { return } for i := range d.buf { d.buf[i] = 0 } d.pos = 0 } // StereoEncoder generates stereo MPX primitives from stereo audio frames. // Supports DSB-SC (standard), SSB-SC (lower sideband only), and VSB modes. type StereoEncoder struct { pilot dsp.PilotGenerator lastPhase float64 mode Mode // SSB/VSB paths use a Hilbert transformer. The Hilbert FIR introduces a // fixed group delay, so the mono path must be delayed by the same amount. hilbert *dsp.HilbertFilter monoDelay *sampleDelay // VSB remains experimental. The low band is kept as DSB, while the high band // is encoded as SSB. Mono delay compensation still applies so the decoded // stereo matrix does not produce comb/reverb artefacts. vsbLPF *dsp.FilterChain hilbertHi *dsp.HilbertFilter } // NewStereoEncoder creates a StereoEncoder configured for the provided sample rate. func NewStereoEncoder(sampleRate float64) StereoEncoder { return StereoEncoder{ pilot: dsp.NewPilotGenerator(sampleRate), mode: ModeDSB, } } // SetMode changes the stereo encoding mode and (re)initializes internal state. func (s *StereoEncoder) SetMode(mode Mode, sampleRate float64) { s.mode = mode s.hilbert = nil s.hilbertHi = nil s.vsbLPF = nil s.monoDelay = nil switch mode { case ModeSSB: s.hilbert = dsp.NewHilbertFilter(ssbHilbertTaps) s.monoDelay = newSampleDelay((ssbHilbertTaps - 1) / 2) case ModeVSB: s.hilbert = dsp.NewHilbertFilter(ssbHilbertTaps) s.hilbertHi = dsp.NewHilbertFilter(ssbHilbertTaps) s.vsbLPF = dsp.NewLPF4(200, sampleRate) s.monoDelay = newSampleDelay((ssbHilbertTaps - 1) / 2) } } // Encode converts a stereo frame into MPX components. func (s *StereoEncoder) Encode(frame audio.Frame) Components { pilotPhase := s.pilot.Phase() s.lastPhase = pilotPhase pilot := s.pilot.Sample() sub38sin := math.Sin(2 * math.Pi * 2 * pilotPhase) sub38cos := math.Cos(2 * math.Pi * 2 * pilotPhase) diff := float64(frame.Difference()) mono := float64(frame.Mono()) monoOut := mono var stereoOut float64 switch s.mode { case ModeSSB: if s.hilbert == nil { stereoOut = diff * sub38sin break } monoOut = s.monoDelay.Process(mono) // SSB-LSB: s(t) = m_delayed(t)·sin(ωt) - m̂(t)·cos(ωt) // The - sign selects LSB (below 38 kHz). // ×2 compensates for the removed upper sideband (+6 dB). delayedDiff, hilb := s.hilbert.Process(diff) stereoOut = 2 * (delayedDiff*sub38sin - hilb*sub38cos) case ModeVSB: if s.hilbertHi == nil || s.vsbLPF == nil { stereoOut = diff * sub38sin break } monoOut = s.monoDelay.Process(mono) // Experimental VSB split: // - 0..200 Hz remains DSB to avoid aggressive low-frequency image shift. // - Above 200 Hz the residual is encoded as SSB-LSB. // The critical reverb bug was the mono-vs-diff timing mismatch; that is // fixed here by delaying mono by the Hilbert group delay. lo := s.vsbLPF.Process(diff) hi := diff - lo dsbPart := lo * sub38sin delayedHi, hilbHi := s.hilbertHi.Process(hi) ssbPart := 2 * (delayedHi*sub38sin - hilbHi*sub38cos) stereoOut = dsbPart + ssbPart default: // ModeDSB stereoOut = diff * sub38sin } return Components{ Mono: monoOut, Stereo: stereoOut, Pilot: pilot, } } // Reset restarts the pilot generator and clears filter state. func (s *StereoEncoder) Reset() { s.pilot.Reset() if s.hilbert != nil { s.hilbert.Reset() } if s.hilbertHi != nil { s.hilbertHi.Reset() } if s.vsbLPF != nil { s.vsbLPF.Reset() } if s.monoDelay != nil { s.monoDelay.Reset() } } // PilotPhase returns the pilot phase used in the most recent Encode() call. func (s *StereoEncoder) PilotPhase() float64 { return s.lastPhase } // RDSCarrier returns sin(3 * pilotPhase * 2π) — the 57 kHz carrier // phase-locked to the pilot, as required by the RDS standard. func (s *StereoEncoder) RDSCarrier() float64 { return math.Sin(2 * math.Pi * 3 * s.lastPhase) }