Bläddra i källkod

feat: implement phase-coherent stereo encoding and standards-grade RDS framing

tags/v0.4.0-pre
Jan Svabenik 1 månad sedan
förälder
incheckning
2a5876d921
4 ändrade filer med 404 tillägg och 119 borttagningar
  1. +235
    -83
      internal/rds/encoder.go
  2. +115
    -24
      internal/rds/encoder_test.go
  3. +15
    -5
      internal/stereo/encoder.go
  4. +39
    -7
      internal/stereo/encoder_test.go

+ 235
- 83
internal/rds/encoder.go Visa fil

@@ -1,136 +1,288 @@
package rds

import (
"math"
"math"
)

const (
defaultBitRate = 1187.5
defaultSubcarrier = 57000
defaultAmplitude = 0.02
defaultSubcarrierHz = 57000.0
defaultBitRateHz = 1187.5
defaultAmplitude = 0.05

// Each RDS group has 4 blocks of 26 bits each (16 data + 10 check).
bitsPerBlock = 26
bitsPerGroup = 4 * bitsPerBlock // 104
)

// Encoder emits a simple BPSK-like RDS subcarrier stream for offline MPX builds.
// -----------------------------------------------------------------------
// CRC / offset words per IEC 62106 / EN 50067
// -----------------------------------------------------------------------

// Generator polynomial: x^10 + x^8 + x^7 + x^5 + x^4 + x^3 + 1 = 0x1B9
const crcPoly = 0x1B9

// Offset words for blocks A, B, C, C', D.
var offsetWords = map[byte]uint16{
'A': 0x0FC,
'B': 0x198,
'C': 0x168,
'c': 0x350, // C' for type B groups
'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)
}
}
return uint16(reg & 0x3FF)
}

func encodeBlock(data uint16, offset byte) uint32 {
check := crc10(data) ^ offsetWords[offset]
return (uint32(data) << 10) | uint32(check)
}

// -----------------------------------------------------------------------
// Group building
// -----------------------------------------------------------------------

// buildGroup0A creates a type 0A group (basic tuning and PS name).
// segIdx selects which 2-char segment of the 8-char PS name (0..3).
func buildGroup0A(pi uint16, pty uint8, tp, ta bool, segIdx int, ps string) [4]uint16 {
ps = normalizePS(ps)

blockA := pi

// Block B: group type 0A (0000 0), TP, PTY, TA, MS=1, DI=0, segment address
var blockB uint16
if tp {
blockB |= 1 << 10
}
blockB |= uint16(pty&0x1F) << 5
if ta {
blockB |= 1 << 4
}
blockB |= 1 << 3 // MS = music
blockB |= uint16(segIdx & 0x03)

// Block C: AF (not implemented) – send PI as filler (common practice)
blockC := pi

// Block D: 2 PS characters
ci := segIdx * 2
ch0 := uint16(ps[ci])
ch1 := uint16(ps[ci+1])
blockD := (ch0 << 8) | ch1

return [4]uint16{blockA, blockB, blockC, blockD}
}

// buildGroup2A creates a type 2A group (RadioText).
// segIdx selects which 4-char segment (0..15) of the 64-char RT.
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 // group type 2
if tp {
blockB |= 1 << 10
}
blockB |= uint16(pty&0x1F) << 5
if abFlag {
blockB |= 1 << 4
}
blockB |= 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}
}

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

// -----------------------------------------------------------------------
// Group scheduler
// -----------------------------------------------------------------------

// GroupScheduler cycles through 0A and 2A groups.
type GroupScheduler struct {
cfg RDSConfig
psIdx int
rtIdx int
rtABFlag bool
phase int
}

func newGroupScheduler(cfg RDSConfig) *GroupScheduler {
return &GroupScheduler{cfg: cfg}
}

// NextGroup returns the next RDS group as 4 raw 16-bit words.
func (gs *GroupScheduler) NextGroup() [4]uint16 {
// Pattern: 4x 0A (full PS cycle), then N x 2A (full RT cycle), repeat.
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
}

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
}
gs.phase++
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
}

// -----------------------------------------------------------------------
// Differential encoder
// -----------------------------------------------------------------------

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.
type Encoder struct {
config RDSConfig
sampleRate float64
bits []float64
bitRate float64
subFreq float64
amplitude float64

scheduler *GroupScheduler
diff diffEncoder

bitBuf []uint8
bitPos int
bitPhase float64
bitIndex int
subPhase 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 = 48000
}
cfg.PS = normalizePS(cfg.PS)
cfg.RT = normalizeRT(cfg.RT)

bits := buildBits(cfg)
if len(bits) == 0 {
bits = []float64{1}
cfg.SampleRate = 228000
}
cfg.PS = normalizePS(cfg.PS)
cfg.RT = normalizeRT(cfg.RT)

return &Encoder{
enc := &Encoder{
config: cfg,
sampleRate: cfg.SampleRate,
bits: bits,
bitRate: defaultBitRate,
subFreq: defaultSubcarrier,
amplitude: defaultAmplitude,
}, nil
scheduler: newGroupScheduler(cfg),
}
enc.loadNextGroup()
return enc, nil
}

// Reset restarts the encoder phases so Generate outputs from the beginning of the bit stream again.
// Reset restarts the encoder.
func (e *Encoder) Reset() {
e.bitPhase = 0
e.bitIndex = 0
e.subPhase = 0
e.diff = diffEncoder{}
e.scheduler = newGroupScheduler(e.config)
e.bitBuf = nil
e.bitPos = 0
e.loadNextGroup()
}

// Generate produces the requested number of RDS samples.
func (e *Encoder) Generate(samples int) []float64 {
out := make([]float64, samples)
if len(e.bits) == 0 || samples == 0 {
return out
}

for i := 0; i < samples; i++ {
for i := range out {
out[i] = e.nextSample()
}
return out
}

func (e *Encoder) loadNextGroup() {
group := e.scheduler.NextGroup()
e.bitBuf = make([]uint8, 0, bitsPerGroup)
offsets := [4]byte{'A', 'B', 'C', 'D'}
for blk := 0; blk < 4; blk++ {
encoded := encodeBlock(group[blk], offsets[blk])
for bit := bitsPerBlock - 1; bit >= 0; bit-- {
raw := uint8((encoded >> uint(bit)) & 1)
diffBit := e.diff.encode(raw)
e.bitBuf = append(e.bitBuf, diffBit)
}
}
e.bitPos = 0
}

func (e *Encoder) currentSymbol() float64 {
if len(e.bitBuf) == 0 {
return 1
}
if e.bitBuf[e.bitPos] == 0 {
return -1
}
return 1
}

func (e *Encoder) nextSample() float64 {
symbol := e.bits[e.bitIndex]
symbol := e.currentSymbol()
value := e.amplitude * symbol * math.Sin(2*math.Pi*e.subPhase)
e.subPhase += e.subFreq / e.sampleRate

e.subPhase += defaultSubcarrierHz / e.sampleRate
if e.subPhase >= 1 {
e.subPhase -= math.Floor(e.subPhase)
}

e.bitPhase += e.bitRate / e.sampleRate
e.bitPhase += defaultBitRateHz / e.sampleRate
if e.bitPhase >= 1 {
steps := int(e.bitPhase)
e.bitIndex = (e.bitIndex + steps) % len(e.bits)
e.bitPhase -= float64(steps)
}

return value
}

func buildBits(cfg RDSConfig) []float64 {
var bits []float64
bits = append(bits, wordToBits(cfg.PI)...)
status := uint8(cfg.PTY&0x1F) | boolToBit(cfg.TP)<<7 | boolToBit(cfg.TA)<<6
bits = append(bits, byteToBits(status)...)
bits = append(bits, stringToBits(cfg.PS)...)
bits = append(bits, stringToBits(cfg.RT)...)
return bits
}

func wordToBits(word uint16) []float64 {
bits := make([]float64, 0, 16)
for i := 15; i >= 0; i-- {
bits = append(bits, bitToSymbol(uint8((word>>i)&1)))
}
return bits
}

func byteToBits(b uint8) []float64 {
bits := make([]float64, 0, 8)
for i := 7; i >= 0; i-- {
bits = append(bits, bitToSymbol(uint8((b>>i)&1)))
}
return bits
}

func stringToBits(text string) []float64 {
bits := make([]float64, 0, len(text)*8)
for i := 0; i < len(text); i++ {
for bit := 7; bit >= 0; bit-- {
bits = append(bits, bitToSymbol(uint8((text[i]>>bit)&1)))
e.bitPos += steps
if e.bitPos >= len(e.bitBuf) {
e.loadNextGroup()
}
}
return bits
}

func bitToSymbol(bit uint8) float64 {
if bit == 0 {
return -1
}
return 1
}

func boolToBit(value bool) uint8 {
if value {
return 1
}
return 0
return value
}

+ 115
- 24
internal/rds/encoder_test.go Visa fil

@@ -6,62 +6,153 @@ import (
"testing"
)

func TestCRC10KnownVector(t *testing.T) {
// Verify the CRC polynomial produces 10-bit outputs
c := crc10(0x1234)
if c > 0x3FF {
t.Fatalf("CRC exceeds 10 bits: %x", c)
}
}

func TestEncodeBlockProduces26Bits(t *testing.T) {
block := encodeBlock(0x1234, 'A')
// Must fit in 26 bits
if block>>26 != 0 {
t.Fatalf("block exceeds 26 bits: %x", block)
}
// Data portion should be the original word
data := uint16(block >> 10)
if data != 0x1234 {
t.Fatalf("data mismatch: got %x want %x", data, 0x1234)
}
}

func TestBuildGroup0ABlockCount(t *testing.T) {
g := buildGroup0A(0x1234, 0, false, false, 0, "TESTFM")
// Block A must be PI
if g[0] != 0x1234 {
t.Fatalf("block A not PI: %x", g[0])
}
// Block D should contain first two PS chars 'T','E'
ch0 := byte(g[3] >> 8)
ch1 := byte(g[3] & 0xFF)
if ch0 != 'T' || ch1 != 'E' {
t.Fatalf("unexpected PS chars: %c %c", ch0, ch1)
}
}

func TestBuildGroup2ABlockCount(t *testing.T) {
g := buildGroup2A(0x1234, 0, false, false, 0, "Hello World")
if g[0] != 0x1234 {
t.Fatalf("block A not PI: %x", g[0])
}
// Group type field in block B should have type 2 in bits 15..12
groupType := (g[1] >> 12) & 0x0F
if groupType != 2 {
t.Fatalf("unexpected group type: %d", groupType)
}
}

func TestEncoderGenerate(t *testing.T) {
cfg := DefaultConfig()
cfg.SampleRate = 228000
enc, err := NewEncoder(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

samples := enc.Generate(128)
if len(samples) != 128 {
t.Fatalf("expected 128 samples, got %d", len(samples))
samples := enc.Generate(1024)
if len(samples) != 1024 {
t.Fatalf("expected 1024 samples, got %d", len(samples))
}

var max float64
var sum float64
var maxAbs float64
var energy float64
for _, s := range samples {
sum += math.Abs(s)
if math.Abs(s) > max {
max = math.Abs(s)
a := math.Abs(s)
energy += s * s
if a > maxAbs {
maxAbs = a
}
}

if sum == 0 {
t.Fatalf("expected non-zero samples")
if energy == 0 {
t.Fatal("expected non-zero energy in RDS output")
}

if max > 0.1 {
t.Fatalf("samples exceed configured amplitude: %v", max)
if maxAbs > defaultAmplitude*1.01 {
t.Fatalf("samples exceed configured amplitude: %.6f", maxAbs)
}
}

func TestEncoderReset(t *testing.T) {
cfg := DefaultConfig()
cfg.SampleRate = 228000
enc, err := NewEncoder(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

sampleA := enc.Generate(1)[0]
enc.Generate(10)
enc.Generate(100)
enc.Reset()
if sampleB := enc.Generate(1)[0]; math.Abs(sampleA-sampleB) > 1e-9 {
sampleB := enc.Generate(1)[0]
if math.Abs(sampleA-sampleB) > 1e-9 {
t.Fatalf("expected reset to replay initial sample: %v vs %v", sampleA, sampleB)
}
}

func TestGroupSchedulerCycles(t *testing.T) {
cfg := DefaultConfig()
cfg.PS = "TESTPS"
cfg.RT = "short"
gs := newGroupScheduler(cfg)

// Should get 4 PS groups then RT groups then cycle
for i := 0; i < 40; i++ {
_ = gs.NextGroup()
}
// No panic = success
}

func TestNormalizePS(t *testing.T) {
got := normalizePS("radiox")
if got != "RADIOX " {
t.Fatalf("unexpected PS: %q", got)
}
got := normalizePS("radiox")
if got != "RADIOX " {
t.Fatalf("unexpected PS: %q", got)
}
}

func TestNormalizeRT(t *testing.T) {
long := strings.Repeat("a", 80)
got := normalizeRT(long)
if len(got) != 64 {
t.Fatalf("unexpected RT length: %d", len(got))
}
long := strings.Repeat("a", 80)
got := normalizeRT(long)
if len(got) != 64 {
t.Fatalf("unexpected RT length: %d", len(got))
}
}

func TestDifferentialEncoder(t *testing.T) {
d := diffEncoder{}
// Input: 0 -> out = 0^0 = 0, prev=0
// Input: 1 -> out = 0^1 = 1, prev=1
// Input: 0 -> out = 1^0 = 1, prev=1
// Input: 1 -> out = 1^1 = 0, prev=0
expected := []uint8{0, 1, 1, 0}
input := []uint8{0, 1, 0, 1}
for i, in := range input {
got := d.encode(in)
if got != expected[i] {
t.Fatalf("step %d: input=%d expected=%d got=%d", i, in, expected[i], got)
}
}
}

func TestRTSegmentCount(t *testing.T) {
if n := rtSegmentCount("Hi"); n != 1 {
t.Fatalf("expected 1, got %d", n)
}
if n := rtSegmentCount("Hello World!"); n != 3 {
t.Fatalf("expected 3, got %d", n)
}
if n := rtSegmentCount(strings.Repeat("x", 64)); n != 16 {
t.Fatalf("expected 16, got %d", n)
}
}

+ 15
- 5
internal/stereo/encoder.go Visa fil

@@ -8,13 +8,16 @@ import (
// Components holds the individual MPX components produced by the stereo encoder.
type Components struct {
Mono float64 // L+R baseband
Stereo float64 // L-R baseband on suppressed carrier
Stereo float64 // L-R modulated onto 38 kHz DSB-SC
Pilot float64 // 19 kHz pilot tone
}

// StereoEncoder generates stereo MPX primitives from stereo audio frames.
// It internally maintains phase-coherent 19 kHz pilot and 38 kHz subcarrier
// oscillators so that block-boundary phase continuity is guaranteed.
type StereoEncoder struct {
pilot dsp.PilotGenerator
subcarrier dsp.Oscillator // 38 kHz, phase-locked to pilot (2× pilot)
LevelStereo float64
}

@@ -22,20 +25,27 @@ type StereoEncoder struct {
func NewStereoEncoder(sampleRate float64) StereoEncoder {
return StereoEncoder{
pilot: dsp.NewPilotGenerator(sampleRate, 0.1),
LevelStereo: 0.75,
subcarrier: dsp.Oscillator{Frequency: 38000, SampleRate: sampleRate},
LevelStereo: 1.0,
}
}

// Encode converts a stereo frame into MPX components.
// The 38 kHz subcarrier is generated from the internal oscillator,
// maintaining continuous phase across calls.
func (s *StereoEncoder) Encode(frame audio.Frame) Components {
pilot := s.pilot.Sample()
sub38 := s.subcarrier.Tick()

return Components{
Mono: float64(frame.Mono()),
Stereo: float64(frame.Difference()) * s.LevelStereo,
Pilot: s.pilot.Sample(),
Stereo: float64(frame.Difference()) * s.LevelStereo * sub38,
Pilot: pilot,
}
}

// Reset restarts the pilot generator phase.
// Reset restarts the pilot and subcarrier generators.
func (s *StereoEncoder) Reset() {
s.pilot.Reset()
s.subcarrier.Reset()
}

+ 39
- 7
internal/stereo/encoder_test.go Visa fil

@@ -8,7 +8,7 @@ import (
)

func TestStereoEncoderEncode(t *testing.T) {
enc := NewStereoEncoder(48000)
enc := NewStereoEncoder(228000)
frame := audio.NewFrame(1, -1)
result := enc.Encode(frame)

@@ -16,19 +16,51 @@ func TestStereoEncoderEncode(t *testing.T) {
t.Fatalf("expected mono 0, got %v", diff)
}

expected := 0.75 * ((1 - (-1)) / 2.0)
if math.Abs(result.Stereo-expected) > 1e-9 {
t.Fatalf("unexpected stereo level: %v", result.Stereo)
// Stereo should be non-zero for L!=R input
// (exact value depends on oscillator phase at sample 0)
// We just verify it's modulated (could be zero at phase=0 for sin)
// Run a few samples and check some are non-zero
var maxStereo float64
for i := 0; i < 100; i++ {
c := enc.Encode(frame)
if math.Abs(c.Stereo) > maxStereo {
maxStereo = math.Abs(c.Stereo)
}
}
if maxStereo < 0.1 {
t.Fatalf("expected non-trivial stereo signal, maxStereo=%.6f", maxStereo)
}
}

func TestStereoEncoderMonoSignal(t *testing.T) {
enc := NewStereoEncoder(228000)
// Identical L and R should produce zero difference/stereo
frame := audio.NewFrame(0.5, 0.5)
for i := 0; i < 100; i++ {
c := enc.Encode(frame)
if math.Abs(c.Stereo) > 1e-12 {
t.Fatalf("expected zero stereo for mono input, got %.9f", c.Stereo)
}
if math.Abs(c.Mono-0.5) > 1e-9 {
t.Fatalf("expected mono=0.5, got %.9f", c.Mono)
}
}
}

if result.Pilot < -0.1 || result.Pilot > 0.1 {
t.Fatalf("pilot sample out of expected range: %v", result.Pilot)
func TestStereoEncoderPilotRange(t *testing.T) {
enc := NewStereoEncoder(228000)
frame := audio.NewFrame(0.1, -0.1)
for i := 0; i < 1000; i++ {
c := enc.Encode(frame)
if c.Pilot < -0.101 || c.Pilot > 0.101 {
t.Fatalf("pilot out of range: %.6f", c.Pilot)
}
}
}

func TestStereoEncoderReset(t *testing.T) {
frame := audio.NewFrame(0.1, -0.1)
enc := NewStereoEncoder(48000)
enc := NewStereoEncoder(228000)

initial := make([]float64, 0, 4)
for i := 0; i < 4; i++ {


Laddar…
Avbryt
Spara