Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

347 lines
11KB

  1. package offline
  2. import (
  3. "context"
  4. "encoding/binary"
  5. "fmt"
  6. "path/filepath"
  7. "sync/atomic"
  8. "time"
  9. "github.com/jan/fm-rds-tx/internal/audio"
  10. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  11. "github.com/jan/fm-rds-tx/internal/dsp"
  12. "github.com/jan/fm-rds-tx/internal/mpx"
  13. "github.com/jan/fm-rds-tx/internal/output"
  14. "github.com/jan/fm-rds-tx/internal/rds"
  15. "github.com/jan/fm-rds-tx/internal/stereo"
  16. )
  17. type frameSource interface {
  18. NextFrame() audio.Frame
  19. }
  20. // LiveParams carries DSP parameters that can be hot-swapped at runtime.
  21. // Loaded once per chunk via atomic pointer — zero per-sample overhead.
  22. type LiveParams struct {
  23. OutputDrive float64
  24. StereoEnabled bool
  25. PilotLevel float64
  26. RDSInjection float64
  27. RDSEnabled bool
  28. LimiterEnabled bool
  29. LimiterCeiling float64
  30. }
  31. // PreEmphasizedSource wraps an audio source and applies pre-emphasis.
  32. // The source is expected to already output at composite rate (resampled
  33. // upstream). Pre-emphasis is applied per-sample at that rate.
  34. type PreEmphasizedSource struct {
  35. src frameSource
  36. preL *dsp.PreEmphasis
  37. preR *dsp.PreEmphasis
  38. gain float64
  39. }
  40. func NewPreEmphasizedSource(src frameSource, tauUS, sampleRate, gain float64) *PreEmphasizedSource {
  41. p := &PreEmphasizedSource{src: src, gain: gain}
  42. if tauUS > 0 {
  43. p.preL = dsp.NewPreEmphasis(tauUS, sampleRate)
  44. p.preR = dsp.NewPreEmphasis(tauUS, sampleRate)
  45. }
  46. return p
  47. }
  48. func (p *PreEmphasizedSource) NextFrame() audio.Frame {
  49. f := p.src.NextFrame()
  50. l := float64(f.L) * p.gain
  51. r := float64(f.R) * p.gain
  52. if p.preL != nil {
  53. l = p.preL.Process(l)
  54. r = p.preR.Process(r)
  55. }
  56. return audio.NewFrame(audio.Sample(l), audio.Sample(r))
  57. }
  58. type SourceInfo struct {
  59. Kind string
  60. SampleRate float64
  61. Detail string
  62. }
  63. type Generator struct {
  64. cfg cfgpkg.Config
  65. // Persistent DSP state across GenerateFrame calls
  66. source *PreEmphasizedSource
  67. stereoEncoder stereo.StereoEncoder
  68. rdsEnc *rds.Encoder
  69. combiner mpx.DefaultCombiner
  70. limiter *dsp.StereoLimiter // stereo-linked, operates on L/R BEFORE stereo encoding
  71. lpfL, lpfR *dsp.BiquadLPF // 15kHz lowpass after limiter, protects RDS band
  72. fmMod *dsp.FMModulator
  73. sampleRate float64
  74. initialized bool
  75. frameSeq uint64
  76. // Pre-allocated frame buffer — reused every GenerateFrame call.
  77. frameBuf *output.CompositeFrame
  78. bufCap int
  79. // Live-updatable DSP parameters — written by control API, read per chunk.
  80. liveParams atomic.Pointer[LiveParams]
  81. // Optional external audio source (e.g. StreamResampler for live audio).
  82. // When set, takes priority over WAV/tones in sourceFor().
  83. externalSource frameSource
  84. }
  85. func NewGenerator(cfg cfgpkg.Config) *Generator {
  86. return &Generator{cfg: cfg}
  87. }
  88. // SetExternalSource sets a live audio source (e.g. StreamResampler) that
  89. // takes priority over WAV/tone sources. Must be called before the first
  90. // GenerateFrame() call (i.e. before init).
  91. func (g *Generator) SetExternalSource(src frameSource) {
  92. g.externalSource = src
  93. }
  94. // UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API,
  95. // applied at the next chunk boundary by the DSP goroutine.
  96. func (g *Generator) UpdateLive(p LiveParams) {
  97. g.liveParams.Store(&p)
  98. }
  99. // CurrentLiveParams returns the current live parameter snapshot.
  100. // Used by Engine.UpdateConfig to do read-modify-write on the params.
  101. func (g *Generator) CurrentLiveParams() LiveParams {
  102. if lp := g.liveParams.Load(); lp != nil {
  103. return *lp
  104. }
  105. return LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0}
  106. }
  107. // RDSEncoder returns the live RDS encoder, or nil if RDS is disabled.
  108. // Used by the Engine to forward text updates.
  109. func (g *Generator) RDSEncoder() *rds.Encoder {
  110. return g.rdsEnc
  111. }
  112. func (g *Generator) init() {
  113. if g.initialized {
  114. return
  115. }
  116. g.sampleRate = float64(g.cfg.FM.CompositeRateHz)
  117. if g.sampleRate <= 0 {
  118. g.sampleRate = 228000
  119. }
  120. rawSource, _ := g.sourceFor(g.sampleRate)
  121. g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain)
  122. g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate)
  123. g.combiner = mpx.DefaultCombiner{
  124. MonoGain: 1.0, StereoGain: 1.0,
  125. PilotGain: g.cfg.FM.PilotLevel, RDSGain: g.cfg.FM.RDSInjection,
  126. }
  127. if g.cfg.RDS.Enabled {
  128. piCode, _ := cfgpkg.ParsePI(g.cfg.RDS.PI)
  129. g.rdsEnc, _ = rds.NewEncoder(rds.RDSConfig{
  130. PI: piCode, PS: g.cfg.RDS.PS, RT: g.cfg.RDS.RadioText,
  131. PTY: uint8(g.cfg.RDS.PTY), SampleRate: g.sampleRate,
  132. })
  133. }
  134. ceiling := g.cfg.FM.LimiterCeiling
  135. if ceiling <= 0 { ceiling = 1.0 }
  136. // Audio ceiling leaves headroom for pilot + RDS so total ≤ ceiling
  137. pilotAmp := g.cfg.FM.PilotLevel * g.cfg.FM.OutputDrive
  138. rdsAmp := g.cfg.FM.RDSInjection * g.cfg.FM.OutputDrive
  139. audioCeiling := ceiling - pilotAmp - rdsAmp
  140. if audioCeiling < 0.3 { audioCeiling = 0.3 }
  141. if g.cfg.FM.LimiterEnabled {
  142. g.limiter = dsp.NewStereoLimiter(audioCeiling, 0.5, 100, g.sampleRate)
  143. }
  144. // 15kHz lowpass after limiter — removes limiter gain-step intermodulation
  145. // products that would otherwise fall into pilot/stereo/RDS bands.
  146. g.lpfL = dsp.NewBiquadLPF(15000, g.sampleRate)
  147. g.lpfR = dsp.NewBiquadLPF(15000, g.sampleRate)
  148. if g.cfg.FM.FMModulationEnabled {
  149. g.fmMod = dsp.NewFMModulator(g.sampleRate)
  150. if g.cfg.FM.MaxDeviationHz > 0 { g.fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz }
  151. }
  152. // Seed initial live params from config
  153. g.liveParams.Store(&LiveParams{
  154. OutputDrive: g.cfg.FM.OutputDrive,
  155. StereoEnabled: g.cfg.FM.StereoEnabled,
  156. PilotLevel: g.cfg.FM.PilotLevel,
  157. RDSInjection: g.cfg.FM.RDSInjection,
  158. RDSEnabled: g.cfg.RDS.Enabled,
  159. LimiterEnabled: g.cfg.FM.LimiterEnabled,
  160. LimiterCeiling: ceiling,
  161. })
  162. g.initialized = true
  163. }
  164. func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) {
  165. if g.externalSource != nil {
  166. return g.externalSource, SourceInfo{Kind: "stream", SampleRate: sampleRate, Detail: "live audio"}
  167. }
  168. if g.cfg.Audio.InputPath != "" {
  169. if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil {
  170. return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath}
  171. }
  172. 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}
  173. }
  174. return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"}
  175. }
  176. func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
  177. g.init()
  178. samples := int(duration.Seconds() * g.sampleRate)
  179. if samples <= 0 { samples = int(g.sampleRate / 10) }
  180. // Reuse buffer — grow only if needed, never shrink
  181. if g.frameBuf == nil || g.bufCap < samples {
  182. g.frameBuf = &output.CompositeFrame{
  183. Samples: make([]output.IQSample, samples),
  184. }
  185. g.bufCap = samples
  186. }
  187. frame := g.frameBuf
  188. frame.Samples = frame.Samples[:samples]
  189. frame.SampleRateHz = g.sampleRate
  190. frame.Timestamp = time.Now().UTC()
  191. g.frameSeq++
  192. frame.Sequence = g.frameSeq
  193. // Load live params once per chunk — single atomic read, zero per-sample cost
  194. lp := g.liveParams.Load()
  195. if lp == nil {
  196. // Fallback: should never happen after init(), but be safe
  197. lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0}
  198. }
  199. // Signal path (matches professional broadcast processors):
  200. // Audio L/R → × Drive → Stereo-linked limiter → Stereo encoder
  201. // → Mono + Stereo sub (from limited audio, natural levels)
  202. // → + Pilot (fixed) → + RDS (fixed) → FM modulator
  203. //
  204. // The limiter never sees the 38kHz subcarrier, so it can't pump
  205. // the stereo difference signal. Pilot and RDS are post-encoder
  206. // at fixed amplitudes, unaffected by audio dynamics.
  207. //
  208. // Audio ceiling is auto-reduced to leave headroom for pilot + RDS,
  209. // so total composite stays within ±ceiling (= ±75kHz deviation).
  210. ceiling := lp.LimiterCeiling
  211. if ceiling <= 0 { ceiling = 1.0 }
  212. pilotAmp := lp.PilotLevel * lp.OutputDrive
  213. rdsAmp := lp.RDSInjection * lp.OutputDrive
  214. audioCeiling := ceiling - pilotAmp - rdsAmp
  215. if audioCeiling < 0.3 { audioCeiling = 0.3 } // safety floor
  216. for i := 0; i < samples; i++ {
  217. in := g.source.NextFrame()
  218. // --- Stage 1: Band-limit pre-emphasized audio ---
  219. // The 15kHz LPF goes BEFORE drive+limiter. Pre-emphasis boosts
  220. // HF by up to +13.5dB. Without the LPF, the limiter would waste
  221. // gain reduction on HF peaks that get filtered later, causing
  222. // wild modulation swings (30-163%). With LPF first, the limiter
  223. // sees the final audio bandwidth and sets gain correctly.
  224. l := g.lpfL.Process(float64(in.L))
  225. r := g.lpfR.Process(float64(in.R))
  226. // --- Stage 2: Scale and limit ---
  227. l *= lp.OutputDrive
  228. r *= lp.OutputDrive
  229. if lp.LimiterEnabled && g.limiter != nil {
  230. l, r = g.limiter.Process(l, r)
  231. }
  232. // --- Stage 3: Stereo encode the limited, filtered audio ---
  233. limited := audio.NewFrame(audio.Sample(l), audio.Sample(r))
  234. comps := g.stereoEncoder.Encode(limited)
  235. // --- Stage 3: Combine at fixed levels ---
  236. composite := float64(comps.Mono)
  237. if lp.StereoEnabled {
  238. composite += float64(comps.Stereo)
  239. composite += pilotAmp * comps.Pilot
  240. }
  241. if g.rdsEnc != nil && lp.RDSEnabled {
  242. rdsCarrier := g.stereoEncoder.RDSCarrier()
  243. rdsValue := g.rdsEnc.NextSampleWithCarrier(rdsCarrier)
  244. composite += rdsAmp * rdsValue
  245. }
  246. // Final composite safety clip — only fires on brief limiter
  247. // overshoots during fast transients. Clips the entire composite,
  248. // not individual audio bands, so harmonics don't target RDS.
  249. if lp.LimiterEnabled {
  250. composite = dsp.HardClip(composite, ceiling)
  251. }
  252. if g.fmMod != nil {
  253. iq_i, iq_q := g.fmMod.Modulate(composite)
  254. frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)}
  255. } else {
  256. frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
  257. }
  258. }
  259. return frame
  260. }
  261. func (g *Generator) WriteFile(path string, duration time.Duration) error {
  262. if path == "" {
  263. path = g.cfg.Backend.OutputPath
  264. }
  265. if path == "" {
  266. path = filepath.Join("build", "offline", "composite.iqf32")
  267. }
  268. backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
  269. Name: "offline-file",
  270. Description: "offline composite file backend",
  271. })
  272. if err != nil {
  273. return err
  274. }
  275. defer backend.Close(context.Background())
  276. if err := backend.Configure(context.Background(), output.BackendConfig{
  277. SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
  278. Channels: 2,
  279. IQLevel: float32(g.cfg.FM.OutputDrive),
  280. }); err != nil {
  281. return err
  282. }
  283. frame := g.GenerateFrame(duration)
  284. if _, err := backend.Write(context.Background(), frame); err != nil {
  285. return err
  286. }
  287. return backend.Flush(context.Background())
  288. }
  289. func (g *Generator) Summary(duration time.Duration) string {
  290. sampleRate := float64(g.cfg.FM.CompositeRateHz)
  291. if sampleRate <= 0 {
  292. sampleRate = 228000
  293. }
  294. _, info := g.sourceFor(sampleRate)
  295. preemph := "off"
  296. if g.cfg.FM.PreEmphasisTauUS > 0 {
  297. preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisTauUS)
  298. }
  299. modMode := "composite"
  300. if g.cfg.FM.FMModulationEnabled {
  301. modMode = fmt.Sprintf("FM-IQ(±%.0fHz)", g.cfg.FM.MaxDeviationHz)
  302. }
  303. 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",
  304. g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(),
  305. g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled,
  306. preemph, g.cfg.FM.LimiterEnabled, modMode, info.Kind, info.Detail)
  307. }