package stereo import ( "math" "github.com/jan/fm-rds-tx/internal/audio" "github.com/jan/fm-rds-tx/internal/dsp" ) // 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; Omnia-style) ) // 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 } // 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: Hilbert transform for quadrature modulation hilbert *dsp.HilbertFilter // VSB: crossover filter splits L-R into low (<200Hz, DSB) and high (>200Hz, SSB) vsbLPF *dsp.FilterChain // 200 Hz LPF for VSB low band vsbHPF *dsp.FilterChain // 200 Hz HPF for VSB high band (derived from allpass - LPF) hilbertHi *dsp.HilbertFilter // separate Hilbert for high band } // 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. Creates Hilbert filter if needed. func (s *StereoEncoder) SetMode(mode Mode, sampleRate float64) { s.mode = mode if mode == ModeSSB || mode == ModeVSB { // 127-tap Hilbert FIR at 228kHz: group delay = 63 samples = 0.276ms s.hilbert = dsp.NewHilbertFilter(127) } if mode == ModeVSB { // Crossover at 200 Hz for vestigial sideband s.vsbLPF = dsp.NewLPF4(200, sampleRate) s.vsbHPF = dsp.NewLPF4(200, sampleRate) // we'll subtract to get HPF s.hilbertHi = dsp.NewHilbertFilter(127) } } // 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()) var stereoOut float64 switch s.mode { case ModeSSB: if s.hilbert == nil { // Fallback to DSB if not initialized stereoOut = diff * sub38sin break } // SSB-LSB: s(t) = m_delayed(t)·sin(ωt) - m̂(t)·cos(ωt) // The - sign selects LSB (below 38 kHz). // ×2 compensates for removed USB (+6 dB). delayed, hilb := s.hilbert.Process(diff) stereoOut = 2 * (delayed*sub38sin - hilb*sub38cos) case ModeVSB: if s.hilbert == nil || s.vsbLPF == nil { stereoOut = diff * sub38sin break } // VSB: 0-200Hz → DSB, 200Hz+ → SSB-LSB lo := s.vsbLPF.Process(diff) hiRef := s.vsbHPF.Process(diff) // same LPF for HPF derivation hi := diff - hiRef // highpass = original - lowpass (requires matching delay, approximate) // Low band: standard DSB dsbPart := lo * sub38sin // High band: SSB-LSB with Hilbert delayed, hilb := s.hilbertHi.Process(hi) ssbPart := 2 * (delayed*sub38sin - hilb*sub38cos) stereoOut = dsbPart + ssbPart default: // ModeDSB stereoOut = diff * sub38sin } return Components{ Mono: mono, 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() } } // 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) }