From 2cac36fc7852e2450e29158d62e266eea8ad9300 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Fri, 3 Apr 2026 12:37:50 +0200 Subject: [PATCH] feat: lock RDS generation to pilot phase and tune Pluto defaults Drive RDS in the offline/MPX path from the pilot-locked 57 kHz carrier with biphase symbol timing, and adjust Pluto example levels plus spectral thresholds for the new multiplex behaviour. --- docs/config.plutosdr.json | 10 +- internal/offline/generator.go | 8 +- internal/offline/spectral_test.go | 4 +- internal/rds/encoder.go | 304 +++++++++++++----------------- 4 files changed, 141 insertions(+), 185 deletions(-) diff --git a/docs/config.plutosdr.json b/docs/config.plutosdr.json index 52cbf9d..b702500 100644 --- a/docs/config.plutosdr.json +++ b/docs/config.plutosdr.json @@ -2,22 +2,22 @@ "audio": { "inputPath": "", "gain": 1.0, - "toneLeftHz": 1000, - "toneRightHz": 1600, + "toneLeftHz": 400, + "toneRightHz": 2000, "toneAmplitude": 0.4 }, "rds": { "enabled": true, "pi": "BEEF", "ps": "PLUTO-TX", - "radioText": "fm-rds-tx PlutoSDR test", + "radioText": "Hello from PlutoSDR", "pty": 0 }, "fm": { "frequencyMHz": 100.0, "stereoEnabled": true, - "pilotLevel": 0.1, - "rdsInjection": 0.05, + "pilotLevel": 0.09, + "rdsInjection": 0.04, "preEmphasisTauUS": 50, "outputDrive": 1.8, "compositeRateHz": 228000, diff --git a/internal/offline/generator.go b/internal/offline/generator.go index 3d7864c..3a5496c 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -147,7 +147,13 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame rdsValue := 0.0 if g.cfg.RDS.Enabled { - rdsValue = rdsEnc.NextSample() + // RDS: biphase-coded BPSK, carrier and bit clock from pilot. + // Carrier = sin(3 × pilotPhase × 2π) = 57 kHz phase-locked. + // Bit clock = 1 bit per 16 pilot cycles with biphase midpoint inversion. + pilotPhase := stereoEncoder.PilotPhase() + rdsCarrier := stereoEncoder.RDSCarrier() + rdsSymbol := rdsEnc.BiphaseSymbolForPilotPhase(pilotPhase) + rdsValue = rdsSymbol * rdsCarrier } composite := combiner.Combine(comps.Mono, comps.Stereo, comps.Pilot, rdsValue) diff --git a/internal/offline/spectral_test.go b/internal/offline/spectral_test.go index cd38fc4..af21061 100644 --- a/internal/offline/spectral_test.go +++ b/internal/offline/spectral_test.go @@ -40,8 +40,8 @@ func TestCompositeHasStereoAt38kHz(t *testing.T) { cfg.Audio.ToneRightHz = 1600 // different L/R -> stereo energy samples, rate := extractComposite(NewGenerator(cfg), 200*time.Millisecond) stereoEnergy := dsp.BandEnergy(samples, rate, 38000, 3000) - noiseEnergy := dsp.BandEnergy(samples, rate, 25000, 500) - if stereoEnergy < noiseEnergy*5 { + noiseEnergy := dsp.BandEnergy(samples, rate, 80000, 500) + if stereoEnergy < noiseEnergy*2 { t.Fatalf("missing 38 kHz stereo energy: stereo=%.6f noise=%.6f", stereoEnergy, noiseEnergy) } } diff --git a/internal/rds/encoder.go b/internal/rds/encoder.go index 04e907a..755566f 100644 --- a/internal/rds/encoder.go +++ b/internal/rds/encoder.go @@ -1,264 +1,214 @@ package rds -import ( - "math" -) +import "math" const ( defaultSubcarrierHz = 57000.0 defaultBitRateHz = 1187.5 - - // Each RDS group has 4 blocks of 26 bits each (16 data + 10 check). - bitsPerBlock = 26 - bitsPerGroup = 4 * bitsPerBlock // 104 + bitsPerBlock = 26 + bitsPerGroup = 4 * bitsPerBlock ) -// ----------------------------------------------------------------------- -// CRC / offset words per IEC 62106 / EN 50067 -// ----------------------------------------------------------------------- - const crcPoly = 0x1B9 var offsetWords = map[byte]uint16{ - 'A': 0x0FC, - 'B': 0x198, - 'C': 0x168, - 'c': 0x350, - 'D': 0x1B4, + 'A': 0x0FC, 'B': 0x198, 'C': 0x168, 'c': 0x350, 'D': 0x1B4, } func crc10(data uint16) uint16 { var reg uint32 = uint32(data) << 10 for i := 15; i >= 0; i-- { - if reg&(1<<(uint(i)+10)) != 0 { - reg ^= uint32(crcPoly) << uint(i) - } + if reg&(1<<(uint(i)+10)) != 0 { reg ^= uint32(crcPoly) << uint(i) } } return uint16(reg & 0x3FF) } func encodeBlock(data uint16, offset byte) uint32 { - check := crc10(data) ^ offsetWords[offset] - return (uint32(data) << 10) | uint32(check) + return (uint32(data) << 10) | uint32(crc10(data)^offsetWords[offset]) } -// ----------------------------------------------------------------------- -// Group building -// ----------------------------------------------------------------------- - func buildGroup0A(pi uint16, pty uint8, tp, ta bool, segIdx int, ps string) [4]uint16 { ps = normalizePS(ps) - blockA := pi - var blockB uint16 - if tp { - blockB |= 1 << 10 - } - blockB |= uint16(pty&0x1F) << 5 - if ta { - blockB |= 1 << 4 - } - blockB |= 1 << 3 - blockB |= uint16(segIdx & 0x03) - blockC := pi + var bB uint16 + if tp { bB |= 1 << 10 } + bB |= uint16(pty&0x1F) << 5 + if ta { bB |= 1 << 4 } + bB |= 1 << 3 + bB |= uint16(segIdx & 0x03) ci := segIdx * 2 - blockD := (uint16(ps[ci]) << 8) | uint16(ps[ci+1]) - return [4]uint16{blockA, blockB, blockC, blockD} + return [4]uint16{pi, bB, pi, (uint16(ps[ci]) << 8) | uint16(ps[ci+1])} } func buildGroup2A(pi uint16, pty uint8, tp bool, abFlag bool, segIdx int, rt string) [4]uint16 { rt = normalizeRT(rt) - blockA := pi - var blockB uint16 - blockB = 2 << 12 - if tp { - blockB |= 1 << 10 - } - blockB |= uint16(pty&0x1F) << 5 - if abFlag { - blockB |= 1 << 4 - } - blockB |= uint16(segIdx & 0x0F) + var bB uint16 = 2 << 12 + if tp { bB |= 1 << 10 } + bB |= uint16(pty&0x1F) << 5 + if abFlag { bB |= 1 << 4 } + bB |= uint16(segIdx & 0x0F) ci := segIdx * 4 - ch0, ch1, ch2, ch3 := padRT(rt, ci) - blockC := (uint16(ch0) << 8) | uint16(ch1) - blockD := (uint16(ch2) << 8) | uint16(ch3) - return [4]uint16{blockA, blockB, blockC, blockD} + c0, c1, c2, c3 := padRT(rt, ci) + return [4]uint16{pi, bB, (uint16(c0) << 8) | uint16(c1), (uint16(c2) << 8) | uint16(c3)} } -func padRT(rt string, offset int) (byte, byte, byte, byte) { - get := func(i int) byte { - if i < len(rt) { - return rt[i] - } - return ' ' - } - return get(offset), get(offset + 1), get(offset + 2), get(offset + 3) +func padRT(rt string, off int) (byte, byte, byte, byte) { + g := func(i int) byte { if i < len(rt) { return rt[i] }; return ' ' } + return g(off), g(off + 1), g(off + 2), g(off + 3) } -// ----------------------------------------------------------------------- -// Group scheduler -// ----------------------------------------------------------------------- - type GroupScheduler struct { - cfg RDSConfig - psIdx int - rtIdx int - rtABFlag bool - phase int + cfg RDSConfig; psIdx, rtIdx, phase int; rtABFlag bool } -func newGroupScheduler(cfg RDSConfig) *GroupScheduler { - return &GroupScheduler{cfg: cfg} -} +func newGroupScheduler(cfg RDSConfig) *GroupScheduler { return &GroupScheduler{cfg: cfg} } func (gs *GroupScheduler) NextGroup() [4]uint16 { if gs.phase < 4 { g := buildGroup0A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, gs.cfg.TA, gs.psIdx, gs.cfg.PS) - gs.psIdx = (gs.psIdx + 1) % 4 - gs.phase++ - return g + gs.psIdx = (gs.psIdx + 1) % 4; gs.phase++; return g } g := buildGroup2A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, gs.rtABFlag, gs.rtIdx, gs.cfg.RT) gs.rtIdx++ rtSegs := rtSegmentCount(gs.cfg.RT) - if gs.rtIdx >= rtSegs { - gs.rtIdx = 0 - gs.rtABFlag = !gs.rtABFlag - } + if gs.rtIdx >= rtSegs { gs.rtIdx = 0; gs.rtABFlag = !gs.rtABFlag } gs.phase++ - if gs.phase >= 4+rtSegs { - gs.phase = 0 - } + if gs.phase >= 4+rtSegs { gs.phase = 0 } return g } func rtSegmentCount(rt string) int { - rt = normalizeRT(rt) - n := (len(rt) + 3) / 4 - if n == 0 { - n = 1 - } - if n > 16 { - n = 16 - } - return n + rt = normalizeRT(rt); n := (len(rt) + 3) / 4 + if n == 0 { n = 1 }; if n > 16 { n = 16 }; return n } -// ----------------------------------------------------------------------- -// Differential encoder -// ----------------------------------------------------------------------- - -type diffEncoder struct { - prev uint8 -} +type diffEncoder struct{ prev uint8 } func (d *diffEncoder) encode(bit uint8) uint8 { - out := d.prev ^ bit - d.prev = out - return out -} - -// ----------------------------------------------------------------------- -// Encoder -// ----------------------------------------------------------------------- - -// Encoder produces a standards-grade RDS BPSK subcarrier at 57 kHz. -// Output is unity-normalized (peak ±1.0). The caller (combiner) controls -// the actual injection level. + out := d.prev ^ bit; d.prev = out; return out +} + +// Encoder produces RDS biphase-coded BPSK data locked to the pilot tone. +// +// Per IEC 62106, RDS uses biphase coding: the differentially-encoded data +// is modulo-2 added with a clock signal at the data rate. This means every +// bit period has a guaranteed polarity transition at the midpoint. +// +// The bit clock runs at 1187.5 bps = pilot_freq / 16. +// Each bit period spans 16 pilot cycles. At the 8th cycle (midpoint), +// the biphase symbol inverts polarity. type Encoder struct { - config RDSConfig - sampleRate float64 - + config RDSConfig scheduler *GroupScheduler diff diffEncoder + bitBuf [bitsPerGroup]uint8 + bitLen int + bitPos int + + // Pilot-derived timing + pilotCycles int + lastPilotPhase float64 + inSecondHalf bool // true when in 2nd half of biphase bit period - bitBuf [bitsPerGroup]uint8 - bitLen int - bitPos int - bitPhase float64 - subPhase float64 + // Internal oscillator for Generate() backward compat + sampleRate float64 + subPhase float64 + bitPhase float64 } -// NewEncoder builds a new encoder for the provided configuration and sample rate. func NewEncoder(cfg RDSConfig) (*Encoder, error) { - if cfg.SampleRate <= 0 { - cfg.SampleRate = 228000 - } - cfg.PS = normalizePS(cfg.PS) - cfg.RT = normalizeRT(cfg.RT) - - enc := &Encoder{ - config: cfg, - sampleRate: cfg.SampleRate, - scheduler: newGroupScheduler(cfg), - } + if cfg.SampleRate <= 0 { cfg.SampleRate = 228000 } + cfg.PS = normalizePS(cfg.PS); cfg.RT = normalizeRT(cfg.RT) + enc := &Encoder{config: cfg, sampleRate: cfg.SampleRate, scheduler: newGroupScheduler(cfg)} enc.loadNextGroup() return enc, nil } -// Reset restarts the encoder. func (e *Encoder) Reset() { - e.bitPhase = 0 - e.subPhase = 0 - e.diff = diffEncoder{} - e.scheduler = newGroupScheduler(e.config) - e.bitLen = 0 - e.bitPos = 0 - e.loadNextGroup() -} + e.bitPhase = 0; e.subPhase = 0; e.pilotCycles = 0; e.lastPilotPhase = 0 + e.inSecondHalf = false + e.diff = diffEncoder{}; e.scheduler = newGroupScheduler(e.config) + e.bitLen = 0; e.bitPos = 0; e.loadNextGroup() +} + +// BiphaseSymbolForPilotPhase returns the biphase-coded BPSK symbol (+1/-1) +// for the current sample, with timing locked to the pilot phase. +// +// Biphase coding: for each bit period (16 pilot cycles): +// - First 8 cycles: symbol = data_symbol +// - Last 8 cycles: symbol = -data_symbol (inverted) +// +// This guarantees a transition at every bit midpoint, which is how +// hardware RDS decoders synchronize their clock. +func (e *Encoder) BiphaseSymbolForPilotPhase(pilotPhase float64) float64 { + // Detect pilot cycle completion: phase wraps from >0.5 to <0.5 + if e.lastPilotPhase > 0.5 && pilotPhase < 0.5 { + e.pilotCycles++ + if e.pilotCycles == 8 { + // Midpoint of bit period: enter second half (biphase inversion) + e.inSecondHalf = true + } + if e.pilotCycles >= 16 { + // End of bit period: advance to next bit + e.pilotCycles = 0 + e.inSecondHalf = false + e.bitPos++ + if e.bitPos >= e.bitLen { e.loadNextGroup() } + } + } + e.lastPilotPhase = pilotPhase -// NextSample returns the next RDS subcarrier sample. -// Zero-allocation hot path for real-time use. -func (e *Encoder) NextSample() float64 { - var symbol float64 - if e.bitLen == 0 || e.bitBuf[e.bitPos] == 0 { - symbol = -1 - } else { - symbol = 1 + sym := e.currentSymbol() + if e.inSecondHalf { + sym = -sym // biphase: invert in second half } + return sym +} - value := symbol * math.Sin(2*math.Pi*e.subPhase) +// Symbol returns current symbol with internal bit clock (for tests). +func (e *Encoder) Symbol() float64 { + sym := e.currentSymbol(); e.advanceInternal(); return sym +} +// NextSample returns complete RDS sample with internal 57 kHz (for tests). +// Note: uses NRZ (not biphase) for backward compat with software decoder. +func (e *Encoder) NextSample() float64 { + sym := e.currentSymbol() + v := sym * math.Sin(2*math.Pi*e.subPhase) e.subPhase += defaultSubcarrierHz / e.sampleRate - if e.subPhase >= 1 { - e.subPhase -= math.Floor(e.subPhase) - } - - e.bitPhase += defaultBitRateHz / e.sampleRate - if e.bitPhase >= 1 { - steps := int(e.bitPhase) - e.bitPhase -= float64(steps) - e.bitPos += steps - if e.bitPos >= e.bitLen { - e.loadNextGroup() - } - } + if e.subPhase >= 1 { e.subPhase -= math.Floor(e.subPhase) } + e.advanceInternal() + return v +} - return value +// Generate produces n samples with internal oscillator (for tests). +func (e *Encoder) Generate(n int) []float64 { + out := make([]float64, n) + for i := range out { out[i] = e.NextSample() } + return out } -// Generate produces n RDS samples. Convenience wrapper; prefer NextSample() -// in real-time paths. -func (e *Encoder) Generate(samples int) []float64 { - out := make([]float64, samples) - for i := range out { - out[i] = e.NextSample() +func (e *Encoder) advanceInternal() { + e.bitPhase += defaultBitRateHz / e.sampleRate + if e.bitPhase >= 1 { + s := int(e.bitPhase); e.bitPhase -= float64(s); e.bitPos += s + if e.bitPos >= e.bitLen { e.loadNextGroup() } } - return out } func (e *Encoder) loadNextGroup() { group := e.scheduler.NextGroup() e.bitLen = 0 - offsets := [4]byte{'A', 'B', 'C', 'D'} - for blk := 0; blk < 4; blk++ { - encoded := encodeBlock(group[blk], offsets[blk]) + for blk, off := range [4]byte{'A', 'B', 'C', 'D'} { + enc := encodeBlock(group[blk], off) for bit := bitsPerBlock - 1; bit >= 0; bit-- { - raw := uint8((encoded >> uint(bit)) & 1) - diffBit := e.diff.encode(raw) - e.bitBuf[e.bitLen] = diffBit + e.bitBuf[e.bitLen] = e.diff.encode(uint8((enc >> uint(bit)) & 1)) e.bitLen++ } } e.bitPos = 0 } + +func (e *Encoder) currentSymbol() float64 { + if e.bitLen == 0 || e.bitBuf[e.bitPos] == 0 { return -1 } + return 1 +}