Procházet zdrojové kódy

feat: add spectral verification and unify real-time signal path

tags/v0.5.0-pre
Jan Svabenik před 1 měsícem
rodič
revize
f420bc6dae
10 změnil soubory, kde provedl 378 přidání a 435 odebrání
  1. +31
    -0
      internal/dsp/goertzel.go
  2. +28
    -0
      internal/dsp/goertzel_test.go
  3. +6
    -6
      internal/dsp/oscillator.go
  4. +53
    -67
      internal/offline/generator.go
  5. +71
    -125
      internal/offline/generator_test.go
  6. +84
    -0
      internal/offline/spectral_test.go
  7. +47
    -71
      internal/rds/encoder.go
  8. +40
    -111
      internal/rds/encoder_test.go
  9. +9
    -14
      internal/stereo/encoder.go
  10. +9
    -41
      internal/stereo/encoder_test.go

+ 31
- 0
internal/dsp/goertzel.go Zobrazit soubor

@@ -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
}

+ 28
- 0
internal/dsp/goertzel_test.go Zobrazit soubor

@@ -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")
}
}

+ 6
- 6
internal/dsp/oscillator.go Zobrazit soubor

@@ -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()
}

+ 53
- 67
internal/offline/generator.go Zobrazit soubor

@@ -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 {


+ 71
- 125
internal/offline/generator_test.go Zobrazit soubor

@@ -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) }
}

+ 84
- 0
internal/offline/spectral_test.go Zobrazit soubor

@@ -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)
}
}

+ 47
- 71
internal/rds/encoder.go Zobrazit soubor

@@ -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
}

+ 40
- 111
internal/rds/encoder_test.go Zobrazit soubor

@@ -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") }
}

+ 9
- 14
internal/stereo/encoder.go Zobrazit soubor

@@ -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,
}
}


+ 9
- 41
internal/stereo/encoder_test.go Zobrazit soubor

@@ -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) }
}
}

Načítá se…
Zrušit
Uložit