package offline import ( "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 } // 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 Detail string } type Generator struct { cfg cfgpkg.Config } func NewGenerator(cfg cfgpkg.Config) *Generator { 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"} } 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, } // 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 (unity-normalized pilot + subcarrier) stereoEncoder := stereo.NewStereoEncoder(sampleRate) // 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 (unity-normalized output) piCode, _ := cfgpkg.ParsePI(g.cfg.RDS.PI) // already validated rdsEnc, _ := rds.NewEncoder(rds.RDSConfig{ PI: piCode, PS: g.cfg.RDS.PS, RT: g.cfg.RDS.RadioText, PTY: uint8(g.cfg.RDS.PTY), SampleRate: sampleRate, }) // 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 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 } } // --- Sample loop (zero-allocation hot path) --- for i := 0; i < samples; i++ { in := source.NextFrame() comps := stereoEncoder.Encode(in) if !g.cfg.FM.StereoEnabled { comps.Stereo = 0 comps.Pilot = 0 } rdsValue := 0.0 if g.cfg.RDS.Enabled { rdsValue = rdsEnc.NextSample() } composite := combiner.Combine(comps.Mono, comps.Stereo, comps.Pilot, rdsValue) composite *= g.cfg.FM.OutputDrive if limiter != nil { composite = limiter.Process(composite) composite = dsp.HardClip(composite, ceiling) // safety net only with limiter } 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 } return backend.Flush(context.Background()) } func (g *Generator) Summary(duration time.Duration) string { sampleRate := float64(g.cfg.FM.CompositeRateHz) if sampleRate <= 0 { sampleRate = 228000 } _, info := g.sourceFor(sampleRate) preemph := "off" if g.cfg.FM.PreEmphasisTauUS > 0 { preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisTauUS) } 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) }