diff --git a/internal/audio/audio.go b/internal/audio/audio.go new file mode 100644 index 0000000..4328ded --- /dev/null +++ b/internal/audio/audio.go @@ -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() +} diff --git a/internal/dsp/oscillator.go b/internal/dsp/oscillator.go new file mode 100644 index 0000000..f432eb6 --- /dev/null +++ b/internal/dsp/oscillator.go @@ -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() +} diff --git a/internal/mpx/combiner.go b/internal/mpx/combiner.go new file mode 100644 index 0000000..efe5391 --- /dev/null +++ b/internal/mpx/combiner.go @@ -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 +} diff --git a/internal/mpx/combiner_test.go b/internal/mpx/combiner_test.go new file mode 100644 index 0000000..ed0b9a9 --- /dev/null +++ b/internal/mpx/combiner_test.go @@ -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) + } +} diff --git a/internal/rds/config.go b/internal/rds/config.go new file mode 100644 index 0000000..9c8c140 --- /dev/null +++ b/internal/rds/config.go @@ -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, + } +} diff --git a/internal/rds/encoder.go b/internal/rds/encoder.go new file mode 100644 index 0000000..1a62279 --- /dev/null +++ b/internal/rds/encoder.go @@ -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 +} diff --git a/internal/rds/encoder_test.go b/internal/rds/encoder_test.go new file mode 100644 index 0000000..964acee --- /dev/null +++ b/internal/rds/encoder_test.go @@ -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) + } +} diff --git a/internal/stereo/encoder.go b/internal/stereo/encoder.go new file mode 100644 index 0000000..d021bdc --- /dev/null +++ b/internal/stereo/encoder.go @@ -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() +} diff --git a/internal/stereo/encoder_test.go b/internal/stereo/encoder_test.go new file mode 100644 index 0000000..453de1a --- /dev/null +++ b/internal/stereo/encoder_test.go @@ -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]) + } + } +}