| @@ -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 { | |||
| @@ -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 { | |||
| @@ -4,6 +4,7 @@ type Demodulator interface { | |||
| Name() string | |||
| Demod(iq []complex64, sampleRate int) []float32 | |||
| OutputSampleRate() int | |||
| Channels() int | |||
| } | |||
| var registry = map[string]Demodulator{} | |||
| @@ -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{}) | |||
| } | |||
| @@ -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) } | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||