| @@ -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() | |||||
| } | |||||
| @@ -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() | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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, | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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() | |||||
| } | |||||
| @@ -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]) | |||||
| } | |||||
| } | |||||
| } | |||||