From e11aa95d8b7e4e9e66fc8d89c1ecd42721014795 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Wed, 18 Mar 2026 07:21:48 +0100 Subject: [PATCH] Improve RDS decode with block sync and RT --- internal/rds/rds.go | 156 +++++++++++++++++++++++++++---------- internal/recorder/demod.go | 3 + 2 files changed, 116 insertions(+), 43 deletions(-) diff --git a/internal/rds/rds.go b/internal/rds/rds.go index cb680be..70a4d75 100644 --- a/internal/rds/rds.go +++ b/internal/rds/rds.go @@ -1,21 +1,22 @@ package rds -import ( - "math" -) +import "math" // Decoder performs a simple RDS baseband decode (BPSK, 1187.5 bps). type Decoder struct { - lastPS string + ps [8]rune + rt [64]rune lastPI uint16 } type Result struct { PI uint16 `json:"pi"` PS string `json:"ps"` + RT string `json:"rt"` } -// Decode takes baseband samples at ~2400 Hz and attempts to extract PI/PS. +// Decode takes baseband samples at ~2400 Hz and attempts to extract PI/PS/RT. +// NOTE: lightweight decoder with CRC+block sync; not a full RDS implementation. func (d *Decoder) Decode(base []float32, sampleRate int) Result { if len(base) == 0 || sampleRate <= 0 { return Result{} @@ -23,69 +24,138 @@ func (d *Decoder) Decode(base []float32, sampleRate int) 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 { + if base[i] >= 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 len(bits) < 26*4 { + return Result{PI: d.lastPI, PS: d.psString(), RT: d.rtString()} } - if ps != "" { - d.lastPS = ps + // search for block sync + for i := 0; i+26*4 <= len(bits); i++ { + bA, okA := decodeBlock(bits[i : i+26]) + bB, okB := decodeBlock(bits[i+26 : i+52]) + bC, okC := decodeBlock(bits[i+52 : i+78]) + bD, okD := decodeBlock(bits[i+78 : i+104]) + if !(okA && okB && okC && okD) { + continue + } + if bA.offset != offA || bB.offset != offB || bC.offset != offC || bD.offset != offD { + continue + } + pi := bA.data + if pi != 0 { + d.lastPI = pi + } + groupType := (bB.data >> 12) & 0xF + versionA := ((bB.data >> 11) & 0x1) == 0 + if groupType == 0 && versionA { + addr := bB.data & 0x3 + chars := []byte{byte(bD.data >> 8), byte(bD.data & 0xFF)} + idx := int(addr) * 2 + if idx+1 < len(d.ps) { + d.ps[idx] = sanitizeRune(chars[0]) + d.ps[idx+1] = sanitizeRune(chars[1]) + } + } + if groupType == 2 && versionA { + addr := bB.data & 0xF + chars := []byte{byte(bC.data >> 8), byte(bC.data & 0xFF), byte(bD.data >> 8), byte(bD.data & 0xFF)} + idx := int(addr) * 4 + for j := 0; j < 4 && idx+j < len(d.rt); j++ { + d.rt[idx+j] = sanitizeRune(chars[j]) + } + } + break } - return Result{PI: d.lastPI, PS: d.lastPS} + return Result{PI: d.lastPI, PS: d.psString(), RT: d.rtString()} +} + +type block struct { + data uint16 + offset uint16 } -func bitsToU16(bits []int) uint16 { - var v uint16 +const ( + offA uint16 = 0x0FC + offB uint16 = 0x198 + offC uint16 = 0x168 + offD uint16 = 0x1B4 +) + +func decodeBlock(bits []int) (block, bool) { + if len(bits) != 26 { + return block{}, false + } + var raw uint32 for _, b := range bits { - v = (v << 1) | uint16(b&1) + raw = (raw << 1) | uint32(b&1) + } + data := uint16(raw >> 10) + synd := crcSyndrome(raw) + if synd == 0 { + return block{}, false } - return v + // use syndrome as offset word + return block{data: data, offset: uint16(synd)}, true } -func decodePS(bits []int) string { - // naive: take next 64 bits as 8 ASCII chars - if len(bits) < 16+64 { - return "" +func crcSyndrome(raw uint32) uint16 { + // polynomial 0x1B9 (10-bit) + var reg uint32 = raw + poly := uint32(0x1B9) + for i := 25; i >= 10; i-- { + if (reg>>uint(i))&1 == 1 { + reg ^= poly << uint(i-10) + } } - 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) + return uint16(reg & 0x3FF) +} + +func sanitizeRune(b byte) rune { + if b < 32 || b > 126 { + return ' ' + } + return rune(b) +} + +func (d *Decoder) psString() string { + out := make([]rune, 0, len(d.ps)) + for _, r := range d.ps { + if r == 0 { + r = ' ' } - if c < 32 || c > 126 { - c = ' ' + out = append(out, r) + } + return trimRight(out) +} + +func (d *Decoder) rtString() string { + out := make([]rune, 0, len(d.rt)) + for _, r := range d.rt { + if r == 0 { + r = ' ' } - out = append(out, rune(c)) + out = append(out, r) } - // trim - for len(out) > 0 && out[len(out)-1] == ' ' { - out = out[:len(out)-1] + return trimRight(out) +} + +func trimRight(in []rune) string { + end := len(in) + for end > 0 && in[end-1] == ' ' { + end-- } - return string(out) + return string(in[:end]) } // BPSKCostas returns a simple carrier-locked version of baseband (placeholder). diff --git a/internal/recorder/demod.go b/internal/recorder/demod.go index 7d5dd46..016a967 100644 --- a/internal/recorder/demod.go +++ b/internal/recorder/demod.go @@ -61,6 +61,9 @@ func (m *Manager) demodAndWrite(dir string, ev detector.Event, iq []complex64, f if res.PS != "" { files["rds_ps"] = res.PS } + if res.RT != "" { + files["rds_rt"] = res.RT + } } } return nil