Add experimental single-sideband and vestigial-sideband stereo encoding modes alongside the standard DSB-SC stereo subcarrier path. - add encoder mode selection (DSB, SSB, VSB) - add Hilbert-transform based quadrature path for SSB generation - add vestigial split with low-band DSB and high-band SSB handling - wire the mode through config, control, engine, generator, and fmrtx - add test coverage for the SSB path The default mode remains the standard DSB stereo encoder.main
| @@ -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, | |||
| @@ -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 | |||
| } | |||
| @@ -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, | |||
| @@ -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 || | |||
| @@ -399,6 +399,10 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1 | |||
| <div class="toggle-copy"><div class="title">Stereo</div><div class="sub">19 kHz pilot + 38 kHz DSB-SC</div></div> | |||
| <div class="toggle-ctl"><div class="toggle" id="tog-stereo" data-toggle="stereoEnabled" role="switch" aria-checked="false" tabindex="0"></div><div class="toggle-state" id="stereo-label">--</div></div> | |||
| </div> | |||
| <div class="toggle-row"> | |||
| <div class="toggle-copy"><div class="title">Stereo Mode</div><div class="sub">Subcarrier modulation</div></div> | |||
| <div class="toggle-ctl"><select id="sel-stereo-mode" style="font-size:13px;padding:4px 8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-card);color:var(--fg)"><option value="DSB">DSB-SC (Standard)</option><option value="SSB">SSB-SC LSB</option><option value="VSB">VSB (Vestigial)</option></select></div> | |||
| </div> | |||
| <div class="toggle-row"> | |||
| <div class="toggle-copy"><div class="title">Limiter</div><div class="sub">MPX peak protection</div></div> | |||
| <div class="toggle-ctl"><div class="toggle" id="tog-limiter" data-toggle="limiterEnabled" role="switch" aria-checked="false" tabindex="0"></div><div class="toggle-state" id="limiter-label">--</div></div> | |||
| @@ -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<ps.length;i++){const p=ps[i];if(!p)continue;if(i===ps.length-1){c[p]=v;return;}if(!c[p]||typeof c[p]!=='object')c[p]={};c=c[p];}} | |||
| function srvVal(key){const cfg=S.server.config||{};switch(key){case 'frequencyMHz':return cfg.fm?.frequencyMHz;case 'ps':return cfg.rds?.ps??'';case 'radioText':return cfg.rds?.radioText??'';case 'stereoEnabled':return cfg.fm?.stereoEnabled;case 'rdsEnabled':return cfg.rds?.enabled;case 'limiterEnabled':return cfg.fm?.limiterEnabled;case 'compositeClipperEnabled':return cfg.fm?.compositeClipper?.enabled;}return undefined;} | |||
| function srvVal(key){const cfg=S.server.config||{};switch(key){case 'frequencyMHz':return cfg.fm?.frequencyMHz;case 'ps':return cfg.rds?.ps??'';case 'radioText':return cfg.rds?.radioText??'';case 'stereoEnabled':return cfg.fm?.stereoEnabled;case 'stereoMode':return cfg.fm?.stereoMode??'DSB';case 'rdsEnabled':return cfg.rds?.enabled;case 'limiterEnabled':return cfg.fm?.limiterEnabled;case 'compositeClipperEnabled':return cfg.fm?.compositeClipper?.enabled;}return undefined;} | |||
| function cfgSrvVal(key){const cfg=S.server.config||{};const f=CFG[key];return f?gp(cfg,f.path):undefined;} | |||
| function effVal(key){return S.dirty.has(key)?S.draft[key]:srvVal(key);} | |||
| function cfgEff(key){return S.cfgDraft[key]!==undefined?S.cfgDraft[key]:cfgSrvVal(key);} | |||
| @@ -956,6 +960,7 @@ function _render(){ | |||
| const tr=$('tone-r-slider'),trn=$('tone-r-num');if(tr&&document.activeElement!==tr)tr.value=String(cfgEff('toneRightHz')??1000);if(trn&&document.activeElement!==trn)trn.value=String(cfgEff('toneRightHz')??1000); | |||
| // Switches | |||
| syncToggle('tog-stereo','stereo-label','stereoEnabled');syncToggle('tog-limiter','limiter-label','limiterEnabled'); | |||
| const selMode=document.getElementById('sel-stereo-mode');if(selMode){const m=srvVal('stereoMode');if(m)selMode.value=m;} | |||
| // Compliance | |||
| syncCfgToggle('tog-bs412','bs412-label','bs412Enabled'); | |||
| syncSlider('bs412-threshold-slider','bs412-threshold-val','bs412ThresholdDBr',v=>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 | |||
| @@ -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 | |||
| } | |||
| @@ -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, | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| } | |||
| } | |||