Browse Source

RDS: stabilize live decode + conservative recovery

master
Jan Svabenik 14 hours ago
parent
commit
0d9a9e096e
4 changed files with 441 additions and 94 deletions
  1. +2
    -0
      .gitignore
  2. +22
    -14
      cmd/sdrd/dsp_loop.go
  3. +417
    -80
      internal/rds/rds.go
  4. BIN
      sdr-visual-suite.rar

+ 2
- 0
.gitignore View File

@@ -24,6 +24,8 @@ web/old/
web/openai/
web/*-web.zip
sdr-visual-suite.zip
*.zip
*.rar

# downloaded decoder binaries
tools/downloads/


+ 22
- 14
cmd/sdrd/dsp_loop.go View File

@@ -85,18 +85,18 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det *
cfg = upd.cfg
if rec != nil {
rec.Update(cfg.SampleRate, cfg.FFTSize, recorder.Policy{
Enabled: cfg.Recorder.Enabled,
MinSNRDb: cfg.Recorder.MinSNRDb,
MinDuration: mustParseDuration(cfg.Recorder.MinDuration, 1*time.Second),
MaxDuration: mustParseDuration(cfg.Recorder.MaxDuration, 300*time.Second),
PrerollMs: cfg.Recorder.PrerollMs,
RecordIQ: cfg.Recorder.RecordIQ,
RecordAudio: cfg.Recorder.RecordAudio,
AutoDemod: cfg.Recorder.AutoDemod,
AutoDecode: cfg.Recorder.AutoDecode,
MaxDiskMB: cfg.Recorder.MaxDiskMB,
OutputDir: cfg.Recorder.OutputDir,
ClassFilter: cfg.Recorder.ClassFilter,
Enabled: cfg.Recorder.Enabled,
MinSNRDb: cfg.Recorder.MinSNRDb,
MinDuration: mustParseDuration(cfg.Recorder.MinDuration, 1*time.Second),
MaxDuration: mustParseDuration(cfg.Recorder.MaxDuration, 300*time.Second),
PrerollMs: cfg.Recorder.PrerollMs,
RecordIQ: cfg.Recorder.RecordIQ,
RecordAudio: cfg.Recorder.RecordAudio,
AutoDemod: cfg.Recorder.AutoDemod,
AutoDecode: cfg.Recorder.AutoDecode,
MaxDiskMB: cfg.Recorder.MaxDiskMB,
OutputDir: cfg.Recorder.OutputDir,
ClassFilter: cfg.Recorder.ClassFilter,
RingSeconds: cfg.Recorder.RingSeconds,
DeemphasisUs: cfg.Recorder.DeemphasisUs,
ExtractionTaps: cfg.Recorder.ExtractionTaps,
@@ -242,7 +242,11 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det *
}
// RDS decode for WFM — async, uses ring buffer for continuous IQ
if (cls.ModType == classifier.ClassWFM || cls.ModType == classifier.ClassWFMStereo) && rec != nil {
key := int64(math.Round(signals[i].CenterHz / 500000))
keyHz := pll.ExactHz
if keyHz == 0 {
keyHz = signals[i].CenterHz
}
key := int64(math.Round(keyHz / 25000.0))
st := rdsMap[key]
if st == nil {
st = &rdsState{}
@@ -321,7 +325,11 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det *
if len(rdsMap) > 0 {
activeIDs := make(map[int64]bool, len(signals))
for _, s := range signals {
activeIDs[int64(math.Round(s.CenterHz / 500000))] = true
keyHz := s.CenterHz
if s.PLL != nil && s.PLL.ExactHz != 0 {
keyHz = s.PLL.ExactHz
}
activeIDs[int64(math.Round(keyHz/25000.0))] = true
}
for id := range rdsMap {
if !activeIDs[id] {


+ 417
- 80
internal/rds/rds.go View File

@@ -15,12 +15,19 @@ type Decoder struct {
costasPhase float64
costasFreq float64
// Symbol sync state
syncMu float64
syncMu float64
syncPrev complex64
syncPrevDecision complex64
syncHasPrev bool
lastSampleRate int
// Differential decode state across Decode() calls
lastHardBit int
hasLastHardBit bool
// Diagnostic counters
TotalDecodes int
BlockAHits int
GroupsFound int
LastDiag string
TotalDecodes int
BlockAHits int
GroupsFound int
LastDiag string
}

type Result struct {
@@ -29,6 +36,44 @@ type Result struct {
RT string `json:"rt"`
}

type scanDiag struct {
pol string
blockHits int
offAHits int
offBHits int
offCHits int
offCpHits int
offDHits int
abSeq int
abcSeq int
groups int
piHint uint16
piHintCount int
fecBlockFix int
grpFecFix int
blockAmbig int
seqAmbig int
}

func (s scanDiag) score() int {
return s.groups*100000 + s.abcSeq*1000 + s.abSeq*100 + s.blockHits
}

func bestDiag(a, b scanDiag) scanDiag {
if b.score() > a.score() {
return b
}
if b.score() == a.score() {
if b.piHintCount > a.piHintCount {
return b
}
if b.fecBlockFix > a.fecBlockFix {
return b
}
}
return a
}

// 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 {
@@ -36,13 +81,29 @@ func (d *Decoder) Decode(samples []complex64, sampleRate int) Result {
}
d.TotalDecodes++

if d.lastSampleRate != 0 && d.lastSampleRate != sampleRate {
d.costasPhase = 0
d.costasFreq = 0
d.syncMu = 0
d.syncPrev = 0
d.syncPrevDecision = 0
d.syncHasPrev = false
d.lastHardBit = 0
d.hasLastHardBit = false
}
d.lastSampleRate = sampleRate

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
// === Mueller & Muller symbol timing recovery (persistent across calls) ===
mu := d.syncMu
if mu < 0 || mu >= sps || math.IsNaN(mu) || math.IsInf(mu, 0) {
mu = sps / 2
}
symbols := make([]complex64, 0, len(samples)/int(sps)+1)
var prev, prevDecision complex64
prev := d.syncPrev
prevDecision := d.syncPrevDecision
havePrev := d.syncHasPrev
for mu < float64(len(samples)-1) {
idx := int(mu)
frac := mu - float64(idx)
@@ -55,49 +116,59 @@ func (d *Decoder) Decode(samples []complex64, sampleRate int) Result {
))

var decision complex64
if real(samp) > 0 {
if real(samp) >= 0 {
decision = 1
} else {
decision = -1
}

if len(symbols) >= 2 {
if havePrev {
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
havePrev = true
}

prevDecision = decision
prev = samp
symbols = append(symbols, samp)
}
residualMu := mu - float64(len(samples))
for residualMu < 0 {
residualMu += sps
}
for residualMu >= sps {
residualMu -= sps
}
d.syncMu = residualMu
d.syncPrev = prev
d.syncPrevDecision = prevDecision
d.syncHasPrev = havePrev

if len(symbols) < 26*4 {
d.LastDiag = fmt.Sprintf("too few symbols: %d", len(symbols))
d.LastDiag = fmt.Sprintf("too few symbols: %d sps=%.1f mu=%.3f", len(symbols), sps, d.syncMu)
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
// === Costas loop for fine frequency/phase synchronization (persistent across calls) ===
alpha := 0.132
beta := alpha * alpha / 4.0
phase := 0.0
freq := 0.0
phase := d.costasPhase
freq := d.costasFreq
synced := make([]complex64, len(symbols))
for i, s := range symbols {
// Multiply by exp(-j*phase) to de-rotate
// 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
// BPSK phase error: sign(I) * Q.
var err float64
if real(synced[i]) > 0 {
if real(synced[i]) >= 0 {
err = float64(imag(synced[i]))
} else {
err = -float64(imag(synced[i]))
@@ -111,16 +182,14 @@ func (d *Decoder) Decode(samples []complex64, sampleRate int) Result {
phase += 2 * math.Pi
}
}
// state not persisted — samples have gaps
d.costasPhase = phase
d.costasFreq = freq

// Measure signal quality: average |I| and |Q| after Costas
// 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 }
ri := math.Abs(float64(real(s)))
rq := math.Abs(float64(imag(s)))
sumI += ri
sumQ += rq
}
@@ -130,7 +199,7 @@ func (d *Decoder) Decode(samples []complex64, sampleRate int) Result {
// === BPSK demodulation ===
hardBits := make([]int, len(synced))
for i, s := range synced {
if real(s) > 0 {
if real(s) >= 0 {
hardBits[i] = 1
} else {
hardBits[i] = 0
@@ -138,85 +207,298 @@ func (d *Decoder) Decode(samples []complex64, sampleRate int) Result {
}

// === 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++
// Preserve the differential transition across Decode() calls. On the very
// first call we keep the historical behavior (N hard bits -> N-1 diff bits);
// once carry state exists we prepend the cross-chunk transition bit.
var bits []int
if len(hardBits) > 0 {
if d.hasLastHardBit {
bits = make([]int, len(hardBits))
bits[0] = hardBits[0] ^ d.lastHardBit
for i := 1; i < len(hardBits); i++ {
bits[i] = hardBits[i] ^ hardBits[i-1]
}
} else if len(hardBits) >= 2 {
bits = make([]int, len(hardBits)-1)
for i := 1; i < len(hardBits); i++ {
bits[i-1] = hardBits[i] ^ hardBits[i-1]
}
}
d.lastHardBit = hardBits[len(hardBits)-1]
d.hasLastHardBit = true
}
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 {

// === Diagnostics before/after 1-bit FEC ===
rawBest := bestDiag(analyzeStream(bits, false, "dir"), analyzeStream(invBits, false, "inv"))
fecBest := bestDiag(analyzeStream(bits, true, "dir"), analyzeStream(invBits, true, "inv"))
d.BlockAHits += rawBest.offAHits

// === Block sync + CRC decode with conservative 1-bit FEC ===
groupsFound := d.tryDecode(bits, true)
usedPol := "dir"
if groupsFound == 0 {
groupsFound = d.tryDecode(invBits, true)
if groupsFound > 0 {
usedPol = "inv"
} else {
usedPol = "none"
}
}
if groupsFound > 0 {
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)
d.LastDiag = fmt.Sprintf(
"syms=%d sps=%.1f mu=%.3f costasFreq=%.4f avgI=%.4f avgQ=%.4f diffCarry=%t raw[%s blk=%d A/B/C/Cp/D=%d/%d/%d/%d/%d AB=%d ABC=%d grp=%d pi=%04Xx%d] fec[%s blk=%d A/B/C/Cp/D=%d/%d/%d/%d/%d AB=%d ABC=%d grp=%d pi=%04Xx%d fixBlk=%d fixGrp=%d ambBlk=%d ambSeq=%d] use=%s found=%d okCalls=%d",
len(symbols), sps, d.syncMu, d.costasFreq, avgI, avgQ, d.hasLastHardBit,
rawBest.pol, rawBest.blockHits, rawBest.offAHits, rawBest.offBHits, rawBest.offCHits, rawBest.offCpHits, rawBest.offDHits, rawBest.abSeq, rawBest.abcSeq, rawBest.groups, rawBest.piHint, rawBest.piHintCount,
fecBest.pol, fecBest.blockHits, fecBest.offAHits, fecBest.offBHits, fecBest.offCHits, fecBest.offCpHits, fecBest.offDHits, fecBest.abSeq, fecBest.abcSeq, fecBest.groups, fecBest.piHint, fecBest.piHintCount, fecBest.fecBlockFix, fecBest.grpFecFix, fecBest.blockAmbig, fecBest.seqAmbig,
usedPol, groupsFound, 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 {
func analyzeStream(bits []int, useFEC bool, pol string) scanDiag {
diag := scanDiag{pol: pol}
if len(bits) < 26 {
return diag
}

piCounts := make(map[uint16]int)
for i := 0; i+26 <= len(bits); i++ {
blk, ok, corrected, ambiguous := decodeBlockAny(bits[i:i+26], useFEC)
if ambiguous {
diag.blockAmbig++
}
if !ok {
continue
}
bB, okB := decodeBlock(bits[i+26 : i+52])
if !okB || bB.offset != offB {
diag.blockHits++
if corrected {
diag.fecBlockFix++
}
switch blk.offset {
case offA:
diag.offAHits++
if blk.data != 0 {
piCounts[blk.data]++
}
case offB:
diag.offBHits++
case offC:
diag.offCHits++
case offCp:
diag.offCpHits++
case offD:
diag.offDHits++
}
}
for pi, n := range piCounts {
if n > diag.piHintCount {
diag.piHint = pi
diag.piHintCount = n
}
}

const groupBits = 26 * 4
for i := 0; i+groupBits <= len(bits); i++ {
fixes := 0

_, okA, fixedA, ambA := decodeBlockExpected(bits[i:i+26], []uint16{offA}, useFEC)
if ambA {
diag.seqAmbig++
}
if !okA {
continue
}
bC, okC := decodeBlock(bits[i+52 : i+78])
if !okC || (bC.offset != offC && bC.offset != offCp) {
if fixedA {
fixes++
}

_, okB, fixedB, ambB := decodeBlockExpected(bits[i+26:i+52], []uint16{offB}, useFEC)
if ambB {
diag.seqAmbig++
}
if !okB {
continue
}
bD, okD := decodeBlock(bits[i+78 : i+104])
if !okD || bD.offset != offD {
diag.abSeq++
if fixedB {
fixes++
}

_, okC, fixedC, ambC := decodeBlockExpected(bits[i+52:i+78], []uint16{offC, offCp}, useFEC)
if ambC {
diag.seqAmbig++
}
if !okC {
continue
}
diag.abcSeq++
if fixedC {
fixes++
}

_, okD, fixedD, ambD := decodeBlockExpected(bits[i+78:i+104], []uint16{offD}, useFEC)
if ambD {
diag.seqAmbig++
}
if !okD {
continue
}
if fixedD {
fixes++
}

diag.groups++
diag.grpFecFix += fixes
}

return diag
}

func (d *Decoder) tryDecode(bits []int, useFEC bool) int {
const (
groupBits = 26 * 4
flywheelJitter = 3
)
groups := 0
for i := 0; i+groupBits <= len(bits); {
grp, _, ok := decodeGroupAt(bits, i, useFEC)
if !ok {
i++
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])

groups++
d.applyGroup(grp)

// Flywheel: once a valid group was found, prefer the next expected 104-bit
// boundary and search only in a tiny jitter window around it before falling
// back to full bitwise scanning.
nextExpected := i + groupBits
locked := true
for locked && nextExpected+groupBits <= len(bits) {
if nextGrp, _, ok := decodeGroupAt(bits, nextExpected, useFEC); ok {
d.applyGroup(nextGrp)
groups++
nextExpected += groupBits
continue
}

matched := false
for delta := 1; delta <= flywheelJitter && !matched; delta++ {
left := nextExpected - delta
if left >= 0 {
if nextGrp, _, ok := decodeGroupAt(bits, left, useFEC); ok {
d.applyGroup(nextGrp)
groups++
nextExpected = left + groupBits
matched = true
break
}
}
right := nextExpected + delta
if right+groupBits <= len(bits) {
if nextGrp, _, ok := decodeGroupAt(bits, right, useFEC); ok {
d.applyGroup(nextGrp)
groups++
nextExpected = right + groupBits
matched = true
break
}
}
}

if !matched {
locked = false
}
}
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])

resume := nextExpected - flywheelJitter
if resume <= i {
resume = i + 1
}
i = resume
}
return groups
}

func decodeGroupAt(bits []int, start int, useFEC bool) ([4]block, int, bool) {
const groupBits = 26 * 4
var grp [4]block
if start < 0 || start+groupBits > len(bits) {
return grp, 0, false
}
fixes := 0

bA, okA, fixedA, _ := decodeBlockExpected(bits[start:start+26], []uint16{offA}, useFEC)
if !okA {
return grp, 0, false
}
if fixedA {
fixes++
}
bB, okB, fixedB, _ := decodeBlockExpected(bits[start+26:start+52], []uint16{offB}, useFEC)
if !okB {
return grp, 0, false
}
if fixedB {
fixes++
}
bC, okC, fixedC, _ := decodeBlockExpected(bits[start+52:start+78], []uint16{offC, offCp}, useFEC)
if !okC {
return grp, 0, false
}
if fixedC {
fixes++
}
bD, okD, fixedD, _ := decodeBlockExpected(bits[start+78:start+104], []uint16{offD}, useFEC)
if !okD {
return grp, 0, false
}
if fixedD {
fixes++
}
grp[0] = bA
grp[1] = bB
grp[2] = bC
grp[3] = bD
return grp, fixes, true
}

func (d *Decoder) applyGroup(grp [4]block) {
bA, bB, bC, bD := grp[0], grp[1], grp[2], grp[3]
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])
}
}
i += 103
}
return found
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])
}
}
}

type block struct {
@@ -232,14 +514,24 @@ const (
offD uint16 = 0x1B4
)

var allOffsets = []uint16{offA, offB, offC, offCp, offD}

func decodeBlock(bits []int) (block, bool) {
if len(bits) != 26 {
return block{}, false
}
return decodeRawBlock(bitsToRaw(bits))
}

func bitsToRaw(bits []int) uint32 {
var raw uint32
for _, b := range bits {
raw = (raw << 1) | uint32(b&1)
}
return raw
}

func decodeRawBlock(raw uint32) (block, bool) {
data := uint16(raw >> 10)
synd := crcSyndrome(raw)
switch synd {
@@ -250,6 +542,51 @@ func decodeBlock(bits []int) (block, bool) {
}
}

func decodeBlockAny(bits []int, useFEC bool) (block, bool, bool, bool) {
return decodeBlockExpected(bits, allOffsets, useFEC)
}

func decodeBlockExpected(bits []int, allowed []uint16, useFEC bool) (block, bool, bool, bool) {
if len(bits) != 26 {
return block{}, false, false, false
}
if blk, ok := decodeBlock(bits); ok && offsetAllowed(blk.offset, allowed) {
return blk, true, false, false
}
if !useFEC {
return block{}, false, false, false
}

raw := bitsToRaw(bits)
var candidate block
candidateCount := 0
for i := 0; i < 26; i++ {
flipped := raw ^ (uint32(1) << uint(25-i))
blk, ok := decodeRawBlock(flipped)
if !ok || !offsetAllowed(blk.offset, allowed) {
continue
}
candidate = blk
candidateCount++
if candidateCount > 1 {
return block{}, false, false, true
}
}
if candidateCount == 1 {
return candidate, true, true, false
}
return block{}, false, false, false
}

func offsetAllowed(offset uint16, allowed []uint16) bool {
for _, want := range allowed {
if offset == want {
return true
}
}
return false
}

func crcSyndrome(raw uint32) uint16 {
poly := uint32(0x1B9)
reg := raw


BIN
sdr-visual-suite.rar View File


Loading…
Cancel
Save