diff --git a/.gitignore b/.gitignore index d84ca9d..670dd91 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ web/old/ web/openai/ web/*-web.zip sdr-visual-suite.zip +*.zip +*.rar # downloaded decoder binaries tools/downloads/ diff --git a/cmd/sdrd/dsp_loop.go b/cmd/sdrd/dsp_loop.go index 6215219..70565bd 100644 --- a/cmd/sdrd/dsp_loop.go +++ b/cmd/sdrd/dsp_loop.go @@ -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] { diff --git a/internal/rds/rds.go b/internal/rds/rds.go index 8f56d31..fd3043c 100644 --- a/internal/rds/rds.go +++ b/internal/rds/rds.go @@ -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 diff --git a/sdr-visual-suite.rar b/sdr-visual-suite.rar deleted file mode 100644 index ea51e37..0000000 Binary files a/sdr-visual-suite.rar and /dev/null differ