|
|
@@ -1,137 +1,215 @@ |
|
|
package offline |
|
|
package offline |
|
|
|
|
|
|
|
|
import ( |
|
|
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 { |
|
|
type frameSource interface { |
|
|
NextFrame() audio.Frame |
|
|
|
|
|
|
|
|
NextFrame() audio.Frame |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
type SourceInfo struct { |
|
|
type SourceInfo struct { |
|
|
Kind string |
|
|
|
|
|
SampleRate float64 |
|
|
|
|
|
Detail string |
|
|
|
|
|
|
|
|
Kind string |
|
|
|
|
|
SampleRate float64 |
|
|
|
|
|
Detail string |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
type Generator struct { |
|
|
type Generator struct { |
|
|
cfg cfgpkg.Config |
|
|
|
|
|
|
|
|
cfg cfgpkg.Config |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
func NewGenerator(cfg cfgpkg.Config) *Generator { |
|
|
func NewGenerator(cfg cfgpkg.Config) *Generator { |
|
|
return &Generator{cfg: cfg} |
|
|
|
|
|
|
|
|
return &Generator{cfg: cfg} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) { |
|
|
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 { |
|
|
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 { |
|
|
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 { |
|
|
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) |
|
|
} |
|
|
} |