Explorar el Código

feat: add mp x pipeline primitives

tags/v0.3.0-pre
Jan Svabenik hace 1 mes
padre
commit
d92f2b0fc9
Se han modificado 9 ficheros con 464 adiciones y 0 borrados
  1. +46
    -0
      internal/audio/audio.go
  2. +53
    -0
      internal/dsp/oscillator.go
  3. +29
    -0
      internal/mpx/combiner.go
  4. +22
    -0
      internal/mpx/combiner_test.go
  5. +38
    -0
      internal/rds/config.go
  6. +134
    -0
      internal/rds/encoder.go
  7. +51
    -0
      internal/rds/encoder_test.go
  8. +41
    -0
      internal/stereo/encoder.go
  9. +50
    -0
      internal/stereo/encoder_test.go

+ 46
- 0
internal/audio/audio.go Ver fichero

@@ -0,0 +1,46 @@
package audio

// Sample represents a normalized audio sample in the range [-1, +1].
type Sample float64

const (
SampleMin Sample = -1.0
SampleMax Sample = 1.0
)

// Frame is a stereo pair of audio samples.
type Frame struct {
L Sample
R Sample
}

// NewFrame creates a Frame from the provided left/right samples.
func NewFrame(l, r Sample) Frame {
return Frame{L: l, R: r}
}

// Mono returns the (L+R)/2 signal used in MPX generation.
func (f Frame) Mono() Sample {
return (f.L + f.R) / 2
}

// Difference returns the (L-R)/2 signal used for the stereo subcarrier.
func (f Frame) Difference() Sample {
return (f.L - f.R) / 2
}

// Clamp ensures the sample stays within the legal range.
func (s Sample) Clamp() Sample {
if s > SampleMax {
return SampleMax
}
if s < SampleMin {
return SampleMin
}
return s
}

// Scale adjusts the sample by a gain factor while keeping the result clamped.
func (s Sample) Scale(gain float64) Sample {
return Sample(float64(s) * gain).Clamp()
}

+ 53
- 0
internal/dsp/oscillator.go Ver fichero

@@ -0,0 +1,53 @@
package dsp

import "math"

// Oscillator produces a sine wave at a configured frequency and sample rate.
type Oscillator struct {
Frequency float64
SampleRate float64
phase float64
}

// Tick advances the oscillator by one sample and returns the current sine value.
func (o *Oscillator) Tick() float64 {
if o.SampleRate <= 0 || o.Frequency == 0 {
return 0
}
value := math.Sin(2 * math.Pi * o.phase)
step := o.Frequency / o.SampleRate
o.phase += step
if o.phase >= 1 {
o.phase -= math.Floor(o.phase)
}
return value
}

// Reset brings the phase back to zero.
func (o *Oscillator) Reset() {
o.phase = 0
}

// Phase returns the current phase of the oscillator in [0, 1).
func (o *Oscillator) Phase() float64 {
return o.phase
}

// PilotGenerator emits the 19 kHz pilot tone required by FM stereo.
type PilotGenerator struct {
Oscillator
Level float64
}

// NewPilotGenerator constructs a pilot tone generator for the given sample rate and level.
func NewPilotGenerator(sampleRate, level float64) PilotGenerator {
return PilotGenerator{
Oscillator: Oscillator{Frequency: 19000, SampleRate: sampleRate},
Level: level,
}
}

// Sample returns the next pilot sample.
func (p *PilotGenerator) Sample() float64 {
return p.Level * p.Oscillator.Tick()
}

+ 29
- 0
internal/mpx/combiner.go Ver fichero

@@ -0,0 +1,29 @@
package mpx

// Combiner defines the interface used to merge MPX primitives into a composite waveform.
type Combiner interface {
Combine(mono, stereo, pilot, rds float64) float64
}

// DefaultCombiner combines components with configurable gains.
type DefaultCombiner struct {
MonoGain float64
StereoGain float64
PilotGain float64
RDSGain float64
}

// NewDefaultCombiner creates a combiner with sane default gains.
func NewDefaultCombiner() DefaultCombiner {
return DefaultCombiner{
MonoGain: 1,
StereoGain: 1,
PilotGain: 1,
RDSGain: 1,
}
}

// Combine merges the provided MPX components.
func (c DefaultCombiner) Combine(mono, stereo, pilot, rds float64) float64 {
return c.MonoGain*mono + c.StereoGain*stereo + c.PilotGain*pilot + c.RDSGain*rds
}

+ 22
- 0
internal/mpx/combiner_test.go Ver fichero

@@ -0,0 +1,22 @@
package mpx

import (
"math"
"testing"
)

func TestDefaultCombinerCombine(t *testing.T) {
comb := NewDefaultCombiner()
value := comb.Combine(1, 0.5, 0.2, -0.1)
if math.Abs(value-1.6) > 1e-9 {
t.Fatalf("unexpected combined value: %v", value)
}
}

func TestCustomGains(t *testing.T) {
comb := DefaultCombiner{MonoGain: 0.5, StereoGain: 2, PilotGain: 0, RDSGain: -1}
result := comb.Combine(1, 1, 1, 1)
if math.Abs(result-(0.5+2+0-1)) > 1e-9 {
t.Fatalf("custom gains not applied: %v", result)
}
}

+ 38
- 0
internal/rds/config.go Ver fichero

@@ -0,0 +1,38 @@
package rds

// RDSConfig holds configuration data used to build the RDS data stream.
type RDSConfig struct {
// Program Identification – 16-bit school identifier for the broadcast.
PI uint16

// Program Service name (typically 8 ASCII characters).
PS string

// RadioText (up to 64 characters). Short messages describing the current song or info.
RT string

// Program Type (0-31 standard RDS PTY values).
PTY uint8

// Traffic Announcement (TA) flag.
TA bool

// Traffic Program (TP) flag.
TP bool

// SampleRate that the encoder will work against. Defaults to 48000 when zero.
SampleRate float64
}

// DefaultConfig returns a minimal config with sane defaults.
func DefaultConfig() RDSConfig {
return RDSConfig{
PI: 0x1234,
PS: "FM-RDS",
RT: "Go-based MPX",
PTY: 0,
TA: false,
TP: false,
SampleRate: 48000,
}
}

+ 134
- 0
internal/rds/encoder.go Ver fichero

@@ -0,0 +1,134 @@
package rds

import (
"math"
)

const (
defaultBitRate = 1187.5
defaultSubcarrier = 57000
defaultAmplitude = 0.02
)

// Encoder emits a simple BPSK-like RDS subcarrier stream for offline MPX builds.
type Encoder struct {
config RDSConfig
sampleRate float64
bits []float64
bitRate float64
subFreq float64
amplitude float64

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
}

bits := buildBits(cfg)
if len(bits) == 0 {
bits = []float64{1}
}

return &Encoder{
config: cfg,
sampleRate: cfg.SampleRate,
bits: bits,
bitRate: defaultBitRate,
subFreq: defaultSubcarrier,
amplitude: defaultAmplitude,
}, nil
}

// Reset restarts the encoder phases so Generate outputs from the beginning of the bit stream again.
func (e *Encoder) Reset() {
e.bitPhase = 0
e.bitIndex = 0
e.subPhase = 0
}

// 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++ {
out[i] = e.nextSample()
}
return out
}

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

e.bitPhase += e.bitRate / 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)))
}
}
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
}

+ 51
- 0
internal/rds/encoder_test.go Ver fichero

@@ -0,0 +1,51 @@
package rds

import (
"math"
"testing"
)

func TestEncoderGenerate(t *testing.T) {
cfg := DefaultConfig()
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))
}

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

if sum == 0 {
t.Fatalf("expected non-zero samples")
}

if max > 0.1 {
t.Fatalf("samples exceed configured amplitude: %v", max)
}
}

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

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

+ 41
- 0
internal/stereo/encoder.go Ver fichero

@@ -0,0 +1,41 @@
package stereo

import (
"github.com/jan/fm-rds-tx/internal/audio"
"github.com/jan/fm-rds-tx/internal/dsp"
)

// 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
Pilot float64 // 19 kHz pilot tone
}

// StereoEncoder generates stereo MPX primitives from stereo audio frames.
type StereoEncoder struct {
pilot dsp.PilotGenerator
LevelStereo float64
}

// NewStereoEncoder creates a StereoEncoder configured for the provided sample rate.
func NewStereoEncoder(sampleRate float64) StereoEncoder {
return StereoEncoder{
pilot: dsp.NewPilotGenerator(sampleRate, 0.1),
LevelStereo: 0.75,
}
}

// Encode converts a stereo frame into MPX components.
func (s *StereoEncoder) Encode(frame audio.Frame) Components {
return Components{
Mono: float64(frame.Mono()),
Stereo: float64(frame.Difference()) * s.LevelStereo,
Pilot: s.pilot.Sample(),
}
}

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

+ 50
- 0
internal/stereo/encoder_test.go Ver fichero

@@ -0,0 +1,50 @@
package stereo

import (
"math"
"testing"

"github.com/jan/fm-rds-tx/internal/audio"
)

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

if diff := result.Mono; math.Abs(diff) > 1e-9 {
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)
}

if result.Pilot < -0.1 || result.Pilot > 0.1 {
t.Fatalf("pilot sample out of expected range: %v", result.Pilot)
}
}

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

initial := make([]float64, 0, 4)
for i := 0; i < 4; i++ {
initial = append(initial, enc.Encode(frame).Pilot)
}

enc.Reset()

afterReset := make([]float64, 0, 4)
for i := 0; i < 4; i++ {
afterReset = append(afterReset, enc.Encode(frame).Pilot)
}

for i := range initial {
if math.Abs(initial[i]-afterReset[i]) > 1e-9 {
t.Fatalf("reset failed at sample %d: %v vs %v", i, initial[i], afterReset[i])
}
}
}

Cargando…
Cancelar
Guardar