Explorar el Código

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.
tags/v0.7.0-pre
Jan Svabenik hace 1 mes
padre
commit
2cac36fc78
Se han modificado 4 ficheros con 141 adiciones y 185 borrados
  1. +5
    -5
      docs/config.plutosdr.json
  2. +7
    -1
      internal/offline/generator.go
  3. +2
    -2
      internal/offline/spectral_test.go
  4. +127
    -177
      internal/rds/encoder.go

+ 5
- 5
docs/config.plutosdr.json Ver fichero

@@ -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,


+ 7
- 1
internal/offline/generator.go Ver fichero

@@ -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)


+ 2
- 2
internal/offline/spectral_test.go Ver fichero

@@ -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)
}
}


+ 127
- 177
internal/rds/encoder.go Ver fichero

@@ -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
}

Cargando…
Cancelar
Guardar