|
- package offline
-
- import (
- "context"
- "encoding/binary"
- "fmt"
- "path/filepath"
- "strconv"
- "strings"
- "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
- }
-
- 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 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 {
- 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: parsePI(g.cfg.RDS.PI),
- 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)
- 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]
- }
-
- // 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
- }
-
- 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.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)
- }
|