diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index dfdc990..36fa2d2 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -364,6 +364,7 @@ func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error { FrequencyMHz: lp.FrequencyMHz, OutputDrive: lp.OutputDrive, StereoEnabled: lp.StereoEnabled, + StereoMode: lp.StereoMode, PilotLevel: lp.PilotLevel, RDSInjection: lp.RDSInjection, RDSEnabled: lp.RDSEnabled, diff --git a/internal/app/engine.go b/internal/app/engine.go index e9d2820..7583bc6 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -282,6 +282,7 @@ type LiveConfigUpdate struct { FrequencyMHz *float64 OutputDrive *float64 StereoEnabled *bool + StereoMode *string PilotLevel *float64 RDSInjection *float64 RDSEnabled *bool @@ -370,6 +371,9 @@ func (e *Engine) UpdateConfig(u LiveConfigUpdate) error { if u.StereoEnabled != nil { next.StereoEnabled = *u.StereoEnabled } + if u.StereoMode != nil { + next.StereoMode = *u.StereoMode + } if u.PilotLevel != nil { next.PilotLevel = *u.PilotLevel } diff --git a/internal/config/config.go b/internal/config/config.go index cdf3d1d..5582f81 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,6 +38,7 @@ type RDSConfig struct { type FMConfig struct { FrequencyMHz float64 `json:"frequencyMHz"` StereoEnabled bool `json:"stereoEnabled"` + StereoMode string `json:"stereoMode"` // "DSB" (standard), "SSB" (experimental LSB), "VSB" (vestigial) PilotLevel float64 `json:"pilotLevel"` // fraction of ±75kHz deviation (0.09 = 9%, ITU standard) RDSInjection float64 `json:"rdsInjection"` // fraction of ±75kHz deviation (0.04 = 4%, typical) PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off @@ -159,6 +160,7 @@ func Default() Config { FM: FMConfig{ FrequencyMHz: 100.0, StereoEnabled: true, + StereoMode: "DSB", PilotLevel: 0.09, RDSInjection: 0.04, PreEmphasisTauUS: 50, diff --git a/internal/control/control.go b/internal/control/control.go index 4b84a97..e5fabb7 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -38,6 +38,7 @@ type LivePatch struct { FrequencyMHz *float64 OutputDrive *float64 StereoEnabled *bool + StereoMode *string PilotLevel *float64 RDSInjection *float64 RDSEnabled *bool @@ -123,6 +124,7 @@ type ConfigPatch struct { FrequencyMHz *float64 `json:"frequencyMHz,omitempty"` OutputDrive *float64 `json:"outputDrive,omitempty"` StereoEnabled *bool `json:"stereoEnabled,omitempty"` + StereoMode *string `json:"stereoMode,omitempty"` PilotLevel *float64 `json:"pilotLevel,omitempty"` RDSInjection *float64 `json:"rdsInjection,omitempty"` RDSEnabled *bool `json:"rdsEnabled,omitempty"` @@ -301,6 +303,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) { "backend": cfg.Backend.Kind, "frequencyMHz": cfg.FM.FrequencyMHz, "stereoEnabled": cfg.FM.StereoEnabled, + "stereoMode": cfg.FM.StereoMode, "rdsEnabled": cfg.RDS.Enabled, "preEmphasisTauUS": cfg.FM.PreEmphasisTauUS, "limiterEnabled": cfg.FM.LimiterEnabled, @@ -573,6 +576,9 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { if patch.StereoEnabled != nil { next.FM.StereoEnabled = *patch.StereoEnabled } + if patch.StereoMode != nil { + next.FM.StereoMode = *patch.StereoMode + } if patch.LimiterEnabled != nil { next.FM.LimiterEnabled = *patch.LimiterEnabled } @@ -618,6 +624,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { FrequencyMHz: patch.FrequencyMHz, OutputDrive: patch.OutputDrive, StereoEnabled: patch.StereoEnabled, + StereoMode: patch.StereoMode, PilotLevel: patch.PilotLevel, RDSInjection: patch.RDSInjection, RDSEnabled: patch.RDSEnabled, @@ -636,7 +643,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { // s.mu across a potentially blocking engine call. tx := s.tx hasLiveFields := patch.FrequencyMHz != nil || patch.OutputDrive != nil || - patch.StereoEnabled != nil || patch.PilotLevel != nil || + patch.StereoEnabled != nil || patch.StereoMode != nil || patch.PilotLevel != nil || patch.RDSInjection != nil || patch.RDSEnabled != nil || patch.LimiterEnabled != nil || patch.LimiterCeiling != nil || patch.PS != nil || patch.RadioText != nil || diff --git a/internal/control/ui.html b/internal/control/ui.html index e9db2b7..a233302 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -399,6 +399,10 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
Stereo
19 kHz pilot + 38 kHz DSB-SC
--
+
+
Stereo Mode
Subcarrier modulation
+
+
Limiter
MPX peak protection
--
@@ -764,7 +768,7 @@ function clone(o){return JSON.parse(JSON.stringify(o??{}));} function gp(o,path){const ps=String(path||'').split('.');let c=o;for(const p of ps){if(!p)continue;if(c==null||typeof c!=='object')return undefined;c=c[p];}return c;} function sp(o,path,v){const ps=String(path||'').split('.');let c=o;for(let i=0;iv==null?'--':Number(v).toFixed(1)); @@ -1065,6 +1070,8 @@ function bindAll(){ document.querySelectorAll('[data-panel]').forEach(h=>h.addEventListener('click',()=>{h.classList.toggle('collapsed');h.nextElementSibling?.classList.toggle('collapsed');})); // Live toggles document.querySelectorAll('.toggle[data-toggle]').forEach(tog=>{const key=tog.dataset.toggle;const handler=()=>setToggle(key,!srvVal(key));tog.addEventListener('click',handler);tog.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();handler();}});}); + // Stereo mode select (live) + $('sel-stereo-mode')?.addEventListener('change',e=>sendPatch({stereoMode:e.target.value},{ok:'Stereo mode: '+e.target.value})); // Config-only toggles (restart-required) document.querySelectorAll('.toggle[data-toggle-cfg]').forEach(tog=>{const key=tog.dataset.toggleCfg;const handler=()=>cfgSetDirty(key,!cfgEff(key));tog.addEventListener('click',handler);tog.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();handler();}});}); // Freq diff --git a/internal/dsp/hilbert.go b/internal/dsp/hilbert.go new file mode 100644 index 0000000..a29ff46 --- /dev/null +++ b/internal/dsp/hilbert.go @@ -0,0 +1,83 @@ +package dsp + +import "math" + +// HilbertFilter implements a FIR-based Hilbert transform with matched delay. +// It produces the analytic signal's imaginary part (90° phase shift of all +// frequency components) while also providing the appropriately delayed +// version of the original signal. +// +// Used for SSB-SC stereo encoding (Foti/Tarsio): the L-R difference signal +// is split into in-phase (delayed) and quadrature (Hilbert-transformed) +// components for single-sideband modulation. +type HilbertFilter struct { + coeffs []float64 // FIR coefficients (length N, odd) + delay []float64 // circular buffer + pos int // write position in buffer + n int // filter length + mid int // group delay = (N-1)/2 +} + +// NewHilbertFilter creates a Hilbert transform FIR filter. +// taps must be odd (typical: 127 for 228 kHz). Higher = better low-freq response. +func NewHilbertFilter(taps int) *HilbertFilter { + if taps%2 == 0 { + taps++ // must be odd + } + mid := (taps - 1) / 2 + coeffs := make([]float64, taps) + + // Hilbert FIR: h[n] = 2/(π·(n-mid)) for odd (n-mid), 0 for even, Hann windowed. + for i := 0; i < taps; i++ { + k := i - mid + if k == 0 { + coeffs[i] = 0 // center tap is always zero + } else if k%2 != 0 { + // Odd offset: ideal Hilbert coefficient × Hann window + window := 0.5 * (1.0 - math.Cos(2*math.Pi*float64(i)/float64(taps))) + coeffs[i] = (2.0 / (math.Pi * float64(k))) * window + } + // Even offset: coefficient is zero (already initialized) + } + + return &HilbertFilter{ + coeffs: coeffs, + delay: make([]float64, taps), + pos: 0, + n: taps, + mid: mid, + } +} + +// Process takes one input sample and returns (delayed, hilbert). +// - delayed: input delayed by (N-1)/2 samples (group delay matched) +// - hilbert: 90° phase-shifted version of the input +// +// Both outputs are time-aligned: delayed[n] corresponds to hilbert[n]. +func (h *HilbertFilter) Process(in float64) (delayed, hilbert float64) { + // Write new sample + h.delay[h.pos] = in + + // Delayed output: sample from (N-1)/2 ago + delayIdx := (h.pos - h.mid + h.n) % h.n + delayed = h.delay[delayIdx] + + // Hilbert output: FIR convolution + var sum float64 + for i := 0; i < h.n; i++ { + idx := (h.pos - i + h.n) % h.n + sum += h.delay[idx] * h.coeffs[i] + } + hilbert = sum + + h.pos = (h.pos + 1) % h.n + return +} + +// Reset clears the filter state. +func (h *HilbertFilter) Reset() { + for i := range h.delay { + h.delay[i] = 0 + } + h.pos = 0 +} diff --git a/internal/offline/generator.go b/internal/offline/generator.go index 7b16f9a..ef58507 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -29,6 +29,7 @@ type frameSource interface { type LiveParams struct { OutputDrive float64 StereoEnabled bool + StereoMode string PilotLevel float64 RDSInjection float64 RDSEnabled bool @@ -164,6 +165,10 @@ func (g *Generator) SetExternalSource(src frameSource) error { // UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API, // applied at the next chunk boundary by the DSP goroutine. func (g *Generator) UpdateLive(p LiveParams) { + // Detect stereo mode change: requires reconfiguring the encoder's Hilbert filter. + if old := g.liveParams.Load(); old != nil && old.StereoMode != p.StereoMode { + g.stereoEncoder.SetMode(stereo.ParseMode(p.StereoMode), g.sampleRate) + } g.liveParams.Store(&p) } @@ -193,6 +198,7 @@ func (g *Generator) init() { rawSource, _ := g.sourceFor(g.sampleRate) g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain) g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate) + g.stereoEncoder.SetMode(stereo.ParseMode(g.cfg.FM.StereoMode), g.sampleRate) g.combiner = mpx.DefaultCombiner{ MonoGain: 1.0, StereoGain: 1.0, PilotGain: g.cfg.FM.PilotLevel, RDSGain: g.cfg.FM.RDSInjection, @@ -262,6 +268,7 @@ func (g *Generator) init() { g.liveParams.Store(&LiveParams{ OutputDrive: g.cfg.FM.OutputDrive, StereoEnabled: g.cfg.FM.StereoEnabled, + StereoMode: g.cfg.FM.StereoMode, PilotLevel: g.cfg.FM.PilotLevel, RDSInjection: g.cfg.FM.RDSInjection, RDSEnabled: g.cfg.RDS.Enabled, diff --git a/internal/stereo/encoder.go b/internal/stereo/encoder.go index e74e0b0..8e00625 100644 --- a/internal/stereo/encoder.go +++ b/internal/stereo/encoder.go @@ -7,67 +7,161 @@ import ( "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 * sin(2 * pilotPhase), unity subcarrier + 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. -// The 38 kHz subcarrier is derived from the pilot phase (2× multiplication), -// guaranteeing perfect phase coherence as required by the FM stereo standard. +// Supports DSB-SC (standard), SSB-SC (lower sideband only), and VSB modes. type StereoEncoder struct { pilot dsp.PilotGenerator - lastPhase float64 // phase captured in last Encode(), for coherent RDS carrier + 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. -// The 38 kHz subcarrier is sin(2*pilotPhase), derived directly from the pilot -// oscillator's phase — not from a separate oscillator. func (s *StereoEncoder) Encode(frame audio.Frame) Components { - // Capture phase BEFORE advancing — the 38 kHz subcarrier must use the - // same phase instant as the pilot sample to maintain coherence. pilotPhase := s.pilot.Phase() s.lastPhase = pilotPhase - pilot := s.pilot.Sample() // sin(2π * 19000 * t), then advances phase + 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) - // 38 kHz subcarrier = sin(2 * pilotPhase * 2π) = sin(4π * 19000 * t) - // This is mathematically identical to sin(2π * 38000 * t) but guaranteed - // phase-locked to the pilot. FM receivers PLL onto the pilot and derive - // the 38 kHz reference this exact same way. - sub38 := math.Sin(2 * math.Pi * 2 * pilotPhase) + // 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: float64(frame.Mono()), - Stereo: float64(frame.Difference()) * sub38, + Mono: mono, + Stereo: stereoOut, Pilot: pilot, } } -// Reset restarts the pilot generator. +// 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. -// This is the coherent phase instant for deriving subcarriers (38 kHz = 2×, 57 kHz = 3×). 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. -// Uses the phase captured in the most recent Encode() call so that -// pilot, 38 kHz subcarrier, and 57 kHz RDS carrier are all coherent. func (s *StereoEncoder) RDSCarrier() float64 { return math.Sin(2 * math.Pi * 3 * s.lastPhase) } diff --git a/internal/stereo/ssb_test.go b/internal/stereo/ssb_test.go new file mode 100644 index 0000000..a73cf05 --- /dev/null +++ b/internal/stereo/ssb_test.go @@ -0,0 +1,79 @@ +package stereo + +import ( + "math" + "math/cmplx" + "testing" + + "github.com/jan/fm-rds-tx/internal/audio" +) + +func TestSSBSidebandSuppression(t *testing.T) { + const sampleRate = 228000 + const testFreq = 5000.0 // 5 kHz test tone + const nSamples = 228000 // 1 second + + for _, mode := range []Mode{ModeDSB, ModeSSB, ModeVSB} { + enc := NewStereoEncoder(sampleRate) + enc.SetMode(mode, sampleRate) + + // Generate 1 second of stereo: L=tone, R=-tone → mono=0, diff=tone + composite := make([]float64, nSamples) + for i := 0; i < nSamples; i++ { + phase := 2 * math.Pi * testFreq * float64(i) / sampleRate + l := audio.Sample(0.5 * math.Sin(phase)) + r := audio.Sample(-0.5 * math.Sin(phase)) + c := enc.Encode(audio.NewFrame(l, r)) + composite[i] = c.Mono + c.Stereo + } + + // FFT to check spectrum + n := len(composite) + buf := make([]complex128, n) + for i := range buf { + buf[i] = complex(composite[i], 0) + } + // Simple DFT at key frequencies (not full FFT, just spot-check) + freqBin := func(hz float64) float64 { + bin := int(hz * float64(n) / sampleRate) + if bin >= n/2 { + return 0 + } + var sum complex128 + for i := 0; i < n; i++ { + angle := -2 * math.Pi * float64(bin) * float64(i) / float64(n) + sum += complex(composite[i], 0) * cmplx.Rect(1, angle) + } + return cmplx.Abs(sum) / float64(n) + } + + lsb := freqBin(38000 - testFreq) // 33 kHz — lower sideband + usb := freqBin(38000 + testFreq) // 43 kHz — upper sideband + + suppression := 0.0 + if usb > 0 && lsb > 0 { + suppression = 20 * math.Log10(usb/lsb) + } + + t.Logf("Mode %s: LSB(%.0fHz)=%.4f USB(%.0fHz)=%.4f suppression=%.1f dB", + mode, 38000-testFreq, lsb, 38000+testFreq, usb, suppression) + + switch mode { + case ModeDSB: + // Both sidebands should be roughly equal + if math.Abs(suppression) > 3 { + t.Errorf("DSB: sidebands should be balanced, got %.1f dB", suppression) + } + case ModeSSB: + // USB should be suppressed by >20 dB + if suppression > -20 { + t.Errorf("SSB: USB suppression only %.1f dB (want >20 dB)", suppression) + } + case ModeVSB: + // 5 kHz tone is above 200 Hz crossover → should behave like SSB + if suppression > -15 { + t.Errorf("VSB: USB suppression only %.1f dB at %0.f Hz (want >15 dB)", suppression, testFreq) + } + } + } +}