Explorar el Código

feat: wire full DSP chain into offline generator with FM IQ output mode

tags/v0.4.0-pre
Jan Svabenik hace 1 mes
padre
commit
3678b43427
Se han modificado 2 ficheros con 298 adiciones y 151 borrados
  1. +187
    -109
      internal/offline/generator.go
  2. +111
    -42
      internal/offline/generator_test.go

+ 187
- 109
internal/offline/generator.go Ver fichero

@@ -1,137 +1,215 @@
package offline

import (
"context"
"encoding/binary"
"fmt"
"math"
"path/filepath"
"time"
"github.com/jan/fm-rds-tx/internal/audio"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
"github.com/jan/fm-rds-tx/internal/mpx"
"github.com/jan/fm-rds-tx/internal/output"
"github.com/jan/fm-rds-tx/internal/rds"
"github.com/jan/fm-rds-tx/internal/stereo"
"context"
"encoding/binary"
"fmt"
"path/filepath"
"time"
"github.com/jan/fm-rds-tx/internal/audio"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
"github.com/jan/fm-rds-tx/internal/dsp"
"github.com/jan/fm-rds-tx/internal/mpx"
"github.com/jan/fm-rds-tx/internal/output"
"github.com/jan/fm-rds-tx/internal/rds"
"github.com/jan/fm-rds-tx/internal/stereo"
)

type frameSource interface {
NextFrame() audio.Frame
NextFrame() audio.Frame
}

type SourceInfo struct {
Kind string
SampleRate float64
Detail string
Kind string
SampleRate float64
Detail string
}

type Generator struct {
cfg cfgpkg.Config
cfg cfgpkg.Config
}

func NewGenerator(cfg cfgpkg.Config) *Generator {
return &Generator{cfg: cfg}
return &Generator{cfg: cfg}
}

func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) {
if g.cfg.Audio.InputPath != "" {
if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil {
return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath}
}
return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath}
}
return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"}
if g.cfg.Audio.InputPath != "" {
if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil {
return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath}
}
return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath}
}
return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"}
}

func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
sampleRate := float64(g.cfg.FM.CompositeRateHz)
if sampleRate <= 0 {
sampleRate = 228000
}
samples := int(duration.Seconds() * sampleRate)
if samples <= 0 {
samples = int(sampleRate / 10)
}

frame := &output.CompositeFrame{
Samples: make([]output.IQSample, samples),
SampleRateHz: sampleRate,
Timestamp: time.Now().UTC(),
Sequence: 1,
}

stereoEncoder := stereo.NewStereoEncoder(sampleRate)
combiner := mpx.NewDefaultCombiner()
combiner.PilotGain = g.cfg.FM.PilotLevel
combiner.RDSGain = g.cfg.FM.RDSInjection

rdsEnc, _ := rds.NewEncoder(rds.RDSConfig{
PI: 0x1234,
PS: g.cfg.RDS.PS,
RT: g.cfg.RDS.RadioText,
PTY: uint8(g.cfg.RDS.PTY),
SampleRate: sampleRate,
})
rdsSamples := rdsEnc.Generate(samples)

source, _ := g.sourceFor(sampleRate)

for i := 0; i < samples; i++ {
t := float64(i) / sampleRate
in := source.NextFrame()
comps := stereoEncoder.Encode(in)
stereoDSB := comps.Stereo * math.Sin(2*math.Pi*38000.0*t)
rdsValue := 0.0
if g.cfg.RDS.Enabled && i < len(rdsSamples) {
rdsValue = rdsSamples[i]
}
composite := combiner.Combine(comps.Mono, stereoDSB, comps.Pilot, rdsValue) * g.cfg.FM.OutputDrive
frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
}

return frame
sampleRate := float64(g.cfg.FM.CompositeRateHz)
if sampleRate <= 0 {
sampleRate = 228000
}
samples := int(duration.Seconds() * sampleRate)
if samples <= 0 {
samples = int(sampleRate / 10)
}

frame := &output.CompositeFrame{
Samples: make([]output.IQSample, samples),
SampleRateHz: sampleRate,
Timestamp: time.Now().UTC(),
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)
}

// Stereo encoder (includes stateful 19kHz pilot and 38kHz 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

// RDS encoder (standards-grade group framing + CRC + diff encoding)
rdsEnc, _ := rds.NewEncoder(rds.RDSConfig{
PI: 0x1234,
PS: g.cfg.RDS.PS,
RT: g.cfg.RDS.RadioText,
PTY: uint8(g.cfg.RDS.PTY),
SampleRate: sampleRate,
})

// MPX limiter
var limiter *dsp.MPXLimiter
ceiling := g.cfg.FM.LimiterCeiling
if ceiling <= 0 {
ceiling = 1.0
}
if g.cfg.FM.LimiterEnabled {
limiter = dsp.NewMPXLimiter(ceiling, 0.1, 50, sampleRate)
}

// FM modulator for IQ output
var fmMod *dsp.FMModulator
if g.cfg.FM.FMModulationEnabled {
fmMod = dsp.NewFMModulator(sampleRate)
if g.cfg.FM.MaxDeviationHz > 0 {
fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz
}
}

// Audio source
source, _ := g.sourceFor(sampleRate)

// --- Sample loop ---
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)

// RDS
rdsValue := 0.0
if g.cfg.RDS.Enabled {
rdsBuf := rdsEnc.Generate(1)
rdsValue = rdsBuf[0]
}

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

// 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)}
} else {
frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
}
}

return frame
}

func (g *Generator) WriteFile(path string, duration time.Duration) error {
if path == "" {
path = g.cfg.Backend.OutputPath
}
if path == "" {
path = filepath.Join("build", "offline", "composite.iqf32")
}
backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
Name: "offline-file",
Description: "offline composite file backend",
})
if err != nil {
return err
}
defer backend.Close(context.Background())

if err := backend.Configure(context.Background(), output.BackendConfig{
SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
Channels: 2,
IQLevel: float32(g.cfg.FM.OutputDrive),
}); err != nil {
return err
}

frame := g.GenerateFrame(duration)
if _, err := backend.Write(context.Background(), frame); err != nil {
return err
}
if err := backend.Flush(context.Background()); err != nil {
return err
}
return nil
if path == "" {
path = g.cfg.Backend.OutputPath
}
if path == "" {
path = filepath.Join("build", "offline", "composite.iqf32")
}
backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
Name: "offline-file",
Description: "offline composite file backend",
})
if err != nil {
return err
}
defer backend.Close(context.Background())
if err := backend.Configure(context.Background(), output.BackendConfig{
SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
Channels: 2,
IQLevel: float32(g.cfg.FM.OutputDrive),
}); err != nil {
return err
}
frame := g.GenerateFrame(duration)
if _, err := backend.Write(context.Background(), frame); err != nil {
return err
}
if err := backend.Flush(context.Background()); err != nil {
return err
}
return nil
}

func (g *Generator) Summary(duration time.Duration) string {
sampleRate := float64(g.cfg.FM.CompositeRateHz)
if sampleRate <= 0 {
sampleRate = 228000
}
_, info := g.sourceFor(sampleRate)
return fmt.Sprintf("offline frame: freq=%.1fMHz sampleRate=%d duration=%s outputDrive=%.2f stereo=%t rds=%t source=%s detail=%s", g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(), g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled, info.Kind, info.Detail)
sampleRate := float64(g.cfg.FM.CompositeRateHz)
if sampleRate <= 0 {
sampleRate = 228000
}
_, info := g.sourceFor(sampleRate)
preemph := "off"
if g.cfg.FM.PreEmphasisUS > 0 {
preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisUS)
}
modMode := "composite"
if g.cfg.FM.FMModulationEnabled {
modMode = fmt.Sprintf("FM-IQ(±%.0fHz)", g.cfg.FM.MaxDeviationHz)
}
return fmt.Sprintf("offline frame: freq=%.1fMHz rate=%d duration=%s drive=%.2f stereo=%t rds=%t preemph=%s limiter=%t output=%s source=%s detail=%s",
g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(),
g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled,
preemph, g.cfg.FM.LimiterEnabled, modMode, info.Kind, info.Detail)
}

+ 111
- 42
internal/offline/generator_test.go Ver fichero

@@ -1,59 +1,128 @@
package offline

import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"math"
"os"
"path/filepath"
"strings"
"testing"
"time"

cfgpkg "github.com/jan/fm-rds-tx/internal/config"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
)

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

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

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

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

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

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

Cargando…
Cancelar
Guardar