package rds import ( "fmt" "math" ) // Decoder performs RDS baseband decode with Costas loop carrier recovery // and Mueller & Muller symbol timing synchronization. type Decoder struct { ps [8]rune rt [64]rune lastPI uint16 // Costas loop state (persistent across calls) costasPhase float64 costasFreq float64 // Symbol sync state syncMu float64 // Diagnostic counters TotalDecodes int BlockAHits int GroupsFound int LastDiag string } type Result struct { PI uint16 `json:"pi"` PS string `json:"ps"` RT string `json:"rt"` } // Decode takes complex baseband samples at ~20kHz and extracts RDS data. func (d *Decoder) Decode(samples []complex64, sampleRate int) Result { if len(samples) < 104 || sampleRate <= 0 { return Result{PI: d.lastPI, PS: d.psString(), RT: d.rtString()} } d.TotalDecodes++ sps := float64(sampleRate) / 1187.5 // samples per symbol // === Mueller & Muller symbol timing recovery === // Reset state each call — accumulated samples have phase gaps between frames mu := sps / 2 symbols := make([]complex64, 0, len(samples)/int(sps)+1) var prev, prevDecision complex64 for mu < float64(len(samples)-1) { idx := int(mu) frac := mu - float64(idx) if idx+1 >= len(samples) { break } samp := complex64(complex( float64(real(samples[idx]))*(1-frac)+float64(real(samples[idx+1]))*frac, float64(imag(samples[idx]))*(1-frac)+float64(imag(samples[idx+1]))*frac, )) var decision complex64 if real(samp) > 0 { decision = 1 } else { decision = -1 } if len(symbols) >= 2 { errR := float64(real(decision)-real(prevDecision))*float64(real(prev)) - float64(real(samp)-real(prev))*float64(real(prevDecision)) mu += sps + 0.01*errR } else { mu += sps } prevDecision = decision prev = samp symbols = append(symbols, samp) } if len(symbols) < 26*4 { d.LastDiag = fmt.Sprintf("too few symbols: %d", len(symbols)) return Result{PI: d.lastPI, PS: d.psString(), RT: d.rtString()} } // === Costas loop for fine frequency/phase synchronization === // Reset each call — phase gaps between accumulated frames break continuity alpha := 0.132 beta := alpha * alpha / 4.0 phase := 0.0 freq := 0.0 synced := make([]complex64, len(symbols)) for i, s := range symbols { // Multiply by exp(-j*phase) to de-rotate cosP := float32(math.Cos(phase)) sinP := float32(math.Sin(phase)) synced[i] = complex( real(s)*cosP+imag(s)*sinP, imag(s)*cosP-real(s)*sinP, ) // BPSK phase error: sign(I) * Q var err float64 if real(synced[i]) > 0 { err = float64(imag(synced[i])) } else { err = -float64(imag(synced[i])) } freq += beta * err phase += freq + alpha*err for phase > math.Pi { phase -= 2 * math.Pi } for phase < -math.Pi { phase += 2 * math.Pi } } // state not persisted — samples have gaps // Measure signal quality: average |I| and |Q| after Costas var sumI, sumQ float64 for _, s := range synced { ri := float64(real(s)) rq := float64(imag(s)) if ri < 0 { ri = -ri } if rq < 0 { rq = -rq } sumI += ri sumQ += rq } avgI := sumI / float64(len(synced)) avgQ := sumQ / float64(len(synced)) // === BPSK demodulation === hardBits := make([]int, len(synced)) for i, s := range synced { if real(s) > 0 { hardBits[i] = 1 } else { hardBits[i] = 0 } } // === Differential decoding === bits := make([]int, len(hardBits)-1) for i := 1; i < len(hardBits); i++ { bits[i-1] = hardBits[i] ^ hardBits[i-1] } // === Block sync + CRC decode (try both polarities) === // Count block A CRC hits for diagnostics blockAHits := 0 for i := 0; i+26 <= len(bits); i++ { if _, ok := decodeBlock(bits[i : i+26]); ok { blockAHits++ } } d.BlockAHits += blockAHits found1 := d.tryDecode(bits) invBits := make([]int, len(bits)) for i, b := range bits { invBits[i] = 1 - b } found2 := d.tryDecode(invBits) if found1 || found2 { d.GroupsFound++ } d.LastDiag = fmt.Sprintf("syms=%d sps=%.1f costasFreq=%.4f avgI=%.4f avgQ=%.4f blockAHits=%d groups=%d", len(symbols), sps, freq, avgI, avgQ, blockAHits, d.GroupsFound) return Result{PI: d.lastPI, PS: d.psString(), RT: d.rtString()} } func (d *Decoder) tryDecode(bits []int) bool { found := false for i := 0; i+26*4 <= len(bits); i++ { bA, okA := decodeBlock(bits[i : i+26]) if !okA || bA.offset != offA { continue } bB, okB := decodeBlock(bits[i+26 : i+52]) if !okB || bB.offset != offB { continue } bC, okC := decodeBlock(bits[i+52 : i+78]) if !okC || (bC.offset != offC && bC.offset != offCp) { continue } bD, okD := decodeBlock(bits[i+78 : i+104]) if !okD || bD.offset != offD { continue } found = true pi := bA.data if pi != 0 { d.lastPI = pi } groupType := (bB.data >> 12) & 0xF versionA := ((bB.data >> 11) & 0x1) == 0 if groupType == 0 { addr := bB.data & 0x3 if versionA { 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]) } } i += 103 } return found } type block struct { data uint16 offset uint16 } const ( offA uint16 = 0x0FC offB uint16 = 0x198 offC uint16 = 0x168 offCp uint16 = 0x350 offD uint16 = 0x1B4 ) func decodeBlock(bits []int) (block, bool) { if len(bits) != 26 { return block{}, false } var raw uint32 for _, b := range bits { raw = (raw << 1) | uint32(b&1) } data := uint16(raw >> 10) synd := crcSyndrome(raw) switch synd { case offA, offB, offC, offCp, offD: return block{data: data, offset: synd}, true default: return block{}, false } } func crcSyndrome(raw uint32) uint16 { poly := uint32(0x1B9) reg := raw for i := 25; i >= 10; i-- { if (reg>>uint(i))&1 == 1 { reg ^= poly << uint(i-10) } } 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 = ' ' } 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, r) } return trimRight(out) } func trimRight(in []rune) string { end := len(in) for end > 0 && in[end-1] == ' ' { end-- } return string(in[:end]) }