| @@ -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. | // 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 { | type PilotGenerator struct { | ||||
| Oscillator | 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{ | return PilotGenerator{ | ||||
| Oscillator: Oscillator{Frequency: 19000, SampleRate: sampleRate}, | 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 { | func (p *PilotGenerator) Sample() float64 { | ||||
| return p.Level * p.Oscillator.Tick() | |||||
| return p.Oscillator.Tick() | |||||
| } | } | ||||
| @@ -5,8 +5,6 @@ import ( | |||||
| "encoding/binary" | "encoding/binary" | ||||
| "fmt" | "fmt" | ||||
| "path/filepath" | "path/filepath" | ||||
| "strconv" | |||||
| "strings" | |||||
| "time" | "time" | ||||
| "github.com/jan/fm-rds-tx/internal/audio" | "github.com/jan/fm-rds-tx/internal/audio" | ||||
| @@ -22,6 +20,36 @@ type frameSource interface { | |||||
| NextFrame() audio.Frame | 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 { | type SourceInfo struct { | ||||
| Kind string | Kind string | ||||
| SampleRate float64 | 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"} | 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 { | func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame { | ||||
| sampleRate := float64(g.cfg.FM.CompositeRateHz) | sampleRate := float64(g.cfg.FM.CompositeRateHz) | ||||
| if sampleRate <= 0 { | if sampleRate <= 0 { | ||||
| @@ -77,33 +91,32 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||||
| Sequence: 1, | 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) | 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{ | rdsEnc, _ := rds.NewEncoder(rds.RDSConfig{ | ||||
| PI: parsePI(g.cfg.RDS.PI), | |||||
| PI: piCode, | |||||
| PS: g.cfg.RDS.PS, | PS: g.cfg.RDS.PS, | ||||
| RT: g.cfg.RDS.RadioText, | RT: g.cfg.RDS.RadioText, | ||||
| PTY: uint8(g.cfg.RDS.PTY), | PTY: uint8(g.cfg.RDS.PTY), | ||||
| SampleRate: sampleRate, | SampleRate: sampleRate, | ||||
| }) | }) | ||||
| // MPX limiter | |||||
| // Limiter | |||||
| var limiter *dsp.MPXLimiter | var limiter *dsp.MPXLimiter | ||||
| ceiling := g.cfg.FM.LimiterCeiling | ceiling := g.cfg.FM.LimiterCeiling | ||||
| if ceiling <= 0 { | if ceiling <= 0 { | ||||
| @@ -113,7 +126,7 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||||
| limiter = dsp.NewMPXLimiter(ceiling, 0.1, 50, sampleRate) | limiter = dsp.NewMPXLimiter(ceiling, 0.1, 50, sampleRate) | ||||
| } | } | ||||
| // FM modulator for IQ output | |||||
| // FM modulator | |||||
| var fmMod *dsp.FMModulator | var fmMod *dsp.FMModulator | ||||
| if g.cfg.FM.FMModulationEnabled { | if g.cfg.FM.FMModulationEnabled { | ||||
| fmMod = dsp.NewFMModulator(sampleRate) | 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++ { | for i := 0; i < samples; i++ { | ||||
| in := source.NextFrame() | 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 { | if !g.cfg.FM.StereoEnabled { | ||||
| comps.Stereo = 0 | comps.Stereo = 0 | ||||
| comps.Pilot = 0 | comps.Pilot = 0 | ||||
| } | } | ||||
| // RDS | |||||
| rdsValue := 0.0 | rdsValue := 0.0 | ||||
| if g.cfg.RDS.Enabled { | 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) | composite := combiner.Combine(comps.Mono, comps.Stereo, comps.Pilot, rdsValue) | ||||
| // Apply output drive | |||||
| composite *= g.cfg.FM.OutputDrive | composite *= g.cfg.FM.OutputDrive | ||||
| // Limiter | |||||
| if limiter != nil { | if limiter != nil { | ||||
| composite = limiter.Process(composite) | 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 { | if fmMod != nil { | ||||
| iq_i, iq_q := fmMod.Modulate(composite) | iq_i, iq_q := fmMod.Modulate(composite) | ||||
| frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)} | 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 { | if _, err := backend.Write(context.Background(), frame); err != nil { | ||||
| return err | 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 { | func (g *Generator) Summary(duration time.Duration) string { | ||||
| @@ -221,8 +207,8 @@ func (g *Generator) Summary(duration time.Duration) string { | |||||
| } | } | ||||
| _, info := g.sourceFor(sampleRate) | _, info := g.sourceFor(sampleRate) | ||||
| preemph := "off" | 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" | modMode := "composite" | ||||
| if g.cfg.FM.FMModulationEnabled { | if g.cfg.FM.FMModulationEnabled { | ||||
| @@ -14,170 +14,116 @@ import ( | |||||
| func TestGenerateFrame(t *testing.T) { | func TestGenerateFrame(t *testing.T) { | ||||
| g := NewGenerator(cfgpkg.Default()) | g := NewGenerator(cfgpkg.Default()) | ||||
| frame := g.GenerateFrame(50 * time.Millisecond) | 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) { | 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++ { | for i := 100; i < len(frame.Samples) && i < 200; i++ { | ||||
| s := frame.Samples[i] | s := frame.Samples[i] | ||||
| mag := math.Sqrt(float64(s.I)*float64(s.I) + float64(s.Q)*float64(s.Q)) | 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) { | 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++ { | 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 | 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) { | func TestWriteFile(t *testing.T) { | ||||
| cfg := cfgpkg.Default() | cfg := cfgpkg.Default() | ||||
| out := filepath.Join(t.TempDir(), "test.iqf32") | 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) { | func TestLimiterPreventsClipping(t *testing.T) { | ||||
| cfg := cfgpkg.Default() | 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 { | 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 ( | const ( | ||||
| defaultSubcarrierHz = 57000.0 | defaultSubcarrierHz = 57000.0 | ||||
| defaultBitRateHz = 1187.5 | defaultBitRateHz = 1187.5 | ||||
| defaultAmplitude = 0.05 | |||||
| // Each RDS group has 4 blocks of 26 bits each (16 data + 10 check). | // Each RDS group has 4 blocks of 26 bits each (16 data + 10 check). | ||||
| bitsPerBlock = 26 | bitsPerBlock = 26 | ||||
| @@ -18,15 +17,13 @@ const ( | |||||
| // CRC / offset words per IEC 62106 / EN 50067 | // 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 | const crcPoly = 0x1B9 | ||||
| // Offset words for blocks A, B, C, C', D. | |||||
| var offsetWords = map[byte]uint16{ | var offsetWords = map[byte]uint16{ | ||||
| 'A': 0x0FC, | 'A': 0x0FC, | ||||
| 'B': 0x198, | 'B': 0x198, | ||||
| 'C': 0x168, | 'C': 0x168, | ||||
| 'c': 0x350, // C' for type B groups | |||||
| 'c': 0x350, | |||||
| 'D': 0x1B4, | 'D': 0x1B4, | ||||
| } | } | ||||
| @@ -49,14 +46,9 @@ func encodeBlock(data uint16, offset byte) uint32 { | |||||
| // Group building | // 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 { | func buildGroup0A(pi uint16, pty uint8, tp, ta bool, segIdx int, ps string) [4]uint16 { | ||||
| ps = normalizePS(ps) | ps = normalizePS(ps) | ||||
| blockA := pi | blockA := pi | ||||
| // Block B: group type 0A (0000 0), TP, PTY, TA, MS=1, DI=0, segment address | |||||
| var blockB uint16 | var blockB uint16 | ||||
| if tp { | if tp { | ||||
| blockB |= 1 << 10 | blockB |= 1 << 10 | ||||
| @@ -65,30 +57,19 @@ func buildGroup0A(pi uint16, pty uint8, tp, ta bool, segIdx int, ps string) [4]u | |||||
| if ta { | if ta { | ||||
| blockB |= 1 << 4 | blockB |= 1 << 4 | ||||
| } | } | ||||
| blockB |= 1 << 3 // MS = music | |||||
| blockB |= 1 << 3 | |||||
| blockB |= uint16(segIdx & 0x03) | blockB |= uint16(segIdx & 0x03) | ||||
| // Block C: AF (not implemented) – send PI as filler (common practice) | |||||
| blockC := pi | blockC := pi | ||||
| // Block D: 2 PS characters | |||||
| ci := segIdx * 2 | 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} | 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 { | func buildGroup2A(pi uint16, pty uint8, tp bool, abFlag bool, segIdx int, rt string) [4]uint16 { | ||||
| rt = normalizeRT(rt) | rt = normalizeRT(rt) | ||||
| blockA := pi | blockA := pi | ||||
| var blockB uint16 | var blockB uint16 | ||||
| blockB = 2 << 12 // group type 2 | |||||
| blockB = 2 << 12 | |||||
| if tp { | if tp { | ||||
| blockB |= 1 << 10 | 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 |= 1 << 4 | ||||
| } | } | ||||
| blockB |= uint16(segIdx & 0x0F) | blockB |= uint16(segIdx & 0x0F) | ||||
| ci := segIdx * 4 | ci := segIdx * 4 | ||||
| ch0, ch1, ch2, ch3 := padRT(rt, ci) | ch0, ch1, ch2, ch3 := padRT(rt, ci) | ||||
| blockC := (uint16(ch0) << 8) | uint16(ch1) | blockC := (uint16(ch0) << 8) | uint16(ch1) | ||||
| blockD := (uint16(ch2) << 8) | uint16(ch3) | blockD := (uint16(ch2) << 8) | uint16(ch3) | ||||
| return [4]uint16{blockA, blockB, blockC, blockD} | return [4]uint16{blockA, blockB, blockC, blockD} | ||||
| } | } | ||||
| @@ -120,7 +99,6 @@ func padRT(rt string, offset int) (byte, byte, byte, byte) { | |||||
| // Group scheduler | // Group scheduler | ||||
| // ----------------------------------------------------------------------- | // ----------------------------------------------------------------------- | ||||
| // GroupScheduler cycles through 0A and 2A groups. | |||||
| type GroupScheduler struct { | type GroupScheduler struct { | ||||
| cfg RDSConfig | cfg RDSConfig | ||||
| psIdx int | psIdx int | ||||
| @@ -133,16 +111,13 @@ func newGroupScheduler(cfg RDSConfig) *GroupScheduler { | |||||
| return &GroupScheduler{cfg: cfg} | return &GroupScheduler{cfg: cfg} | ||||
| } | } | ||||
| // NextGroup returns the next RDS group as 4 raw 16-bit words. | |||||
| func (gs *GroupScheduler) NextGroup() [4]uint16 { | func (gs *GroupScheduler) NextGroup() [4]uint16 { | ||||
| // Pattern: 4x 0A (full PS cycle), then N x 2A (full RT cycle), repeat. | |||||
| if gs.phase < 4 { | if gs.phase < 4 { | ||||
| g := buildGroup0A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, gs.cfg.TA, gs.psIdx, gs.cfg.PS) | 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.psIdx = (gs.psIdx + 1) % 4 | ||||
| gs.phase++ | gs.phase++ | ||||
| return g | return g | ||||
| } | } | ||||
| g := buildGroup2A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, gs.rtABFlag, gs.rtIdx, gs.cfg.RT) | g := buildGroup2A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, gs.rtABFlag, gs.rtIdx, gs.cfg.RT) | ||||
| gs.rtIdx++ | gs.rtIdx++ | ||||
| rtSegs := rtSegmentCount(gs.cfg.RT) | 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. | // 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 { | type Encoder struct { | ||||
| config RDSConfig | config RDSConfig | ||||
| sampleRate float64 | sampleRate float64 | ||||
| amplitude float64 | |||||
| scheduler *GroupScheduler | scheduler *GroupScheduler | ||||
| diff diffEncoder | diff diffEncoder | ||||
| bitBuf []uint8 | |||||
| bitBuf [bitsPerGroup]uint8 | |||||
| bitLen int | |||||
| bitPos int | bitPos int | ||||
| bitPhase float64 | bitPhase float64 | ||||
| subPhase float64 | subPhase float64 | ||||
| @@ -211,10 +188,9 @@ func NewEncoder(cfg RDSConfig) (*Encoder, error) { | |||||
| cfg.RT = normalizeRT(cfg.RT) | cfg.RT = normalizeRT(cfg.RT) | ||||
| enc := &Encoder{ | enc := &Encoder{ | ||||
| config: cfg, | |||||
| config: cfg, | |||||
| sampleRate: cfg.SampleRate, | sampleRate: cfg.SampleRate, | ||||
| amplitude: defaultAmplitude, | |||||
| scheduler: newGroupScheduler(cfg), | |||||
| scheduler: newGroupScheduler(cfg), | |||||
| } | } | ||||
| enc.loadNextGroup() | enc.loadNextGroup() | ||||
| return enc, nil | return enc, nil | ||||
| @@ -226,48 +202,22 @@ func (e *Encoder) Reset() { | |||||
| e.subPhase = 0 | e.subPhase = 0 | ||||
| e.diff = diffEncoder{} | e.diff = diffEncoder{} | ||||
| e.scheduler = newGroupScheduler(e.config) | e.scheduler = newGroupScheduler(e.config) | ||||
| e.bitBuf = nil | |||||
| e.bitLen = 0 | |||||
| e.bitPos = 0 | e.bitPos = 0 | ||||
| e.loadNextGroup() | 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 | e.subPhase += defaultSubcarrierHz / e.sampleRate | ||||
| if e.subPhase >= 1 { | if e.subPhase >= 1 { | ||||
| @@ -279,10 +229,36 @@ func (e *Encoder) nextSample() float64 { | |||||
| steps := int(e.bitPhase) | steps := int(e.bitPhase) | ||||
| e.bitPhase -= float64(steps) | e.bitPhase -= float64(steps) | ||||
| e.bitPos += steps | e.bitPos += steps | ||||
| if e.bitPos >= len(e.bitBuf) { | |||||
| if e.bitPos >= e.bitLen { | |||||
| e.loadNextGroup() | e.loadNextGroup() | ||||
| } | } | ||||
| } | } | ||||
| return value | 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) { | func TestCRC10KnownVector(t *testing.T) { | ||||
| // Verify the CRC polynomial produces 10-bit outputs | |||||
| c := crc10(0x1234) | 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) { | func TestEncodeBlockProduces26Bits(t *testing.T) { | ||||
| block := encodeBlock(0x1234, 'A') | 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") | 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") | 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) { | 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) { | func TestEncoderGenerate(t *testing.T) { | ||||
| cfg := DefaultConfig() | |||||
| cfg.SampleRate = 228000 | |||||
| cfg := DefaultConfig(); cfg.SampleRate = 228000 | |||||
| enc, err := NewEncoder(cfg) | enc, err := NewEncoder(cfg) | ||||
| if err != nil { | |||||
| t.Fatalf("unexpected error: %v", err) | |||||
| } | |||||
| if err != nil { t.Fatal(err) } | |||||
| samples := enc.Generate(1024) | 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 { | for _, s := range samples { | ||||
| a := math.Abs(s) | |||||
| energy += s * 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) { | 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() | 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) { | func TestGroupSchedulerCycles(t *testing.T) { | ||||
| cfg := DefaultConfig() | |||||
| cfg.PS = "TESTPS" | |||||
| cfg.RT = "short" | |||||
| cfg := DefaultConfig(); cfg.PS = "TESTPS"; cfg.RT = "short" | |||||
| gs := newGroupScheduler(cfg) | 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) { | 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) { | 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) { | func TestDifferentialEncoder(t *testing.T) { | ||||
| d := diffEncoder{} | 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} | expected := []uint8{0, 1, 1, 0} | ||||
| input := []uint8{0, 1, 0, 1} | input := []uint8{0, 1, 0, 1} | ||||
| for i, in := range input { | 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) { | 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. | // 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 { | 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. | // 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 { | 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. | // NewStereoEncoder creates a StereoEncoder configured for the provided sample rate. | ||||
| func NewStereoEncoder(sampleRate float64) StereoEncoder { | func NewStereoEncoder(sampleRate float64) StereoEncoder { | ||||
| return 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. | // 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 { | func (s *StereoEncoder) Encode(frame audio.Frame) Components { | ||||
| pilot := s.pilot.Sample() | pilot := s.pilot.Sample() | ||||
| sub38 := s.subcarrier.Tick() | sub38 := s.subcarrier.Tick() | ||||
| return Components{ | return Components{ | ||||
| Mono: float64(frame.Mono()), | Mono: float64(frame.Mono()), | ||||
| Stereo: float64(frame.Difference()) * s.LevelStereo * sub38, | |||||
| Stereo: float64(frame.Difference()) * sub38, | |||||
| Pilot: pilot, | Pilot: pilot, | ||||
| } | } | ||||
| } | } | ||||
| @@ -3,7 +3,6 @@ package stereo | |||||
| import ( | import ( | ||||
| "math" | "math" | ||||
| "testing" | "testing" | ||||
| "github.com/jan/fm-rds-tx/internal/audio" | "github.com/jan/fm-rds-tx/internal/audio" | ||||
| ) | ) | ||||
| @@ -11,39 +10,22 @@ func TestStereoEncoderEncode(t *testing.T) { | |||||
| enc := NewStereoEncoder(228000) | enc := NewStereoEncoder(228000) | ||||
| frame := audio.NewFrame(1, -1) | frame := audio.NewFrame(1, -1) | ||||
| result := enc.Encode(frame) | 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 | var maxStereo float64 | ||||
| for i := 0; i < 100; i++ { | for i := 0; i < 100; i++ { | ||||
| c := enc.Encode(frame) | 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) { | func TestStereoEncoderMonoSignal(t *testing.T) { | ||||
| enc := NewStereoEncoder(228000) | enc := NewStereoEncoder(228000) | ||||
| // Identical L and R should produce zero difference/stereo | |||||
| frame := audio.NewFrame(0.5, 0.5) | frame := audio.NewFrame(0.5, 0.5) | ||||
| for i := 0; i < 100; i++ { | for i := 0; i < 100; i++ { | ||||
| c := enc.Encode(frame) | 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) | frame := audio.NewFrame(0.1, -0.1) | ||||
| for i := 0; i < 1000; i++ { | for i := 0; i < 1000; i++ { | ||||
| c := enc.Encode(frame) | 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) { | func TestStereoEncoderReset(t *testing.T) { | ||||
| frame := audio.NewFrame(0.1, -0.1) | frame := audio.NewFrame(0.1, -0.1) | ||||
| enc := NewStereoEncoder(228000) | 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() | enc.Reset() | ||||
| afterReset := make([]float64, 0, 4) | |||||
| for i := 0; i < 4; i++ { | |||||
| afterReset = append(afterReset, enc.Encode(frame).Pilot) | |||||
| } | |||||
| for i := range initial { | 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) } | |||||
| } | } | ||||
| } | } | ||||