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;i
v==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)
+ }
+ }
+ }
+}