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