| @@ -0,0 +1,31 @@ | |||
| package dsp | |||
| import "math" | |||
| // GoertzelEnergy computes the energy at a specific frequency in a block | |||
| // of samples using the Goertzel algorithm. Returns the magnitude squared. | |||
| func GoertzelEnergy(samples []float64, sampleRate, targetFreqHz float64) float64 { | |||
| n := len(samples) | |||
| if n == 0 { | |||
| return 0 | |||
| } | |||
| k := int(0.5 + float64(n)*targetFreqHz/sampleRate) | |||
| w := 2 * math.Pi * float64(k) / float64(n) | |||
| coeff := 2 * math.Cos(w) | |||
| var s0, s1, s2 float64 | |||
| for _, x := range samples { | |||
| s0 = x + coeff*s1 - s2 | |||
| s2 = s1 | |||
| s1 = s0 | |||
| } | |||
| return s1*s1 + s2*s2 - coeff*s1*s2 | |||
| } | |||
| // BandEnergy computes aggregate energy in a frequency band by averaging | |||
| // Goertzel results at the center and ±span/4 offsets. | |||
| func BandEnergy(samples []float64, sampleRate, centerHz, spanHz float64) float64 { | |||
| e0 := GoertzelEnergy(samples, sampleRate, centerHz) | |||
| e1 := GoertzelEnergy(samples, sampleRate, centerHz-spanHz/4) | |||
| e2 := GoertzelEnergy(samples, sampleRate, centerHz+spanHz/4) | |||
| return (e0 + e1 + e2) / 3 | |||
| } | |||
| @@ -0,0 +1,28 @@ | |||
| package dsp | |||
| import ( | |||
| "math" | |||
| "testing" | |||
| ) | |||
| func TestGoertzelDetectsTone(t *testing.T) { | |||
| fs := 228000.0 | |||
| n := 22800 // 100ms | |||
| freq := 19000.0 | |||
| samples := make([]float64, n) | |||
| for i := range samples { | |||
| samples[i] = math.Sin(2 * math.Pi * freq * float64(i) / fs) | |||
| } | |||
| energy := GoertzelEnergy(samples, fs, freq) | |||
| noiseEnergy := GoertzelEnergy(samples, fs, 5000) | |||
| if energy < noiseEnergy*100 { | |||
| t.Fatalf("expected strong energy at %.0f Hz: got %.4f vs noise %.4f", freq, energy, noiseEnergy) | |||
| } | |||
| } | |||
| func TestGoertzelSilence(t *testing.T) { | |||
| samples := make([]float64, 1000) | |||
| if GoertzelEnergy(samples, 228000, 19000) > 1e-12 { | |||
| t.Fatal("expected near-zero energy for silence") | |||
| } | |||
| } | |||
| @@ -34,20 +34,20 @@ func (o *Oscillator) Phase() float64 { | |||
| } | |||
| // PilotGenerator emits the 19 kHz pilot tone required by FM stereo. | |||
| // Output is unity-normalized (peak ±1.0). The caller controls the | |||
| // actual injection level via the combiner gain. | |||
| 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 { | |||
| // NewPilotGenerator constructs a pilot tone generator for the given sample rate. | |||
| func NewPilotGenerator(sampleRate float64) PilotGenerator { | |||
| return PilotGenerator{ | |||
| Oscillator: Oscillator{Frequency: 19000, SampleRate: sampleRate}, | |||
| Level: level, | |||
| } | |||
| } | |||
| // Sample returns the next pilot sample. | |||
| // Sample returns the next pilot sample (unity amplitude). | |||
| func (p *PilotGenerator) Sample() float64 { | |||
| return p.Level * p.Oscillator.Tick() | |||
| return p.Oscillator.Tick() | |||
| } | |||
| @@ -5,8 +5,6 @@ import ( | |||
| "encoding/binary" | |||
| "fmt" | |||
| "path/filepath" | |||
| "strconv" | |||
| "strings" | |||
| "time" | |||
| "github.com/jan/fm-rds-tx/internal/audio" | |||
| @@ -22,6 +20,36 @@ type frameSource interface { | |||
| NextFrame() audio.Frame | |||
| } | |||
| // PreEmphasizedSource wraps an audio source and applies pre-emphasis at the | |||
| // audio input rate, before upsampling to composite rate. This is more | |||
| // efficient than filtering at composite rate and is the correct signal path. | |||
| type PreEmphasizedSource struct { | |||
| src frameSource | |||
| preL *dsp.PreEmphasis | |||
| preR *dsp.PreEmphasis | |||
| gain float64 | |||
| } | |||
| func NewPreEmphasizedSource(src frameSource, tauUS, sampleRate, gain float64) *PreEmphasizedSource { | |||
| p := &PreEmphasizedSource{src: src, gain: gain} | |||
| if tauUS > 0 { | |||
| p.preL = dsp.NewPreEmphasis(tauUS, sampleRate) | |||
| p.preR = dsp.NewPreEmphasis(tauUS, sampleRate) | |||
| } | |||
| return p | |||
| } | |||
| func (p *PreEmphasizedSource) NextFrame() audio.Frame { | |||
| f := p.src.NextFrame() | |||
| l := float64(f.L) * p.gain | |||
| r := float64(f.R) * p.gain | |||
| if p.preL != nil { | |||
| l = p.preL.Process(l) | |||
| r = p.preR.Process(r) | |||
| } | |||
| return audio.NewFrame(audio.Sample(l), audio.Sample(r)) | |||
| } | |||
| type SourceInfo struct { | |||
| Kind string | |||
| SampleRate float64 | |||
| @@ -46,20 +74,6 @@ func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) { | |||
| return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"} | |||
| } | |||
| func parsePI(pi string) uint16 { | |||
| trimmed := strings.TrimSpace(pi) | |||
| if trimmed == "" { | |||
| return 0x1234 | |||
| } | |||
| trimmed = strings.TrimPrefix(trimmed, "0x") | |||
| trimmed = strings.TrimPrefix(trimmed, "0X") | |||
| v, err := strconv.ParseUint(trimmed, 16, 16) | |||
| if err != nil { | |||
| return 0x1234 | |||
| } | |||
| return uint16(v) | |||
| } | |||
| func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame { | |||
| sampleRate := float64(g.cfg.FM.CompositeRateHz) | |||
| if sampleRate <= 0 { | |||
| @@ -77,33 +91,32 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||
| Sequence: 1, | |||
| } | |||
| // --- DSP chain --- | |||
| // Pre-emphasis filters for L and R channels | |||
| var preL, preR *dsp.PreEmphasis | |||
| if g.cfg.FM.PreEmphasisUS > 0 { | |||
| preL = dsp.NewPreEmphasis(g.cfg.FM.PreEmphasisUS, sampleRate) | |||
| preR = dsp.NewPreEmphasis(g.cfg.FM.PreEmphasisUS, sampleRate) | |||
| } | |||
| // Audio source with pre-emphasis applied at audio rate (before stereo encoding) | |||
| rawSource, _ := g.sourceFor(sampleRate) | |||
| source := NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, sampleRate, g.cfg.Audio.Gain) | |||
| // Stereo encoder (includes stateful 19kHz pilot and 38kHz subcarrier) | |||
| // Stereo encoder (unity-normalized pilot + subcarrier) | |||
| stereoEncoder := stereo.NewStereoEncoder(sampleRate) | |||
| // MPX combiner | |||
| combiner := mpx.NewDefaultCombiner() | |||
| combiner.PilotGain = g.cfg.FM.PilotLevel / 0.1 // normalize: pilot generator has 0.1 level built-in | |||
| combiner.RDSGain = g.cfg.FM.RDSInjection / 0.05 // normalize: RDS encoder has 0.05 amplitude built-in | |||
| // MPX combiner — config values are direct linear injection levels, no magic numbers | |||
| combiner := mpx.DefaultCombiner{ | |||
| MonoGain: 1.0, | |||
| StereoGain: 1.0, | |||
| PilotGain: g.cfg.FM.PilotLevel, | |||
| RDSGain: g.cfg.FM.RDSInjection, | |||
| } | |||
| // RDS encoder (standards-grade group framing + CRC + diff encoding) | |||
| // RDS encoder (unity-normalized output) | |||
| piCode, _ := cfgpkg.ParsePI(g.cfg.RDS.PI) // already validated | |||
| rdsEnc, _ := rds.NewEncoder(rds.RDSConfig{ | |||
| PI: parsePI(g.cfg.RDS.PI), | |||
| PI: piCode, | |||
| PS: g.cfg.RDS.PS, | |||
| RT: g.cfg.RDS.RadioText, | |||
| PTY: uint8(g.cfg.RDS.PTY), | |||
| SampleRate: sampleRate, | |||
| }) | |||
| // MPX limiter | |||
| // Limiter | |||
| var limiter *dsp.MPXLimiter | |||
| ceiling := g.cfg.FM.LimiterCeiling | |||
| if ceiling <= 0 { | |||
| @@ -113,7 +126,7 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||
| limiter = dsp.NewMPXLimiter(ceiling, 0.1, 50, sampleRate) | |||
| } | |||
| // FM modulator for IQ output | |||
| // FM modulator | |||
| var fmMod *dsp.FMModulator | |||
| if g.cfg.FM.FMModulationEnabled { | |||
| fmMod = dsp.NewFMModulator(sampleRate) | |||
| @@ -122,53 +135,29 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||
| } | |||
| } | |||
| // Audio source | |||
| source, _ := g.sourceFor(sampleRate) | |||
| // --- Sample loop --- | |||
| // --- Sample loop (zero-allocation hot path) --- | |||
| for i := 0; i < samples; i++ { | |||
| in := source.NextFrame() | |||
| // Apply gain | |||
| inL := float64(in.L) * g.cfg.Audio.Gain | |||
| inR := float64(in.R) * g.cfg.Audio.Gain | |||
| // Pre-emphasis | |||
| if preL != nil { | |||
| inL = preL.Process(inL) | |||
| inR = preR.Process(inR) | |||
| } | |||
| // Stereo encode (produces mono, DSB-SC stereo, pilot) | |||
| preFrame := audio.NewFrame(audio.Sample(inL), audio.Sample(inR)) | |||
| comps := stereoEncoder.Encode(preFrame) | |||
| comps := stereoEncoder.Encode(in) | |||
| if !g.cfg.FM.StereoEnabled { | |||
| comps.Stereo = 0 | |||
| comps.Pilot = 0 | |||
| } | |||
| // RDS | |||
| rdsValue := 0.0 | |||
| if g.cfg.RDS.Enabled { | |||
| rdsBuf := rdsEnc.Generate(1) | |||
| rdsValue = rdsBuf[0] | |||
| rdsValue = rdsEnc.NextSample() | |||
| } | |||
| // Combine MPX | |||
| composite := combiner.Combine(comps.Mono, comps.Stereo, comps.Pilot, rdsValue) | |||
| // Apply output drive | |||
| composite *= g.cfg.FM.OutputDrive | |||
| // Limiter | |||
| if limiter != nil { | |||
| composite = limiter.Process(composite) | |||
| composite = dsp.HardClip(composite, ceiling) // safety net only with limiter | |||
| } | |||
| // Hard clip safety net | |||
| composite = dsp.HardClip(composite, ceiling) | |||
| // Output: FM modulated IQ or raw composite | |||
| if fmMod != nil { | |||
| iq_i, iq_q := fmMod.Modulate(composite) | |||
| frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)} | |||
| @@ -208,10 +197,7 @@ func (g *Generator) WriteFile(path string, duration time.Duration) error { | |||
| if _, err := backend.Write(context.Background(), frame); err != nil { | |||
| return err | |||
| } | |||
| if err := backend.Flush(context.Background()); err != nil { | |||
| return err | |||
| } | |||
| return nil | |||
| return backend.Flush(context.Background()) | |||
| } | |||
| func (g *Generator) Summary(duration time.Duration) string { | |||
| @@ -221,8 +207,8 @@ func (g *Generator) Summary(duration time.Duration) string { | |||
| } | |||
| _, info := g.sourceFor(sampleRate) | |||
| preemph := "off" | |||
| if g.cfg.FM.PreEmphasisUS > 0 { | |||
| preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisUS) | |||
| if g.cfg.FM.PreEmphasisTauUS > 0 { | |||
| preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisTauUS) | |||
| } | |||
| modMode := "composite" | |||
| if g.cfg.FM.FMModulationEnabled { | |||
| @@ -14,170 +14,116 @@ import ( | |||
| func TestGenerateFrame(t *testing.T) { | |||
| g := NewGenerator(cfgpkg.Default()) | |||
| frame := g.GenerateFrame(50 * time.Millisecond) | |||
| if frame == nil { | |||
| t.Fatal("expected frame") | |||
| } | |||
| if len(frame.Samples) == 0 { | |||
| t.Fatal("expected samples") | |||
| } | |||
| if frame == nil || len(frame.Samples) == 0 { t.Fatal("expected samples") } | |||
| } | |||
| func TestGenerateFrameFMIQ(t *testing.T) { | |||
| cfg := cfgpkg.Default() | |||
| cfg.FM.FMModulationEnabled = true | |||
| g := NewGenerator(cfg) | |||
| frame := g.GenerateFrame(10 * time.Millisecond) | |||
| // With FM modulation, IQ samples should have magnitude ~1 | |||
| cfg := cfgpkg.Default(); cfg.FM.FMModulationEnabled = true | |||
| frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond) | |||
| for i := 100; i < len(frame.Samples) && i < 200; i++ { | |||
| s := frame.Samples[i] | |||
| mag := math.Sqrt(float64(s.I)*float64(s.I) + float64(s.Q)*float64(s.Q)) | |||
| if math.Abs(mag-1.0) > 0.01 { | |||
| t.Fatalf("sample %d: IQ magnitude=%.4f, expected ~1.0", i, mag) | |||
| } | |||
| if math.Abs(mag-1.0) > 0.01 { t.Fatalf("sample %d: mag=%.4f", i, mag) } | |||
| } | |||
| } | |||
| func TestGenerateFrameCompositeOnly(t *testing.T) { | |||
| cfg := cfgpkg.Default() | |||
| cfg.FM.FMModulationEnabled = false | |||
| g := NewGenerator(cfg) | |||
| frame := g.GenerateFrame(10 * time.Millisecond) | |||
| // Without FM modulation, Q should be 0 | |||
| cfg := cfgpkg.Default(); cfg.FM.FMModulationEnabled = false | |||
| frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond) | |||
| for i := 0; i < len(frame.Samples) && i < 100; i++ { | |||
| if frame.Samples[i].Q != 0 { | |||
| t.Fatalf("sample %d: Q=%.6f, expected 0 in composite mode", i, frame.Samples[i].Q) | |||
| } | |||
| if frame.Samples[i].Q != 0 { t.Fatalf("sample %d: Q=%.6f", i, frame.Samples[i].Q) } | |||
| } | |||
| } | |||
| func TestStereoDisabledSuppressesPilotAndStereoDifference(t *testing.T) { | |||
| cfgStereo := cfgpkg.Default() | |||
| cfgStereo.FM.FMModulationEnabled = false | |||
| cfgStereo.FM.StereoEnabled = true | |||
| cfgStereo.Audio.ToneLeftHz = 1000 | |||
| cfgStereo.Audio.ToneRightHz = 1600 | |||
| cfgMono := cfgStereo | |||
| cfgMono.FM.StereoEnabled = false | |||
| stereoFrame := NewGenerator(cfgStereo).GenerateFrame(20 * time.Millisecond) | |||
| monoFrame := NewGenerator(cfgMono).GenerateFrame(20 * time.Millisecond) | |||
| if len(stereoFrame.Samples) != len(monoFrame.Samples) { | |||
| t.Fatal("frame length mismatch") | |||
| } | |||
| func TestStereoDisabled(t *testing.T) { | |||
| cfgS := cfgpkg.Default(); cfgS.FM.FMModulationEnabled = false; cfgS.FM.StereoEnabled = true | |||
| cfgM := cfgS; cfgM.FM.StereoEnabled = false | |||
| sf := NewGenerator(cfgS).GenerateFrame(20 * time.Millisecond) | |||
| mf := NewGenerator(cfgM).GenerateFrame(20 * time.Millisecond) | |||
| var diffEnergy float64 | |||
| for i := range stereoFrame.Samples { | |||
| d := float64(stereoFrame.Samples[i].I - monoFrame.Samples[i].I) | |||
| diffEnergy += d * d | |||
| } | |||
| if diffEnergy == 0 { | |||
| t.Fatal("expected stereo-enabled and stereo-disabled composite output to differ") | |||
| for i := range sf.Samples { | |||
| d := float64(sf.Samples[i].I - mf.Samples[i].I); diffEnergy += d * d | |||
| } | |||
| if diffEnergy == 0 { t.Fatal("expected difference") } | |||
| } | |||
| func TestWriteFile(t *testing.T) { | |||
| cfg := cfgpkg.Default() | |||
| out := filepath.Join(t.TempDir(), "test.iqf32") | |||
| cfg.Backend.OutputPath = out | |||
| g := NewGenerator(cfg) | |||
| if err := g.WriteFile(out, 20*time.Millisecond); err != nil { | |||
| t.Fatalf("WriteFile failed: %v", err) | |||
| } | |||
| info, err := os.Stat(out) | |||
| if err != nil { | |||
| t.Fatalf("expected output file: %v", err) | |||
| } | |||
| if info.Size() == 0 { | |||
| t.Fatal("expected non-empty file") | |||
| } | |||
| if err := NewGenerator(cfg).WriteFile(out, 20*time.Millisecond); err != nil { t.Fatal(err) } | |||
| info, _ := os.Stat(out) | |||
| if info.Size() == 0 { t.Fatal("empty file") } | |||
| } | |||
| func TestSummaryUsesToneFallback(t *testing.T) { | |||
| cfg := cfgpkg.Default() | |||
| cfg.Audio.InputPath = "" | |||
| g := NewGenerator(cfg) | |||
| summary := g.Summary(10 * time.Millisecond) | |||
| if !strings.Contains(summary, "source=tones") { | |||
| t.Fatalf("unexpected summary: %s", summary) | |||
| } | |||
| func TestSummaryTones(t *testing.T) { | |||
| cfg := cfgpkg.Default(); cfg.Audio.InputPath = "" | |||
| s := NewGenerator(cfg).Summary(10 * time.Millisecond) | |||
| if !strings.Contains(s, "source=tones") { t.Fatalf("unexpected: %s", s) } | |||
| } | |||
| func TestSummaryUsesFallbackLabelOnBadWAV(t *testing.T) { | |||
| cfg := cfgpkg.Default() | |||
| cfg.Audio.InputPath = "missing.wav" | |||
| g := NewGenerator(cfg) | |||
| summary := g.Summary(10 * time.Millisecond) | |||
| if !strings.Contains(summary, "source=tone-fallback") { | |||
| t.Fatalf("unexpected summary: %s", summary) | |||
| } | |||
| func TestSummaryToneFallback(t *testing.T) { | |||
| cfg := cfgpkg.Default(); cfg.Audio.InputPath = "missing.wav" | |||
| s := NewGenerator(cfg).Summary(10 * time.Millisecond) | |||
| if !strings.Contains(s, "source=tone-fallback") { t.Fatalf("unexpected: %s", s) } | |||
| } | |||
| func TestSummaryContainsPreemph(t *testing.T) { | |||
| cfg := cfgpkg.Default() | |||
| cfg.FM.PreEmphasisUS = 50 | |||
| g := NewGenerator(cfg) | |||
| summary := g.Summary(10 * time.Millisecond) | |||
| if !strings.Contains(summary, "preemph=50µs") { | |||
| t.Fatalf("unexpected summary: %s", summary) | |||
| } | |||
| func TestSummaryPreemph(t *testing.T) { | |||
| cfg := cfgpkg.Default(); cfg.FM.PreEmphasisTauUS = 50 | |||
| if !strings.Contains(NewGenerator(cfg).Summary(10*time.Millisecond), "preemph=50µs") { t.Fatal("missing preemph") } | |||
| } | |||
| func TestSummaryContainsFMIQ(t *testing.T) { | |||
| cfg := cfgpkg.Default() | |||
| cfg.FM.FMModulationEnabled = true | |||
| g := NewGenerator(cfg) | |||
| summary := g.Summary(10 * time.Millisecond) | |||
| if !strings.Contains(summary, "FM-IQ") { | |||
| t.Fatalf("unexpected summary: %s", summary) | |||
| } | |||
| func TestSummaryFMIQ(t *testing.T) { | |||
| cfg := cfgpkg.Default(); cfg.FM.FMModulationEnabled = true | |||
| if !strings.Contains(NewGenerator(cfg).Summary(10*time.Millisecond), "FM-IQ") { t.Fatal("missing FM-IQ") } | |||
| } | |||
| func TestLimiterPreventsClipping(t *testing.T) { | |||
| cfg := cfgpkg.Default() | |||
| cfg.FM.LimiterEnabled = true | |||
| cfg.FM.LimiterCeiling = 1.0 | |||
| cfg.FM.FMModulationEnabled = false // raw composite to check levels | |||
| cfg.Audio.ToneAmplitude = 0.9 // high amplitude to exercise limiter | |||
| cfg.Audio.Gain = 2.0 | |||
| cfg.FM.OutputDrive = 1.0 | |||
| g := NewGenerator(cfg) | |||
| frame := g.GenerateFrame(50 * time.Millisecond) | |||
| cfg.FM.LimiterEnabled = true; cfg.FM.LimiterCeiling = 1.0 | |||
| cfg.FM.FMModulationEnabled = false | |||
| cfg.Audio.ToneAmplitude = 0.9; cfg.Audio.Gain = 2.0; cfg.FM.OutputDrive = 1.0 | |||
| frame := NewGenerator(cfg).GenerateFrame(50 * time.Millisecond) | |||
| for i, s := range frame.Samples { | |||
| if math.Abs(float64(s.I)) > 1.01 { | |||
| t.Fatalf("sample %d: composite=%.4f exceeds ceiling", i, s.I) | |||
| } | |||
| if math.Abs(float64(s.I)) > 1.01 { t.Fatalf("sample %d: %.4f exceeds ceiling", i, s.I) } | |||
| } | |||
| } | |||
| func TestParsePI(t *testing.T) { | |||
| tests := []struct { | |||
| name string | |||
| in string | |||
| want uint16 | |||
| }{ | |||
| {name: "plain hex", in: "1234", want: 0x1234}, | |||
| {name: "0x prefix", in: "0xBEEF", want: 0xBEEF}, | |||
| {name: "uppercase prefix", in: "0XCAFE", want: 0xCAFE}, | |||
| {name: "whitespace", in: " 0x2345 ", want: 0x2345}, | |||
| {name: "empty fallback", in: "", want: 0x1234}, | |||
| {name: "invalid fallback", in: "nope", want: 0x1234}, | |||
| } | |||
| for _, tt := range tests { | |||
| if got := parsePI(tt.in); got != tt.want { | |||
| t.Fatalf("%s: got 0x%04X want 0x%04X", tt.name, got, tt.want) | |||
| } | |||
| // --- Operator truth tests --- | |||
| func TestRDSDisabledSuppressesRDSEnergy(t *testing.T) { | |||
| cfgOn := cfgpkg.Default(); cfgOn.FM.FMModulationEnabled = false; cfgOn.RDS.Enabled = true | |||
| cfgOff := cfgOn; cfgOff.RDS.Enabled = false | |||
| fOn := NewGenerator(cfgOn).GenerateFrame(20 * time.Millisecond) | |||
| fOff := NewGenerator(cfgOff).GenerateFrame(20 * time.Millisecond) | |||
| var diff float64 | |||
| for i := range fOn.Samples { | |||
| d := float64(fOn.Samples[i].I - fOff.Samples[i].I); diff += d * d | |||
| } | |||
| if diff == 0 { t.Fatal("rds.enabled=false should produce different output") } | |||
| } | |||
| func TestGeneratorUsesConfiguredPI(t *testing.T) { | |||
| cfg := cfgpkg.Default() | |||
| cfg.RDS.PI = "BEEF" | |||
| if got := parsePI(cfg.RDS.PI); got != 0xBEEF { | |||
| t.Fatalf("configured PI was not parsed as expected: got 0x%04X", got) | |||
| func TestFMModDisabledMeansComposite(t *testing.T) { | |||
| cfg := cfgpkg.Default(); cfg.FM.FMModulationEnabled = false | |||
| frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond) | |||
| for i := 0; i < 100; i++ { | |||
| if frame.Samples[i].Q != 0 { t.Fatal("Q should be 0 when FM mod is off") } | |||
| } | |||
| } | |||
| func TestLimiterDisabledAllowsHigherPeaks(t *testing.T) { | |||
| base := cfgpkg.Default() | |||
| base.FM.FMModulationEnabled = false | |||
| base.Audio.ToneAmplitude = 0.9; base.Audio.Gain = 2.0; base.FM.OutputDrive = 1.0 | |||
| cfgLim := base; cfgLim.FM.LimiterEnabled = true; cfgLim.FM.LimiterCeiling = 1.0 | |||
| cfgNoLim := base; cfgNoLim.FM.LimiterEnabled = false | |||
| fLim := NewGenerator(cfgLim).GenerateFrame(50 * time.Millisecond) | |||
| fNoLim := NewGenerator(cfgNoLim).GenerateFrame(50 * time.Millisecond) | |||
| var maxLim, maxNoLim float64 | |||
| for _, s := range fLim.Samples { if math.Abs(float64(s.I)) > maxLim { maxLim = math.Abs(float64(s.I)) } } | |||
| for _, s := range fNoLim.Samples { if math.Abs(float64(s.I)) > maxNoLim { maxNoLim = math.Abs(float64(s.I)) } } | |||
| if maxNoLim <= maxLim { t.Fatalf("limiter disabled should allow higher peaks: lim=%.4f nolim=%.4f", maxLim, maxNoLim) } | |||
| } | |||
| @@ -0,0 +1,84 @@ | |||
| package offline | |||
| import ( | |||
| "testing" | |||
| "time" | |||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | |||
| "github.com/jan/fm-rds-tx/internal/dsp" | |||
| ) | |||
| func extractComposite(g *Generator, duration time.Duration) ([]float64, float64) { | |||
| cfg := g.cfg | |||
| cfg.FM.FMModulationEnabled = false | |||
| gen := NewGenerator(cfg) | |||
| frame := gen.GenerateFrame(duration) | |||
| samples := make([]float64, len(frame.Samples)) | |||
| for i, s := range frame.Samples { | |||
| samples[i] = float64(s.I) | |||
| } | |||
| return samples, frame.SampleRateHz | |||
| } | |||
| func TestCompositeHasPilotAt19kHz(t *testing.T) { | |||
| cfg := cfgpkg.Default() | |||
| cfg.FM.FMModulationEnabled = false | |||
| cfg.FM.StereoEnabled = true | |||
| samples, rate := extractComposite(NewGenerator(cfg), 200*time.Millisecond) | |||
| pilotEnergy := dsp.BandEnergy(samples, rate, 19000, 500) | |||
| noiseEnergy := dsp.BandEnergy(samples, rate, 12000, 500) | |||
| if pilotEnergy < noiseEnergy*10 { | |||
| t.Fatalf("missing 19 kHz pilot: pilot=%.6f noise=%.6f", pilotEnergy, noiseEnergy) | |||
| } | |||
| } | |||
| func TestCompositeHasStereoAt38kHz(t *testing.T) { | |||
| cfg := cfgpkg.Default() | |||
| cfg.FM.FMModulationEnabled = false | |||
| cfg.FM.StereoEnabled = true | |||
| cfg.Audio.ToneLeftHz = 1000 | |||
| cfg.Audio.ToneRightHz = 1600 // different L/R -> stereo energy | |||
| samples, rate := extractComposite(NewGenerator(cfg), 200*time.Millisecond) | |||
| stereoEnergy := dsp.BandEnergy(samples, rate, 38000, 3000) | |||
| noiseEnergy := dsp.BandEnergy(samples, rate, 25000, 500) | |||
| if stereoEnergy < noiseEnergy*5 { | |||
| t.Fatalf("missing 38 kHz stereo energy: stereo=%.6f noise=%.6f", stereoEnergy, noiseEnergy) | |||
| } | |||
| } | |||
| func TestCompositeHasRDSAt57kHz(t *testing.T) { | |||
| cfg := cfgpkg.Default() | |||
| cfg.FM.FMModulationEnabled = false | |||
| cfg.RDS.Enabled = true | |||
| samples, rate := extractComposite(NewGenerator(cfg), 200*time.Millisecond) | |||
| rdsEnergy := dsp.BandEnergy(samples, rate, 57000, 3000) | |||
| noiseEnergy := dsp.BandEnergy(samples, rate, 45000, 500) | |||
| if rdsEnergy < noiseEnergy*5 { | |||
| t.Fatalf("missing 57 kHz RDS energy: rds=%.6f noise=%.6f", rdsEnergy, noiseEnergy) | |||
| } | |||
| } | |||
| func TestCompositeNoStereoWhenDisabled(t *testing.T) { | |||
| cfg := cfgpkg.Default() | |||
| cfg.FM.FMModulationEnabled = false | |||
| cfg.FM.StereoEnabled = false | |||
| samples, rate := extractComposite(NewGenerator(cfg), 200*time.Millisecond) | |||
| pilotEnergy := dsp.BandEnergy(samples, rate, 19000, 500) | |||
| // Should be near-zero compared to mono energy | |||
| monoEnergy := dsp.BandEnergy(samples, rate, 1000, 500) | |||
| if monoEnergy > 0 && pilotEnergy > monoEnergy*0.01 { | |||
| t.Fatalf("pilot should be suppressed: pilot=%.6f mono=%.6f", pilotEnergy, monoEnergy) | |||
| } | |||
| } | |||
| func TestCompositeNoRDSWhenDisabled(t *testing.T) { | |||
| cfg := cfgpkg.Default() | |||
| cfg.FM.FMModulationEnabled = false | |||
| cfg.RDS.Enabled = false | |||
| samples, rate := extractComposite(NewGenerator(cfg), 200*time.Millisecond) | |||
| rdsEnergy := dsp.BandEnergy(samples, rate, 57000, 3000) | |||
| monoEnergy := dsp.BandEnergy(samples, rate, 1000, 500) | |||
| if monoEnergy > 0 && rdsEnergy > monoEnergy*0.001 { | |||
| t.Fatalf("RDS should be suppressed: rds=%.6f mono=%.6f", rdsEnergy, monoEnergy) | |||
| } | |||
| } | |||
| @@ -7,7 +7,6 @@ import ( | |||
| const ( | |||
| defaultSubcarrierHz = 57000.0 | |||
| defaultBitRateHz = 1187.5 | |||
| defaultAmplitude = 0.05 | |||
| // Each RDS group has 4 blocks of 26 bits each (16 data + 10 check). | |||
| bitsPerBlock = 26 | |||
| @@ -18,15 +17,13 @@ const ( | |||
| // CRC / offset words per IEC 62106 / EN 50067 | |||
| // ----------------------------------------------------------------------- | |||
| // Generator polynomial: x^10 + x^8 + x^7 + x^5 + x^4 + x^3 + 1 = 0x1B9 | |||
| const crcPoly = 0x1B9 | |||
| // Offset words for blocks A, B, C, C', D. | |||
| var offsetWords = map[byte]uint16{ | |||
| 'A': 0x0FC, | |||
| 'B': 0x198, | |||
| 'C': 0x168, | |||
| 'c': 0x350, // C' for type B groups | |||
| 'c': 0x350, | |||
| 'D': 0x1B4, | |||
| } | |||
| @@ -49,14 +46,9 @@ func encodeBlock(data uint16, offset byte) uint32 { | |||
| // Group building | |||
| // ----------------------------------------------------------------------- | |||
| // buildGroup0A creates a type 0A group (basic tuning and PS name). | |||
| // segIdx selects which 2-char segment of the 8-char PS name (0..3). | |||
| func buildGroup0A(pi uint16, pty uint8, tp, ta bool, segIdx int, ps string) [4]uint16 { | |||
| ps = normalizePS(ps) | |||
| blockA := pi | |||
| // Block B: group type 0A (0000 0), TP, PTY, TA, MS=1, DI=0, segment address | |||
| var blockB uint16 | |||
| if tp { | |||
| blockB |= 1 << 10 | |||
| @@ -65,30 +57,19 @@ func buildGroup0A(pi uint16, pty uint8, tp, ta bool, segIdx int, ps string) [4]u | |||
| if ta { | |||
| blockB |= 1 << 4 | |||
| } | |||
| blockB |= 1 << 3 // MS = music | |||
| blockB |= 1 << 3 | |||
| blockB |= uint16(segIdx & 0x03) | |||
| // Block C: AF (not implemented) – send PI as filler (common practice) | |||
| blockC := pi | |||
| // Block D: 2 PS characters | |||
| ci := segIdx * 2 | |||
| ch0 := uint16(ps[ci]) | |||
| ch1 := uint16(ps[ci+1]) | |||
| blockD := (ch0 << 8) | ch1 | |||
| blockD := (uint16(ps[ci]) << 8) | uint16(ps[ci+1]) | |||
| return [4]uint16{blockA, blockB, blockC, blockD} | |||
| } | |||
| // buildGroup2A creates a type 2A group (RadioText). | |||
| // segIdx selects which 4-char segment (0..15) of the 64-char RT. | |||
| func buildGroup2A(pi uint16, pty uint8, tp bool, abFlag bool, segIdx int, rt string) [4]uint16 { | |||
| rt = normalizeRT(rt) | |||
| blockA := pi | |||
| var blockB uint16 | |||
| blockB = 2 << 12 // group type 2 | |||
| blockB = 2 << 12 | |||
| if tp { | |||
| blockB |= 1 << 10 | |||
| } | |||
| @@ -97,12 +78,10 @@ func buildGroup2A(pi uint16, pty uint8, tp bool, abFlag bool, segIdx int, rt str | |||
| blockB |= 1 << 4 | |||
| } | |||
| blockB |= uint16(segIdx & 0x0F) | |||
| ci := segIdx * 4 | |||
| ch0, ch1, ch2, ch3 := padRT(rt, ci) | |||
| blockC := (uint16(ch0) << 8) | uint16(ch1) | |||
| blockD := (uint16(ch2) << 8) | uint16(ch3) | |||
| return [4]uint16{blockA, blockB, blockC, blockD} | |||
| } | |||
| @@ -120,7 +99,6 @@ func padRT(rt string, offset int) (byte, byte, byte, byte) { | |||
| // Group scheduler | |||
| // ----------------------------------------------------------------------- | |||
| // GroupScheduler cycles through 0A and 2A groups. | |||
| type GroupScheduler struct { | |||
| cfg RDSConfig | |||
| psIdx int | |||
| @@ -133,16 +111,13 @@ func newGroupScheduler(cfg RDSConfig) *GroupScheduler { | |||
| return &GroupScheduler{cfg: cfg} | |||
| } | |||
| // NextGroup returns the next RDS group as 4 raw 16-bit words. | |||
| func (gs *GroupScheduler) NextGroup() [4]uint16 { | |||
| // Pattern: 4x 0A (full PS cycle), then N x 2A (full RT cycle), repeat. | |||
| if gs.phase < 4 { | |||
| g := buildGroup0A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, gs.cfg.TA, gs.psIdx, gs.cfg.PS) | |||
| gs.psIdx = (gs.psIdx + 1) % 4 | |||
| gs.phase++ | |||
| return g | |||
| } | |||
| g := buildGroup2A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, gs.rtABFlag, gs.rtIdx, gs.cfg.RT) | |||
| gs.rtIdx++ | |||
| rtSegs := rtSegmentCount(gs.cfg.RT) | |||
| @@ -188,15 +163,17 @@ func (d *diffEncoder) encode(bit uint8) uint8 { | |||
| // ----------------------------------------------------------------------- | |||
| // Encoder produces a standards-grade RDS BPSK subcarrier at 57 kHz. | |||
| // Output is unity-normalized (peak ±1.0). The caller (combiner) controls | |||
| // the actual injection level. | |||
| type Encoder struct { | |||
| config RDSConfig | |||
| sampleRate float64 | |||
| amplitude float64 | |||
| scheduler *GroupScheduler | |||
| diff diffEncoder | |||
| bitBuf []uint8 | |||
| bitBuf [bitsPerGroup]uint8 | |||
| bitLen int | |||
| bitPos int | |||
| bitPhase float64 | |||
| subPhase float64 | |||
| @@ -211,10 +188,9 @@ func NewEncoder(cfg RDSConfig) (*Encoder, error) { | |||
| cfg.RT = normalizeRT(cfg.RT) | |||
| enc := &Encoder{ | |||
| config: cfg, | |||
| config: cfg, | |||
| sampleRate: cfg.SampleRate, | |||
| amplitude: defaultAmplitude, | |||
| scheduler: newGroupScheduler(cfg), | |||
| scheduler: newGroupScheduler(cfg), | |||
| } | |||
| enc.loadNextGroup() | |||
| return enc, nil | |||
| @@ -226,48 +202,22 @@ func (e *Encoder) Reset() { | |||
| e.subPhase = 0 | |||
| e.diff = diffEncoder{} | |||
| e.scheduler = newGroupScheduler(e.config) | |||
| e.bitBuf = nil | |||
| e.bitLen = 0 | |||
| e.bitPos = 0 | |||
| e.loadNextGroup() | |||
| } | |||
| // Generate produces the requested number of RDS samples. | |||
| func (e *Encoder) Generate(samples int) []float64 { | |||
| out := make([]float64, samples) | |||
| for i := range out { | |||
| out[i] = e.nextSample() | |||
| } | |||
| return out | |||
| } | |||
| func (e *Encoder) loadNextGroup() { | |||
| group := e.scheduler.NextGroup() | |||
| e.bitBuf = make([]uint8, 0, bitsPerGroup) | |||
| offsets := [4]byte{'A', 'B', 'C', 'D'} | |||
| for blk := 0; blk < 4; blk++ { | |||
| encoded := encodeBlock(group[blk], offsets[blk]) | |||
| for bit := bitsPerBlock - 1; bit >= 0; bit-- { | |||
| raw := uint8((encoded >> uint(bit)) & 1) | |||
| diffBit := e.diff.encode(raw) | |||
| e.bitBuf = append(e.bitBuf, diffBit) | |||
| } | |||
| } | |||
| e.bitPos = 0 | |||
| } | |||
| func (e *Encoder) currentSymbol() float64 { | |||
| if len(e.bitBuf) == 0 { | |||
| return 1 | |||
| // NextSample returns the next RDS subcarrier sample. | |||
| // Zero-allocation hot path for real-time use. | |||
| func (e *Encoder) NextSample() float64 { | |||
| var symbol float64 | |||
| if e.bitLen == 0 || e.bitBuf[e.bitPos] == 0 { | |||
| symbol = -1 | |||
| } else { | |||
| symbol = 1 | |||
| } | |||
| if e.bitBuf[e.bitPos] == 0 { | |||
| return -1 | |||
| } | |||
| return 1 | |||
| } | |||
| func (e *Encoder) nextSample() float64 { | |||
| symbol := e.currentSymbol() | |||
| value := e.amplitude * symbol * math.Sin(2*math.Pi*e.subPhase) | |||
| value := symbol * math.Sin(2*math.Pi*e.subPhase) | |||
| e.subPhase += defaultSubcarrierHz / e.sampleRate | |||
| if e.subPhase >= 1 { | |||
| @@ -279,10 +229,36 @@ func (e *Encoder) nextSample() float64 { | |||
| steps := int(e.bitPhase) | |||
| e.bitPhase -= float64(steps) | |||
| e.bitPos += steps | |||
| if e.bitPos >= len(e.bitBuf) { | |||
| if e.bitPos >= e.bitLen { | |||
| e.loadNextGroup() | |||
| } | |||
| } | |||
| return value | |||
| } | |||
| // Generate produces n RDS samples. Convenience wrapper; prefer NextSample() | |||
| // in real-time paths. | |||
| func (e *Encoder) Generate(samples int) []float64 { | |||
| out := make([]float64, samples) | |||
| for i := range out { | |||
| out[i] = e.NextSample() | |||
| } | |||
| return out | |||
| } | |||
| func (e *Encoder) loadNextGroup() { | |||
| group := e.scheduler.NextGroup() | |||
| e.bitLen = 0 | |||
| offsets := [4]byte{'A', 'B', 'C', 'D'} | |||
| for blk := 0; blk < 4; blk++ { | |||
| encoded := encodeBlock(group[blk], offsets[blk]) | |||
| for bit := bitsPerBlock - 1; bit >= 0; bit-- { | |||
| raw := uint8((encoded >> uint(bit)) & 1) | |||
| diffBit := e.diff.encode(raw) | |||
| e.bitBuf[e.bitLen] = diffBit | |||
| e.bitLen++ | |||
| } | |||
| } | |||
| e.bitPos = 0 | |||
| } | |||
| @@ -7,163 +7,92 @@ import ( | |||
| ) | |||
| func TestCRC10KnownVector(t *testing.T) { | |||
| // Verify the CRC polynomial produces 10-bit outputs | |||
| c := crc10(0x1234) | |||
| if c > 0x3FF { | |||
| t.Fatalf("CRC exceeds 10 bits: %x", c) | |||
| } | |||
| if c > 0x3FF { t.Fatalf("CRC exceeds 10 bits: %x", c) } | |||
| } | |||
| func TestEncodeBlockProduces26Bits(t *testing.T) { | |||
| block := encodeBlock(0x1234, 'A') | |||
| // Must fit in 26 bits | |||
| if block>>26 != 0 { | |||
| t.Fatalf("block exceeds 26 bits: %x", block) | |||
| } | |||
| // Data portion should be the original word | |||
| data := uint16(block >> 10) | |||
| if data != 0x1234 { | |||
| t.Fatalf("data mismatch: got %x want %x", data, 0x1234) | |||
| } | |||
| if block>>26 != 0 { t.Fatalf("block exceeds 26 bits: %x", block) } | |||
| if uint16(block>>10) != 0x1234 { t.Fatalf("data mismatch") } | |||
| } | |||
| func TestBuildGroup0ABlockCount(t *testing.T) { | |||
| func TestBuildGroup0A(t *testing.T) { | |||
| g := buildGroup0A(0x1234, 0, false, false, 0, "TESTFM") | |||
| // Block A must be PI | |||
| if g[0] != 0x1234 { | |||
| t.Fatalf("block A not PI: %x", g[0]) | |||
| } | |||
| // Block D should contain first two PS chars 'T','E' | |||
| ch0 := byte(g[3] >> 8) | |||
| ch1 := byte(g[3] & 0xFF) | |||
| if ch0 != 'T' || ch1 != 'E' { | |||
| t.Fatalf("unexpected PS chars: %c %c", ch0, ch1) | |||
| } | |||
| if g[0] != 0x1234 { t.Fatalf("block A not PI: %x", g[0]) } | |||
| if byte(g[3]>>8) != 'T' || byte(g[3]&0xFF) != 'E' { t.Fatal("wrong PS chars") } | |||
| } | |||
| func TestBuildGroup2ABlockCount(t *testing.T) { | |||
| func TestBuildGroup2A(t *testing.T) { | |||
| g := buildGroup2A(0x1234, 0, false, false, 0, "Hello World") | |||
| if g[0] != 0x1234 { | |||
| t.Fatalf("block A not PI: %x", g[0]) | |||
| } | |||
| // Group type field in block B should have type 2 in bits 15..12 | |||
| groupType := (g[1] >> 12) & 0x0F | |||
| if groupType != 2 { | |||
| t.Fatalf("unexpected group type: %d", groupType) | |||
| } | |||
| if g[0] != 0x1234 { t.Fatal("block A not PI") } | |||
| if (g[1]>>12)&0x0F != 2 { t.Fatal("wrong group type") } | |||
| } | |||
| func TestBuildGroupUsesConfiguredPI(t *testing.T) { | |||
| g0 := buildGroup0A(0xBEEF, 0, false, false, 0, "TESTFM") | |||
| if g0[0] != 0xBEEF { | |||
| t.Fatalf("group0A block A not configured PI: %x", g0[0]) | |||
| } | |||
| g2 := buildGroup2A(0xCAFE, 0, false, false, 0, "Hello World") | |||
| if g2[0] != 0xCAFE { | |||
| t.Fatalf("group2A block A not configured PI: %x", g2[0]) | |||
| } | |||
| if buildGroup0A(0xBEEF, 0, false, false, 0, "TEST")[0] != 0xBEEF { t.Fatal("PI mismatch 0A") } | |||
| if buildGroup2A(0xCAFE, 0, false, false, 0, "Hello")[0] != 0xCAFE { t.Fatal("PI mismatch 2A") } | |||
| } | |||
| func TestEncoderGenerate(t *testing.T) { | |||
| cfg := DefaultConfig() | |||
| cfg.SampleRate = 228000 | |||
| cfg := DefaultConfig(); cfg.SampleRate = 228000 | |||
| enc, err := NewEncoder(cfg) | |||
| if err != nil { | |||
| t.Fatalf("unexpected error: %v", err) | |||
| } | |||
| if err != nil { t.Fatal(err) } | |||
| samples := enc.Generate(1024) | |||
| if len(samples) != 1024 { | |||
| t.Fatalf("expected 1024 samples, got %d", len(samples)) | |||
| } | |||
| var maxAbs float64 | |||
| var energy float64 | |||
| if len(samples) != 1024 { t.Fatal("wrong length") } | |||
| var energy, maxAbs float64 | |||
| for _, s := range samples { | |||
| a := math.Abs(s) | |||
| energy += s * s | |||
| if a > maxAbs { | |||
| maxAbs = a | |||
| } | |||
| if math.Abs(s) > maxAbs { maxAbs = math.Abs(s) } | |||
| } | |||
| if energy == 0 { t.Fatal("zero energy") } | |||
| // Unity output: peak should be close to 1.0 | |||
| if maxAbs > 1.01 { t.Fatalf("exceeds unity: %.6f", maxAbs) } | |||
| } | |||
| if energy == 0 { | |||
| t.Fatal("expected non-zero energy in RDS output") | |||
| } | |||
| if maxAbs > defaultAmplitude*1.01 { | |||
| t.Fatalf("samples exceed configured amplitude: %.6f", maxAbs) | |||
| } | |||
| func TestEncoderNextSample(t *testing.T) { | |||
| cfg := DefaultConfig(); cfg.SampleRate = 228000 | |||
| enc, _ := NewEncoder(cfg) | |||
| s := enc.NextSample() | |||
| // Should not panic and should produce a value | |||
| if math.IsNaN(s) { t.Fatal("NaN") } | |||
| } | |||
| func TestEncoderReset(t *testing.T) { | |||
| cfg := DefaultConfig() | |||
| cfg.SampleRate = 228000 | |||
| enc, err := NewEncoder(cfg) | |||
| if err != nil { | |||
| t.Fatalf("unexpected error: %v", err) | |||
| } | |||
| sampleA := enc.Generate(1)[0] | |||
| enc.Generate(100) | |||
| cfg := DefaultConfig(); cfg.SampleRate = 228000 | |||
| enc, _ := NewEncoder(cfg) | |||
| a := enc.NextSample() | |||
| for i := 0; i < 100; i++ { enc.NextSample() } | |||
| enc.Reset() | |||
| sampleB := enc.Generate(1)[0] | |||
| if math.Abs(sampleA-sampleB) > 1e-9 { | |||
| t.Fatalf("expected reset to replay initial sample: %v vs %v", sampleA, sampleB) | |||
| } | |||
| b := enc.NextSample() | |||
| if math.Abs(a-b) > 1e-9 { t.Fatalf("reset failed: %v vs %v", a, b) } | |||
| } | |||
| func TestGroupSchedulerCycles(t *testing.T) { | |||
| cfg := DefaultConfig() | |||
| cfg.PS = "TESTPS" | |||
| cfg.RT = "short" | |||
| cfg := DefaultConfig(); cfg.PS = "TESTPS"; cfg.RT = "short" | |||
| gs := newGroupScheduler(cfg) | |||
| // Should get 4 PS groups then RT groups then cycle | |||
| for i := 0; i < 40; i++ { | |||
| _ = gs.NextGroup() | |||
| } | |||
| // No panic = success | |||
| for i := 0; i < 40; i++ { _ = gs.NextGroup() } | |||
| } | |||
| func TestNormalizePS(t *testing.T) { | |||
| got := normalizePS("radiox") | |||
| if got != "RADIOX " { | |||
| t.Fatalf("unexpected PS: %q", got) | |||
| } | |||
| if normalizePS("radiox") != "RADIOX " { t.Fatal("wrong PS") } | |||
| } | |||
| func TestNormalizeRT(t *testing.T) { | |||
| long := strings.Repeat("a", 80) | |||
| got := normalizeRT(long) | |||
| if len(got) != 64 { | |||
| t.Fatalf("unexpected RT length: %d", len(got)) | |||
| } | |||
| if len(normalizeRT(strings.Repeat("a", 80))) != 64 { t.Fatal("wrong RT length") } | |||
| } | |||
| func TestDifferentialEncoder(t *testing.T) { | |||
| d := diffEncoder{} | |||
| // Input: 0 -> out = 0^0 = 0, prev=0 | |||
| // Input: 1 -> out = 0^1 = 1, prev=1 | |||
| // Input: 0 -> out = 1^0 = 1, prev=1 | |||
| // Input: 1 -> out = 1^1 = 0, prev=0 | |||
| expected := []uint8{0, 1, 1, 0} | |||
| input := []uint8{0, 1, 0, 1} | |||
| for i, in := range input { | |||
| got := d.encode(in) | |||
| if got != expected[i] { | |||
| t.Fatalf("step %d: input=%d expected=%d got=%d", i, in, expected[i], got) | |||
| } | |||
| if got := d.encode(in); got != expected[i] { t.Fatalf("step %d: got %d want %d", i, got, expected[i]) } | |||
| } | |||
| } | |||
| func TestRTSegmentCount(t *testing.T) { | |||
| if n := rtSegmentCount("Hi"); n != 1 { | |||
| t.Fatalf("expected 1, got %d", n) | |||
| } | |||
| if n := rtSegmentCount("Hello World!"); n != 3 { | |||
| t.Fatalf("expected 3, got %d", n) | |||
| } | |||
| if n := rtSegmentCount(strings.Repeat("x", 64)); n != 16 { | |||
| t.Fatalf("expected 16, got %d", n) | |||
| } | |||
| if rtSegmentCount("Hi") != 1 { t.Fatal("expected 1") } | |||
| if rtSegmentCount("Hello World!") != 3 { t.Fatal("expected 3") } | |||
| if rtSegmentCount(strings.Repeat("x", 64)) != 16 { t.Fatal("expected 16") } | |||
| } | |||
| @@ -6,40 +6,35 @@ import ( | |||
| ) | |||
| // Components holds the individual MPX components produced by the stereo encoder. | |||
| // All outputs are unity-normalized. The combiner controls actual injection levels. | |||
| type Components struct { | |||
| Mono float64 // L+R baseband | |||
| Stereo float64 // L-R modulated onto 38 kHz DSB-SC | |||
| Pilot float64 // 19 kHz pilot tone | |||
| Mono float64 // (L+R)/2 baseband | |||
| Stereo float64 // (L-R)/2 * sin(2π·38kHz·t), unity subcarrier | |||
| Pilot float64 // sin(2π·19kHz·t), unity amplitude | |||
| } | |||
| // StereoEncoder generates stereo MPX primitives from stereo audio frames. | |||
| // It internally maintains phase-coherent 19 kHz pilot and 38 kHz subcarrier | |||
| // oscillators so that block-boundary phase continuity is guaranteed. | |||
| type StereoEncoder struct { | |||
| pilot dsp.PilotGenerator | |||
| subcarrier dsp.Oscillator // 38 kHz, phase-locked to pilot (2× pilot) | |||
| LevelStereo float64 | |||
| pilot dsp.PilotGenerator | |||
| subcarrier dsp.Oscillator | |||
| } | |||
| // NewStereoEncoder creates a StereoEncoder configured for the provided sample rate. | |||
| func NewStereoEncoder(sampleRate float64) StereoEncoder { | |||
| return StereoEncoder{ | |||
| pilot: dsp.NewPilotGenerator(sampleRate, 0.1), | |||
| subcarrier: dsp.Oscillator{Frequency: 38000, SampleRate: sampleRate}, | |||
| LevelStereo: 1.0, | |||
| pilot: dsp.NewPilotGenerator(sampleRate), | |||
| subcarrier: dsp.Oscillator{Frequency: 38000, SampleRate: sampleRate}, | |||
| } | |||
| } | |||
| // Encode converts a stereo frame into MPX components. | |||
| // The 38 kHz subcarrier is generated from the internal oscillator, | |||
| // maintaining continuous phase across calls. | |||
| func (s *StereoEncoder) Encode(frame audio.Frame) Components { | |||
| pilot := s.pilot.Sample() | |||
| sub38 := s.subcarrier.Tick() | |||
| return Components{ | |||
| Mono: float64(frame.Mono()), | |||
| Stereo: float64(frame.Difference()) * s.LevelStereo * sub38, | |||
| Stereo: float64(frame.Difference()) * sub38, | |||
| Pilot: pilot, | |||
| } | |||
| } | |||
| @@ -3,7 +3,6 @@ package stereo | |||
| import ( | |||
| "math" | |||
| "testing" | |||
| "github.com/jan/fm-rds-tx/internal/audio" | |||
| ) | |||
| @@ -11,39 +10,22 @@ func TestStereoEncoderEncode(t *testing.T) { | |||
| enc := NewStereoEncoder(228000) | |||
| 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) | |||
| } | |||
| // Stereo should be non-zero for L!=R input | |||
| // (exact value depends on oscillator phase at sample 0) | |||
| // We just verify it's modulated (could be zero at phase=0 for sin) | |||
| // Run a few samples and check some are non-zero | |||
| if math.Abs(result.Mono) > 1e-9 { t.Fatalf("expected mono 0, got %v", result.Mono) } | |||
| var maxStereo float64 | |||
| for i := 0; i < 100; i++ { | |||
| c := enc.Encode(frame) | |||
| if math.Abs(c.Stereo) > maxStereo { | |||
| maxStereo = math.Abs(c.Stereo) | |||
| } | |||
| } | |||
| if maxStereo < 0.1 { | |||
| t.Fatalf("expected non-trivial stereo signal, maxStereo=%.6f", maxStereo) | |||
| if math.Abs(c.Stereo) > maxStereo { maxStereo = math.Abs(c.Stereo) } | |||
| } | |||
| if maxStereo < 0.1 { t.Fatalf("expected non-trivial stereo, maxStereo=%.6f", maxStereo) } | |||
| } | |||
| func TestStereoEncoderMonoSignal(t *testing.T) { | |||
| enc := NewStereoEncoder(228000) | |||
| // Identical L and R should produce zero difference/stereo | |||
| frame := audio.NewFrame(0.5, 0.5) | |||
| for i := 0; i < 100; i++ { | |||
| c := enc.Encode(frame) | |||
| if math.Abs(c.Stereo) > 1e-12 { | |||
| t.Fatalf("expected zero stereo for mono input, got %.9f", c.Stereo) | |||
| } | |||
| if math.Abs(c.Mono-0.5) > 1e-9 { | |||
| t.Fatalf("expected mono=0.5, got %.9f", c.Mono) | |||
| } | |||
| if math.Abs(c.Stereo) > 1e-12 { t.Fatalf("expected zero stereo, got %.9f", c.Stereo) } | |||
| if math.Abs(c.Mono-0.5) > 1e-9 { t.Fatalf("expected mono=0.5, got %.9f", c.Mono) } | |||
| } | |||
| } | |||
| @@ -52,31 +34,17 @@ func TestStereoEncoderPilotRange(t *testing.T) { | |||
| frame := audio.NewFrame(0.1, -0.1) | |||
| for i := 0; i < 1000; i++ { | |||
| c := enc.Encode(frame) | |||
| if c.Pilot < -0.101 || c.Pilot > 0.101 { | |||
| t.Fatalf("pilot out of range: %.6f", c.Pilot) | |||
| } | |||
| if c.Pilot < -1.001 || c.Pilot > 1.001 { t.Fatalf("pilot out of range: %.6f", c.Pilot) } | |||
| } | |||
| } | |||
| func TestStereoEncoderReset(t *testing.T) { | |||
| frame := audio.NewFrame(0.1, -0.1) | |||
| enc := NewStereoEncoder(228000) | |||
| initial := make([]float64, 0, 4) | |||
| for i := 0; i < 4; i++ { | |||
| initial = append(initial, enc.Encode(frame).Pilot) | |||
| } | |||
| initial := make([]float64, 4) | |||
| for i := range initial { initial[i] = 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]) | |||
| } | |||
| if math.Abs(initial[i]-enc.Encode(frame).Pilot) > 1e-9 { t.Fatalf("reset failed at %d", i) } | |||
| } | |||
| } | |||