From f420bc6dae968bc70f5817a421f0dfe9d9dbff92 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Fri, 3 Apr 2026 10:06:31 +0200 Subject: [PATCH] feat: add spectral verification and unify real-time signal path --- internal/dsp/goertzel.go | 31 +++++ internal/dsp/goertzel_test.go | 28 +++++ internal/dsp/oscillator.go | 12 +- internal/offline/generator.go | 120 ++++++++---------- internal/offline/generator_test.go | 196 +++++++++++------------------ internal/offline/spectral_test.go | 84 +++++++++++++ internal/rds/encoder.go | 118 +++++++---------- internal/rds/encoder_test.go | 151 ++++++---------------- internal/stereo/encoder.go | 23 ++-- internal/stereo/encoder_test.go | 50 ++------ 10 files changed, 378 insertions(+), 435 deletions(-) create mode 100644 internal/dsp/goertzel.go create mode 100644 internal/dsp/goertzel_test.go create mode 100644 internal/offline/spectral_test.go diff --git a/internal/dsp/goertzel.go b/internal/dsp/goertzel.go new file mode 100644 index 0000000..7b27b6a --- /dev/null +++ b/internal/dsp/goertzel.go @@ -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 +} diff --git a/internal/dsp/goertzel_test.go b/internal/dsp/goertzel_test.go new file mode 100644 index 0000000..c24483a --- /dev/null +++ b/internal/dsp/goertzel_test.go @@ -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") + } +} diff --git a/internal/dsp/oscillator.go b/internal/dsp/oscillator.go index f432eb6..e71c949 100644 --- a/internal/dsp/oscillator.go +++ b/internal/dsp/oscillator.go @@ -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() } diff --git a/internal/offline/generator.go b/internal/offline/generator.go index 9fc2590..3d7864c 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -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 { diff --git a/internal/offline/generator_test.go b/internal/offline/generator_test.go index 7e8ab6d..0a411f8 100644 --- a/internal/offline/generator_test.go +++ b/internal/offline/generator_test.go @@ -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) } +} diff --git a/internal/offline/spectral_test.go b/internal/offline/spectral_test.go new file mode 100644 index 0000000..cd38fc4 --- /dev/null +++ b/internal/offline/spectral_test.go @@ -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) + } +} diff --git a/internal/rds/encoder.go b/internal/rds/encoder.go index c98a56f..04e907a 100644 --- a/internal/rds/encoder.go +++ b/internal/rds/encoder.go @@ -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 +} diff --git a/internal/rds/encoder_test.go b/internal/rds/encoder_test.go index 478c94a..4e283fc 100644 --- a/internal/rds/encoder_test.go +++ b/internal/rds/encoder_test.go @@ -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") } } diff --git a/internal/stereo/encoder.go b/internal/stereo/encoder.go index f1da37f..7881f6b 100644 --- a/internal/stereo/encoder.go +++ b/internal/stereo/encoder.go @@ -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, } } diff --git a/internal/stereo/encoder_test.go b/internal/stereo/encoder_test.go index 0f5ac44..95045ba 100644 --- a/internal/stereo/encoder_test.go +++ b/internal/stereo/encoder_test.go @@ -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) } } }