Explorar el Código

stereo: add SC-SSB and VSB stereo encoder modes

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
Jan hace 1 mes
padre
commit
647788d46a
Se han modificado 9 ficheros con 306 adiciones y 22 borrados
  1. +1
    -0
      cmd/fmrtx/main.go
  2. +4
    -0
      internal/app/engine.go
  3. +2
    -0
      internal/config/config.go
  4. +8
    -1
      internal/control/control.go
  5. +8
    -1
      internal/control/ui.html
  6. +83
    -0
      internal/dsp/hilbert.go
  7. +7
    -0
      internal/offline/generator.go
  8. +114
    -20
      internal/stereo/encoder.go
  9. +79
    -0
      internal/stereo/ssb_test.go

+ 1
- 0
cmd/fmrtx/main.go Ver fichero

@@ -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,


+ 4
- 0
internal/app/engine.go Ver fichero

@@ -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
}


+ 2
- 0
internal/config/config.go Ver fichero

@@ -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,


+ 8
- 1
internal/control/control.go Ver fichero

@@ -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 ||


+ 8
- 1
internal/control/ui.html Ver fichero

@@ -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


+ 83
- 0
internal/dsp/hilbert.go Ver fichero

@@ -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
}

+ 7
- 0
internal/offline/generator.go Ver fichero

@@ -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,


+ 114
- 20
internal/stereo/encoder.go Ver fichero

@@ -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)
}

+ 79
- 0
internal/stereo/ssb_test.go Ver fichero

@@ -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)
}
}
}
}

Cargando…
Cancelar
Guardar