From 6ef106577c17b2489a51d28e4461fbc810bfe460 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Wed, 18 Mar 2026 07:04:47 +0100 Subject: [PATCH] Add WFM stereo decode and RDS baseband output --- internal/demod/am.go | 1 + internal/demod/cw.go | 1 + internal/demod/demod.go | 1 + internal/demod/fm.go | 78 +++++++++++++++++++++++++++++++++- internal/demod/ssb.go | 2 + internal/dsp/fir_real.go | 21 +++++++++ internal/recorder/demod.go | 11 ++++- internal/recorder/wavwriter.go | 11 +++-- 8 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 internal/dsp/fir_real.go diff --git a/internal/demod/am.go b/internal/demod/am.go index dcdc26f..f70d239 100644 --- a/internal/demod/am.go +++ b/internal/demod/am.go @@ -6,6 +6,7 @@ type AM struct{} func (AM) Name() string { return "AM" } func (AM) OutputSampleRate() int { return 48000 } +func (AM) Channels() int { return 1 } func (AM) Demod(iq []complex64, sampleRate int) []float32 { if len(iq) == 0 { diff --git a/internal/demod/cw.go b/internal/demod/cw.go index 55c6970..b47b119 100644 --- a/internal/demod/cw.go +++ b/internal/demod/cw.go @@ -6,6 +6,7 @@ type CW struct{} func (CW) Name() string { return "CW" } func (CW) OutputSampleRate() int { return 48000 } +func (CW) Channels() int { return 1 } func (CW) Demod(iq []complex64, sampleRate int) []float32 { if len(iq) == 0 { diff --git a/internal/demod/demod.go b/internal/demod/demod.go index ad11034..fd7f4a4 100644 --- a/internal/demod/demod.go +++ b/internal/demod/demod.go @@ -4,6 +4,7 @@ type Demodulator interface { Name() string Demod(iq []complex64, sampleRate int) []float32 OutputSampleRate() int + Channels() int } var registry = map[string]Demodulator{} diff --git a/internal/demod/fm.go b/internal/demod/fm.go index 23bbe7d..577291e 100644 --- a/internal/demod/fm.go +++ b/internal/demod/fm.go @@ -1,15 +1,28 @@ package demod -import "math" +import ( + "math" + + "sdr-visual-suite/internal/dsp" +) type NFM struct{} type WFM struct{} +type WFMStereo struct{} + func (NFM) Name() string { return "NFM" } func (WFM) Name() string { return "WFM" } +func (WFMStereo) Name() string { return "WFM_STEREO" } func (NFM) OutputSampleRate() int { return 48000 } func (WFM) OutputSampleRate() int { return 192000 } +func (WFMStereo) OutputSampleRate() int { + return 192000 +} +func (NFM) Channels() int { return 1 } +func (WFM) Channels() int { return 1 } +func (WFMStereo) Channels() int { return 2 } func (NFM) Demod(iq []complex64, sampleRate int) []float32 { return fmDiscrim(iq) @@ -19,6 +32,10 @@ func (WFM) Demod(iq []complex64, sampleRate int) []float32 { return fmDiscrim(iq) } +func (WFMStereo) Demod(iq []complex64, sampleRate int) []float32 { + return wfmStereo(iq, sampleRate) +} + func fmDiscrim(iq []complex64) []float32 { if len(iq) < 2 { return nil @@ -34,7 +51,66 @@ func fmDiscrim(iq []complex64) []float32 { return out } +func wfmStereo(iq []complex64, sampleRate int) []float32 { + base := fmDiscrim(iq) + if len(base) == 0 { + return nil + } + lp := dsp.LowpassFIR(15000, sampleRate, 101) + lpr := dsp.ApplyFIRReal(base, lp) + bpHi := dsp.LowpassFIR(53000, sampleRate, 101) + bpLo := dsp.LowpassFIR(23000, sampleRate, 101) + hi := dsp.ApplyFIRReal(base, bpHi) + lo := dsp.ApplyFIRReal(base, bpLo) + bpf := make([]float32, len(base)) + for i := range base { + bpf[i] = hi[i] - lo[i] + } + lr := make([]float32, len(base)) + phase := 0.0 + inc := 2 * math.Pi * 38000 / float64(sampleRate) + for i := range bpf { + phase += inc + lr[i] = bpf[i] * float32(2*math.Cos(phase)) + } + lr = dsp.ApplyFIRReal(lr, lp) + out := make([]float32, len(lpr)*2) + for i := range lpr { + l := 0.5 * (lpr[i] + lr[i]) + r := 0.5 * (lpr[i] - lr[i]) + out[i*2] = l + out[i*2+1] = r + } + return out +} + +// RDSBaseband returns a rough 57k baseband (not decoded). +func RDSBaseband(iq []complex64, sampleRate int) []float32 { + base := fmDiscrim(iq) + if len(base) == 0 { + return nil + } + bpHi := dsp.LowpassFIR(60000, sampleRate, 101) + bpLo := dsp.LowpassFIR(54000, sampleRate, 101) + hi := dsp.ApplyFIRReal(base, bpHi) + lo := dsp.ApplyFIRReal(base, bpLo) + bpf := make([]float32, len(base)) + for i := range base { + bpf[i] = hi[i] - lo[i] + } + phase := 0.0 + inc := 2 * math.Pi * 57000 / float64(sampleRate) + out := make([]float32, len(base)) + for i := range bpf { + phase += inc + out[i] = bpf[i] * float32(math.Cos(phase)) + } + lp := dsp.LowpassFIR(2400, sampleRate, 101) + return dsp.ApplyFIRReal(out, lp) +} + func init() { Register(NFM{}) Register(WFM{}) + Register(WFMStereo{}) } diff --git a/internal/demod/ssb.go b/internal/demod/ssb.go index d488021..9a4b2e7 100644 --- a/internal/demod/ssb.go +++ b/internal/demod/ssb.go @@ -10,6 +10,8 @@ func (USB) Name() string { return "USB" } func (LSB) Name() string { return "LSB" } func (USB) OutputSampleRate() int { return 48000 } func (LSB) OutputSampleRate() int { return 48000 } +func (USB) Channels() int { return 1 } +func (LSB) Channels() int { return 1 } func (USB) Demod(iq []complex64, sampleRate int) []float32 { return ssb(iq, sampleRate, true) } func (LSB) Demod(iq []complex64, sampleRate int) []float32 { return ssb(iq, sampleRate, false) } diff --git a/internal/dsp/fir_real.go b/internal/dsp/fir_real.go new file mode 100644 index 0000000..b156528 --- /dev/null +++ b/internal/dsp/fir_real.go @@ -0,0 +1,21 @@ +package dsp + +// ApplyFIRReal applies real FIR taps to real signal. +func ApplyFIRReal(x []float32, taps []float64) []float32 { + if len(x) == 0 || len(taps) == 0 { + return nil + } + out := make([]float32, len(x)) + for i := 0; i < len(x); i++ { + var acc float64 + for k := 0; k < len(taps); k++ { + idx := i - k + if idx < 0 { + break + } + acc += float64(x[idx]) * taps[k] + } + out[i] = float32(acc) + } + return out +} diff --git a/internal/recorder/demod.go b/internal/recorder/demod.go index be05c0e..190e398 100644 --- a/internal/recorder/demod.go +++ b/internal/recorder/demod.go @@ -39,12 +39,21 @@ func (m *Manager) demodAndWrite(dir string, ev detector.Event, iq []complex64, f dec := dsp.Decimate(filtered, decim) audio := d.Demod(dec, m.sampleRate/decim) wav := filepath.Join(dir, "audio.wav") - if err := writeWAV(wav, audio, d.OutputSampleRate()); err != nil { + if err := writeWAV(wav, audio, d.OutputSampleRate(), d.Channels()); err != nil { return err } files["audio"] = "audio.wav" files["audio_sample_rate"] = d.OutputSampleRate() + files["audio_channels"] = d.Channels() files["audio_demod"] = name + if name == "WFM_STEREO" { + if rds := demod.RDSBaseband(iq, m.sampleRate); len(rds) > 0 { + rdsPath := filepath.Join(dir, "rds.wav") + _ = writeWAV(rdsPath, rds, 2400, 1) + files["rds_baseband"] = "rds.wav" + files["rds_sample_rate"] = 2400 + } + } return nil } diff --git a/internal/recorder/wavwriter.go b/internal/recorder/wavwriter.go index b74be89..696faf8 100644 --- a/internal/recorder/wavwriter.go +++ b/internal/recorder/wavwriter.go @@ -5,13 +5,16 @@ import ( "os" ) -func writeWAV(path string, samples []float32, sampleRate int) error { +func writeWAV(path string, samples []float32, sampleRate int, channels int) error { f, err := os.Create(path) if err != nil { return err } defer f.Close() + if channels <= 0 { + channels = 1 + } // 16-bit PCM dataSize := uint32(len(samples) * 2) // RIFF header @@ -34,17 +37,17 @@ func writeWAV(path string, samples []float32, sampleRate int) error { if err := binary.Write(f, binary.LittleEndian, uint16(1)); err != nil { // PCM return err } - if err := binary.Write(f, binary.LittleEndian, uint16(1)); err != nil { // mono + if err := binary.Write(f, binary.LittleEndian, uint16(channels)); err != nil { return err } if err := binary.Write(f, binary.LittleEndian, uint32(sampleRate)); err != nil { return err } - byteRate := uint32(sampleRate * 2) + byteRate := uint32(sampleRate * channels * 2) if err := binary.Write(f, binary.LittleEndian, byteRate); err != nil { return err } - if err := binary.Write(f, binary.LittleEndian, uint16(2)); err != nil { // block align + if err := binary.Write(f, binary.LittleEndian, uint16(channels*2)); err != nil { return err } if err := binary.Write(f, binary.LittleEndian, uint16(16)); err != nil { // bits