| @@ -1,6 +1,6 @@ | |||||
| module github.com/jan/fm-rds-tx | module github.com/jan/fm-rds-tx | ||||
| go 1.24.0 | |||||
| go 1.22 | |||||
| require github.com/jan/fm-rds-tx/internal v0.0.0 | require github.com/jan/fm-rds-tx/internal v0.0.0 | ||||
| @@ -0,0 +1,46 @@ | |||||
| package dsp | |||||
| import "math" | |||||
| // FMModulator converts a composite baseband signal into FM-modulated IQ samples. | |||||
| // The modulated output represents the instantaneous frequency deviation around | |||||
| // a zero-IF carrier (baseband IQ). For a real SDR transmitter the SDR hardware | |||||
| // then upconverts this to the desired center frequency. | |||||
| type FMModulator struct { | |||||
| // MaxDeviation is the peak frequency deviation in Hz (±75 kHz for FM broadcast). | |||||
| MaxDeviation float64 | |||||
| SampleRate float64 | |||||
| phase float64 // accumulated carrier phase in radians | |||||
| } | |||||
| // NewFMModulator creates a modulator with broadcast FM defaults. | |||||
| func NewFMModulator(sampleRate float64) *FMModulator { | |||||
| return &FMModulator{ | |||||
| MaxDeviation: 75000, // ±75 kHz | |||||
| SampleRate: sampleRate, | |||||
| } | |||||
| } | |||||
| // Modulate converts a single composite sample (normalized to [-1,+1] representing | |||||
| // full deviation) into an IQ pair. | |||||
| func (m *FMModulator) Modulate(composite float64) (i, q float64) { | |||||
| // Instantaneous frequency offset = composite * maxDeviation | |||||
| // Phase increment per sample = 2π * freq_offset / sampleRate | |||||
| freqOffset := composite * m.MaxDeviation | |||||
| m.phase += 2 * math.Pi * freqOffset / m.SampleRate | |||||
| // Keep phase bounded to avoid float64 precision loss over long runs | |||||
| if m.phase > math.Pi { | |||||
| m.phase -= 2 * math.Pi * math.Floor((m.phase+math.Pi)/(2*math.Pi)) | |||||
| } | |||||
| i = math.Cos(m.phase) | |||||
| q = math.Sin(m.phase) | |||||
| return i, q | |||||
| } | |||||
| // Reset clears the modulator phase. | |||||
| func (m *FMModulator) Reset() { | |||||
| m.phase = 0 | |||||
| } | |||||
| @@ -0,0 +1,41 @@ | |||||
| package dsp | |||||
| import ( | |||||
| "math" | |||||
| "testing" | |||||
| ) | |||||
| func TestFMModulatorOutputMagnitude(t *testing.T) { | |||||
| mod := NewFMModulator(228000) | |||||
| // IQ output should always have magnitude ~1 | |||||
| for n := 0; n < 1000; n++ { | |||||
| composite := math.Sin(2 * math.Pi * 1000 * float64(n) / 228000) | |||||
| i, q := mod.Modulate(composite) | |||||
| mag := math.Sqrt(i*i + q*q) | |||||
| if math.Abs(mag-1.0) > 1e-9 { | |||||
| t.Fatalf("sample %d: magnitude=%.9f, expected 1.0", n, mag) | |||||
| } | |||||
| } | |||||
| } | |||||
| func TestFMModulatorZeroInput(t *testing.T) { | |||||
| mod := NewFMModulator(228000) | |||||
| // Zero input = no deviation = phase stays at 0 | |||||
| for n := 0; n < 100; n++ { | |||||
| i, q := mod.Modulate(0) | |||||
| if math.Abs(i-1.0) > 1e-9 || math.Abs(q) > 1e-9 { | |||||
| t.Fatalf("sample %d: expected (1,0) got (%.6f,%.6f)", n, i, q) | |||||
| } | |||||
| } | |||||
| } | |||||
| func TestFMModulatorReset(t *testing.T) { | |||||
| mod := NewFMModulator(228000) | |||||
| mod.Modulate(0.5) | |||||
| mod.Modulate(0.5) | |||||
| mod.Reset() | |||||
| i, q := mod.Modulate(0) | |||||
| if math.Abs(i-1.0) > 1e-9 || math.Abs(q) > 1e-9 { | |||||
| t.Fatalf("after reset: expected (1,0) got (%.6f,%.6f)", i, q) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,70 @@ | |||||
| package dsp | |||||
| import "math" | |||||
| // MPXLimiter is a look-ahead peak limiter for the composite MPX signal. | |||||
| // It prevents overmodulation by keeping the composite output within | |||||
| // the configured ceiling. The limiter applies gain reduction smoothly | |||||
| // to avoid introducing audible artifacts. | |||||
| type MPXLimiter struct { | |||||
| ceiling float64 // maximum absolute output level (e.g. 1.0) | |||||
| attackCoeff float64 // attack smoothing coefficient | |||||
| releaseCoeff float64 // release smoothing coefficient | |||||
| gainReduction float64 // current gain reduction in linear scale (0 = no reduction) | |||||
| } | |||||
| // NewMPXLimiter creates a limiter with the given ceiling, attack and release | |||||
| // times in milliseconds, and sample rate. | |||||
| func NewMPXLimiter(ceiling, attackMs, releaseMs, sampleRate float64) *MPXLimiter { | |||||
| if ceiling <= 0 { | |||||
| ceiling = 1.0 | |||||
| } | |||||
| if attackMs <= 0 { | |||||
| attackMs = 0.1 // fast attack for MPX | |||||
| } | |||||
| if releaseMs <= 0 { | |||||
| releaseMs = 50 // moderate release | |||||
| } | |||||
| attackSamples := attackMs * sampleRate / 1000 | |||||
| releaseSamples := releaseMs * sampleRate / 1000 | |||||
| return &MPXLimiter{ | |||||
| ceiling: ceiling, | |||||
| attackCoeff: 1.0 - math.Exp(-1.0/attackSamples), | |||||
| releaseCoeff: 1.0 - math.Exp(-1.0/releaseSamples), | |||||
| } | |||||
| } | |||||
| // Process applies limiting to a single composite sample. | |||||
| func (l *MPXLimiter) Process(in float64) float64 { | |||||
| absIn := math.Abs(in) | |||||
| targetReduction := 0.0 | |||||
| if absIn > l.ceiling { | |||||
| targetReduction = 1.0 - l.ceiling/absIn | |||||
| } | |||||
| if targetReduction > l.gainReduction { | |||||
| l.gainReduction += l.attackCoeff * (targetReduction - l.gainReduction) | |||||
| } else { | |||||
| l.gainReduction += l.releaseCoeff * (targetReduction - l.gainReduction) | |||||
| } | |||||
| gain := 1.0 - l.gainReduction | |||||
| return in * gain | |||||
| } | |||||
| // Reset clears the limiter state. | |||||
| func (l *MPXLimiter) Reset() { | |||||
| l.gainReduction = 0 | |||||
| } | |||||
| // HardClip provides a simple hard clipper as a safety net after the limiter. | |||||
| func HardClip(sample, ceiling float64) float64 { | |||||
| if sample > ceiling { | |||||
| return ceiling | |||||
| } | |||||
| if sample < -ceiling { | |||||
| return -ceiling | |||||
| } | |||||
| return sample | |||||
| } | |||||
| @@ -0,0 +1,46 @@ | |||||
| package dsp | |||||
| import ( | |||||
| "math" | |||||
| "testing" | |||||
| ) | |||||
| func TestMPXLimiterPassesQuiet(t *testing.T) { | |||||
| lim := NewMPXLimiter(1.0, 0.1, 50, 228000) | |||||
| for i := 0; i < 100; i++ { | |||||
| in := 0.3 * math.Sin(float64(i)) | |||||
| out := lim.Process(in) | |||||
| if math.Abs(out-in) > 1e-9 { | |||||
| t.Fatalf("limiter altered quiet signal at sample %d: in=%.6f out=%.6f", i, in, out) | |||||
| } | |||||
| } | |||||
| } | |||||
| func TestMPXLimiterClamps(t *testing.T) { | |||||
| lim := NewMPXLimiter(1.0, 0.01, 50, 228000) | |||||
| // Feed a signal well above ceiling | |||||
| var maxOut float64 | |||||
| for i := 0; i < 10000; i++ { | |||||
| in := 3.0 * math.Sin(2*math.Pi*1000*float64(i)/228000) | |||||
| out := lim.Process(in) | |||||
| if math.Abs(out) > maxOut { | |||||
| maxOut = math.Abs(out) | |||||
| } | |||||
| } | |||||
| // After attack settles, output should approach ceiling | |||||
| if maxOut > 1.5 { | |||||
| t.Fatalf("limiter didn't reduce level enough: maxOut=%.4f", maxOut) | |||||
| } | |||||
| } | |||||
| func TestHardClip(t *testing.T) { | |||||
| if HardClip(1.5, 1.0) != 1.0 { | |||||
| t.Fatal("expected clip to 1.0") | |||||
| } | |||||
| if HardClip(-1.5, 1.0) != -1.0 { | |||||
| t.Fatal("expected clip to -1.0") | |||||
| } | |||||
| if HardClip(0.5, 1.0) != 0.5 { | |||||
| t.Fatal("expected passthrough") | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,94 @@ | |||||
| package dsp | |||||
| import "math" | |||||
| // PreEmphasis implements a first-order high-shelf filter for FM broadcast | |||||
| // pre-emphasis. Standard time constants are 50 µs (Europe/world) and | |||||
| // 75 µs (North America/South Korea). | |||||
| // | |||||
| // Transfer function: H(s) = 1 + s*τ | |||||
| // Bilinear transform to discrete: H(z) = (b0 + b1*z^-1) / (1 + a1*z^-1) | |||||
| type PreEmphasis struct { | |||||
| b0, b1, a1 float64 | |||||
| x1, y1 float64 // state | |||||
| enabled bool | |||||
| } | |||||
| // NewPreEmphasis creates a pre-emphasis filter for the given time constant | |||||
| // and sample rate. tau is in microseconds (50 or 75). | |||||
| func NewPreEmphasis(tauMicroseconds, sampleRate float64) *PreEmphasis { | |||||
| if tauMicroseconds <= 0 || sampleRate <= 0 { | |||||
| return &PreEmphasis{enabled: false} | |||||
| } | |||||
| tau := tauMicroseconds * 1e-6 | |||||
| // Bilinear transform of H(s) = 1 + s*tau | |||||
| // With pre-warping: let c = 2*fs | |||||
| // Numerator: (1 + tau*c) + (-1 + tau*c)*z^-1 => b0 = 1+tau*c, b1 = -1+tau*c | |||||
| // Denominator: (1 + tau*c) + (1 - tau*c)*z^-1 (but we normalize so a0=1) | |||||
| // Wait - cleaner approach: standard first-order shelf. | |||||
| // H(s) = (1 + s*tau) mapped with bilinear: s = c*(1 - z^-1)/(1 + z^-1), c = 2*fs | |||||
| // De-emphasis: H_de(z) = (1-alpha)/(1 - alpha*z^-1), alpha = exp(-1/(tau*fs)) | |||||
| // Pre-emphasis: H_pre(z) = 1/H_de(z) = (1 - alpha*z^-1)/(1-alpha) | |||||
| // y[n] = (x[n] - alpha*x[n-1]) / (1 - alpha) | |||||
| alpha := math.Exp(-1.0 / (tau * sampleRate)) | |||||
| gain := 1.0 / (1.0 - alpha) | |||||
| return &PreEmphasis{ | |||||
| b0: gain, | |||||
| b1: -alpha * gain, | |||||
| a1: 0, // FIR, no feedback | |||||
| enabled: true, | |||||
| } | |||||
| } | |||||
| // Process applies the pre-emphasis filter to a single sample. | |||||
| func (p *PreEmphasis) Process(in float64) float64 { | |||||
| if !p.enabled { | |||||
| return in | |||||
| } | |||||
| out := p.b0*in + p.b1*p.x1 | |||||
| p.x1 = in | |||||
| return out | |||||
| } | |||||
| // Reset clears the filter state. | |||||
| func (p *PreEmphasis) Reset() { | |||||
| p.x1 = 0 | |||||
| p.y1 = 0 | |||||
| } | |||||
| // DeEmphasis implements the complementary de-emphasis filter. | |||||
| // H(z) = (1-alpha)/(1 - alpha*z^-1) | |||||
| type DeEmphasis struct { | |||||
| alpha float64 | |||||
| gain float64 | |||||
| prevOut float64 | |||||
| enabled bool | |||||
| } | |||||
| // NewDeEmphasis creates a de-emphasis filter. | |||||
| func NewDeEmphasis(tauMicroseconds, sampleRate float64) *DeEmphasis { | |||||
| if tauMicroseconds <= 0 || sampleRate <= 0 { | |||||
| return &DeEmphasis{enabled: false} | |||||
| } | |||||
| tau := tauMicroseconds * 1e-6 | |||||
| alpha := math.Exp(-1.0 / (tau * sampleRate)) | |||||
| return &DeEmphasis{alpha: alpha, gain: 1.0 - alpha, enabled: true} | |||||
| } | |||||
| // Process applies the de-emphasis filter. | |||||
| func (d *DeEmphasis) Process(in float64) float64 { | |||||
| if !d.enabled { | |||||
| return in | |||||
| } | |||||
| out := d.gain*in + d.alpha*d.prevOut | |||||
| d.prevOut = out | |||||
| return out | |||||
| } | |||||
| // Reset clears the filter state. | |||||
| func (d *DeEmphasis) Reset() { | |||||
| d.prevOut = 0 | |||||
| } | |||||
| @@ -0,0 +1,72 @@ | |||||
| package dsp | |||||
| import ( | |||||
| "math" | |||||
| "testing" | |||||
| ) | |||||
| func TestPreEmphasisBoostsHighFrequency(t *testing.T) { | |||||
| fs := 48000.0 | |||||
| pe := NewPreEmphasis(50, fs) | |||||
| // Generate a low-frequency tone and a high-frequency tone, | |||||
| // measure the energy ratio after pre-emphasis. | |||||
| n := 4800 // 100ms | |||||
| lowFreq := 100.0 | |||||
| highFreq := 10000.0 | |||||
| var lowEnergy, highEnergy float64 | |||||
| for i := 0; i < n; i++ { | |||||
| s := math.Sin(2 * math.Pi * lowFreq * float64(i) / fs) | |||||
| out := pe.Process(s) | |||||
| lowEnergy += out * out | |||||
| } | |||||
| pe.Reset() | |||||
| for i := 0; i < n; i++ { | |||||
| s := math.Sin(2 * math.Pi * highFreq * float64(i) / fs) | |||||
| out := pe.Process(s) | |||||
| highEnergy += out * out | |||||
| } | |||||
| // High frequency should have more energy than low frequency | |||||
| // after pre-emphasis. The input energy is the same for both. | |||||
| ratio := highEnergy / lowEnergy | |||||
| if ratio < 2.0 { | |||||
| t.Fatalf("expected high-freq boost, energy ratio=%.2f", ratio) | |||||
| } | |||||
| } | |||||
| func TestPreEmphasisDeEmphasisRoundtrip(t *testing.T) { | |||||
| fs := 48000.0 | |||||
| pe := NewPreEmphasis(50, fs) | |||||
| de := NewDeEmphasis(50, fs) | |||||
| // Run a 1kHz tone through pre+de, should approximately recover | |||||
| n := 4800 | |||||
| freq := 1000.0 | |||||
| var maxErr float64 | |||||
| // Let filters settle for 200 samples | |||||
| for i := 0; i < 200; i++ { | |||||
| s := math.Sin(2 * math.Pi * freq * float64(i) / fs) | |||||
| de.Process(pe.Process(s)) | |||||
| } | |||||
| for i := 200; i < n; i++ { | |||||
| s := math.Sin(2 * math.Pi * freq * float64(i) / fs) | |||||
| recovered := de.Process(pe.Process(s)) | |||||
| err := math.Abs(recovered - s) | |||||
| if err > maxErr { | |||||
| maxErr = err | |||||
| } | |||||
| } | |||||
| if maxErr > 0.05 { | |||||
| t.Fatalf("roundtrip error too large: %.4f", maxErr) | |||||
| } | |||||
| } | |||||
| func TestDisabledPreEmphasis(t *testing.T) { | |||||
| pe := NewPreEmphasis(0, 48000) | |||||
| if pe.Process(0.5) != 0.5 { | |||||
| t.Fatal("disabled filter should pass through") | |||||
| } | |||||
| } | |||||