diff --git a/internal/rds/rds.go b/internal/rds/rds.go new file mode 100644 index 0000000..cb680be --- /dev/null +++ b/internal/rds/rds.go @@ -0,0 +1,100 @@ +package rds + +import ( + "math" +) + +// Decoder performs a simple RDS baseband decode (BPSK, 1187.5 bps). +type Decoder struct { + lastPS string + lastPI uint16 +} + +type Result struct { + PI uint16 `json:"pi"` + PS string `json:"ps"` +} + +// Decode takes baseband samples at ~2400 Hz and attempts to extract PI/PS. +func (d *Decoder) Decode(base []float32, sampleRate int) Result { + if len(base) == 0 || sampleRate <= 0 { + return Result{} + } + // crude clock: 1187.5 bps + baud := 1187.5 + spb := float64(sampleRate) / baud + // carrier recovery simplified: assume baseband already mixed + bits := make([]int, 0, int(float64(len(base))/spb)) + phase := 0.0 + for i := 0; i < len(base); i++ { + phase += 1.0 + if phase >= spb { + phase -= spb + // slice decision + v := base[i] + if v >= 0 { + bits = append(bits, 1) + } else { + bits = append(bits, 0) + } + } + } + // parse groups (very naive): look for 16-bit blocks and decode group type 0A for PS + // This is a placeholder: real RDS needs CRC and block sync. + if len(bits) < 104 { + return Result{PI: d.lastPI, PS: d.lastPS} + } + // best effort: just map first 16 bits to PI and next 8 chars from consecutive bytes + pi := bitsToU16(bits[0:16]) + ps := decodePS(bits) + if pi != 0 { + d.lastPI = pi + } + if ps != "" { + d.lastPS = ps + } + return Result{PI: d.lastPI, PS: d.lastPS} +} + +func bitsToU16(bits []int) uint16 { + var v uint16 + for _, b := range bits { + v = (v << 1) | uint16(b&1) + } + return v +} + +func decodePS(bits []int) string { + // naive: take next 64 bits as 8 ASCII chars + if len(bits) < 16+64 { + return "" + } + start := 16 + out := make([]rune, 0, 8) + for i := 0; i < 8; i++ { + var c byte + for j := 0; j < 8; j++ { + c = (c << 1) | byte(bits[start+i*8+j]&1) + } + if c < 32 || c > 126 { + c = ' ' + } + out = append(out, rune(c)) + } + // trim + for len(out) > 0 && out[len(out)-1] == ' ' { + out = out[:len(out)-1] + } + return string(out) +} + +// BPSKCostas returns a simple carrier-locked version of baseband (placeholder). +func BPSKCostas(in []float32) []float32 { + out := make([]float32, len(in)) + var phase float64 + for i, v := range in { + phase += 0.0001 * float64(v) * math.Sin(phase) + out[i] = float32(float64(v) * math.Cos(phase)) + } + return out +} diff --git a/internal/recorder/demod.go b/internal/recorder/demod.go index 190e398..7d5dd46 100644 --- a/internal/recorder/demod.go +++ b/internal/recorder/demod.go @@ -52,6 +52,15 @@ func (m *Manager) demodAndWrite(dir string, ev detector.Event, iq []complex64, f _ = writeWAV(rdsPath, rds, 2400, 1) files["rds_baseband"] = "rds.wav" files["rds_sample_rate"] = 2400 + // naive decode + dec := rdsdecoder{} + res := dec.Decode(rds, 2400) + if res.PI != 0 { + files["rds_pi"] = res.PI + } + if res.PS != "" { + files["rds_ps"] = res.PS + } } } return nil diff --git a/internal/recorder/rds.go b/internal/recorder/rds.go new file mode 100644 index 0000000..0dd6851 --- /dev/null +++ b/internal/recorder/rds.go @@ -0,0 +1,5 @@ +package recorder + +import "sdr-visual-suite/internal/rds" + +type rdsdecoder struct{ rds.Decoder }