Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

376 líneas
13KB

  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. fmMod *dsp.FMModulator
  72. sampleRate float64
  73. initialized bool
  74. frameSeq uint64
  75. // Broadcast-standard audio filter chain (per channel, L and R):
  76. // Pre-emphasis → 15kHz LPF (4th-order) → 19kHz Notch → Drive → Limiter
  77. audioLPF_L *dsp.FilterChain // 4th-order Butterworth 15kHz
  78. audioLPF_R *dsp.FilterChain
  79. pilotNotchL *dsp.Biquad // 19kHz notch (guard band protection)
  80. pilotNotchR *dsp.Biquad
  81. // Composite clipper protection (post-clip notch filters):
  82. // Audio composite → clip → notch 19kHz → notch 57kHz → + pilot → + RDS
  83. mpxNotch19 *dsp.Biquad // removes clip harmonics at pilot freq
  84. mpxNotch57 *dsp.Biquad // removes clip harmonics at RDS freq
  85. // Pre-allocated frame buffer — reused every GenerateFrame call.
  86. frameBuf *output.CompositeFrame
  87. bufCap int
  88. // Live-updatable DSP parameters — written by control API, read per chunk.
  89. liveParams atomic.Pointer[LiveParams]
  90. // Optional external audio source (e.g. StreamResampler for live audio).
  91. // When set, takes priority over WAV/tones in sourceFor().
  92. externalSource frameSource
  93. }
  94. func NewGenerator(cfg cfgpkg.Config) *Generator {
  95. return &Generator{cfg: cfg}
  96. }
  97. // SetExternalSource sets a live audio source (e.g. StreamResampler) that
  98. // takes priority over WAV/tone sources. Must be called before the first
  99. // GenerateFrame() call (i.e. before init).
  100. func (g *Generator) SetExternalSource(src frameSource) {
  101. g.externalSource = src
  102. }
  103. // UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API,
  104. // applied at the next chunk boundary by the DSP goroutine.
  105. func (g *Generator) UpdateLive(p LiveParams) {
  106. g.liveParams.Store(&p)
  107. }
  108. // CurrentLiveParams returns the current live parameter snapshot.
  109. // Used by Engine.UpdateConfig to do read-modify-write on the params.
  110. func (g *Generator) CurrentLiveParams() LiveParams {
  111. if lp := g.liveParams.Load(); lp != nil {
  112. return *lp
  113. }
  114. return LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0}
  115. }
  116. // RDSEncoder returns the live RDS encoder, or nil if RDS is disabled.
  117. // Used by the Engine to forward text updates.
  118. func (g *Generator) RDSEncoder() *rds.Encoder {
  119. return g.rdsEnc
  120. }
  121. func (g *Generator) init() {
  122. if g.initialized {
  123. return
  124. }
  125. g.sampleRate = float64(g.cfg.FM.CompositeRateHz)
  126. if g.sampleRate <= 0 {
  127. g.sampleRate = 228000
  128. }
  129. rawSource, _ := g.sourceFor(g.sampleRate)
  130. g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain)
  131. g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate)
  132. g.combiner = mpx.DefaultCombiner{
  133. MonoGain: 1.0, StereoGain: 1.0,
  134. PilotGain: g.cfg.FM.PilotLevel, RDSGain: g.cfg.FM.RDSInjection,
  135. }
  136. if g.cfg.RDS.Enabled {
  137. piCode, _ := cfgpkg.ParsePI(g.cfg.RDS.PI)
  138. g.rdsEnc, _ = rds.NewEncoder(rds.RDSConfig{
  139. PI: piCode, PS: g.cfg.RDS.PS, RT: g.cfg.RDS.RadioText,
  140. PTY: uint8(g.cfg.RDS.PTY), SampleRate: g.sampleRate,
  141. })
  142. }
  143. ceiling := g.cfg.FM.LimiterCeiling
  144. if ceiling <= 0 { ceiling = 1.0 }
  145. // Audio ceiling leaves headroom for pilot + RDS so total ≤ ceiling
  146. pilotAmp := g.cfg.FM.PilotLevel * g.cfg.FM.OutputDrive
  147. rdsAmp := g.cfg.FM.RDSInjection * g.cfg.FM.OutputDrive
  148. audioCeiling := ceiling - pilotAmp - rdsAmp
  149. if audioCeiling < 0.3 { audioCeiling = 0.3 }
  150. if g.cfg.FM.LimiterEnabled {
  151. g.limiter = dsp.NewStereoLimiter(audioCeiling, 0.5, 100, g.sampleRate)
  152. }
  153. // Broadcast-standard filter chain:
  154. // 1) 15kHz 4th-order Butterworth LPF — steep guard band, -14dB@19kHz, -54dB@57kHz
  155. g.audioLPF_L = dsp.NewAudioLPF(g.sampleRate)
  156. g.audioLPF_R = dsp.NewAudioLPF(g.sampleRate)
  157. // 2) 19kHz notch — kills residual audio at pilot freq, >40dB rejection
  158. g.pilotNotchL = dsp.NewPilotNotch(g.sampleRate)
  159. g.pilotNotchR = dsp.NewPilotNotch(g.sampleRate)
  160. // 3) Composite clipper protection notches at 19kHz + 57kHz
  161. g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate)
  162. if g.cfg.FM.FMModulationEnabled {
  163. g.fmMod = dsp.NewFMModulator(g.sampleRate)
  164. if g.cfg.FM.MaxDeviationHz > 0 { g.fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz }
  165. }
  166. // Seed initial live params from config
  167. g.liveParams.Store(&LiveParams{
  168. OutputDrive: g.cfg.FM.OutputDrive,
  169. StereoEnabled: g.cfg.FM.StereoEnabled,
  170. PilotLevel: g.cfg.FM.PilotLevel,
  171. RDSInjection: g.cfg.FM.RDSInjection,
  172. RDSEnabled: g.cfg.RDS.Enabled,
  173. LimiterEnabled: g.cfg.FM.LimiterEnabled,
  174. LimiterCeiling: ceiling,
  175. })
  176. g.initialized = true
  177. }
  178. func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) {
  179. if g.externalSource != nil {
  180. return g.externalSource, SourceInfo{Kind: "stream", SampleRate: sampleRate, Detail: "live audio"}
  181. }
  182. if g.cfg.Audio.InputPath != "" {
  183. if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil {
  184. return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath}
  185. }
  186. 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}
  187. }
  188. return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"}
  189. }
  190. func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
  191. g.init()
  192. samples := int(duration.Seconds() * g.sampleRate)
  193. if samples <= 0 { samples = int(g.sampleRate / 10) }
  194. // Reuse buffer — grow only if needed, never shrink
  195. if g.frameBuf == nil || g.bufCap < samples {
  196. g.frameBuf = &output.CompositeFrame{
  197. Samples: make([]output.IQSample, samples),
  198. }
  199. g.bufCap = samples
  200. }
  201. frame := g.frameBuf
  202. frame.Samples = frame.Samples[:samples]
  203. frame.SampleRateHz = g.sampleRate
  204. frame.Timestamp = time.Now().UTC()
  205. g.frameSeq++
  206. frame.Sequence = g.frameSeq
  207. // Load live params once per chunk — single atomic read, zero per-sample cost
  208. lp := g.liveParams.Load()
  209. if lp == nil {
  210. // Fallback: should never happen after init(), but be safe
  211. lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0}
  212. }
  213. // Broadcast-standard FM MPX signal chain:
  214. //
  215. // Audio L/R
  216. // → PreEmphasis (50µs EU / 75µs US)
  217. // → 15kHz LPF (4th-order Butterworth, -14dB@19kHz, -54dB@57kHz)
  218. // → 19kHz Notch (>40dB rejection, guard band protection)
  219. // → × OutputDrive
  220. // → StereoLimiter (instant attack, smooth release)
  221. // → Stereo Encode → Mono (L+R)/2 + Stereo Sub (L-R)/2 × 38kHz
  222. // Audio MPX composite
  223. // → HardClip at audioCeiling (catches limiter overshoots)
  224. // → 19kHz Notch (removes clip harmonics at pilot freq)
  225. // → 57kHz Notch (removes clip harmonics at RDS freq)
  226. // + Pilot 19kHz (fixed amplitude, post-clip)
  227. // + RDS 57kHz (fixed amplitude, post-clip)
  228. // → FM Modulator
  229. //
  230. // Key: Pilot and RDS are NEVER clipped or filtered. They're added
  231. // after all audio processing at constant amplitude.
  232. ceiling := lp.LimiterCeiling
  233. if ceiling <= 0 { ceiling = 1.0 }
  234. pilotAmp := lp.PilotLevel * lp.OutputDrive
  235. rdsAmp := lp.RDSInjection * lp.OutputDrive
  236. audioCeiling := ceiling - pilotAmp - rdsAmp
  237. if audioCeiling < 0.3 { audioCeiling = 0.3 }
  238. for i := 0; i < samples; i++ {
  239. in := g.source.NextFrame()
  240. // --- Stage 1: Audio filtering (per-channel) ---
  241. // 15kHz LPF removes out-of-band pre-emphasis energy.
  242. // 19kHz notch kills residual energy at pilot frequency.
  243. // Both run BEFORE drive+limiter so the limiter sees the
  244. // actual audio bandwidth, not wasted HF energy.
  245. l := g.audioLPF_L.Process(float64(in.L))
  246. l = g.pilotNotchL.Process(l)
  247. r := g.audioLPF_R.Process(float64(in.R))
  248. r = g.pilotNotchR.Process(r)
  249. // --- Stage 2: Drive + Limit ---
  250. l *= lp.OutputDrive
  251. r *= lp.OutputDrive
  252. if lp.LimiterEnabled && g.limiter != nil {
  253. l, r = g.limiter.Process(l, r)
  254. }
  255. // --- Stage 3: Stereo encode ---
  256. limited := audio.NewFrame(audio.Sample(l), audio.Sample(r))
  257. comps := g.stereoEncoder.Encode(limited)
  258. // --- Stage 4: Audio composite clip + protection ---
  259. // Clip the audio-only composite (mono + stereo sub) to budget.
  260. // Then notch-filter the clip harmonics out of the pilot (19kHz)
  261. // and RDS (57kHz) bands before adding the real pilot and RDS.
  262. audioMPX := float64(comps.Mono)
  263. if lp.StereoEnabled {
  264. audioMPX += float64(comps.Stereo)
  265. }
  266. audioMPX = dsp.HardClip(audioMPX, audioCeiling)
  267. audioMPX = g.mpxNotch19.Process(audioMPX)
  268. audioMPX = g.mpxNotch57.Process(audioMPX)
  269. // --- Stage 5: Add protected components at fixed levels ---
  270. composite := audioMPX
  271. if lp.StereoEnabled {
  272. composite += pilotAmp * comps.Pilot
  273. }
  274. if g.rdsEnc != nil && lp.RDSEnabled {
  275. rdsCarrier := g.stereoEncoder.RDSCarrier()
  276. rdsValue := g.rdsEnc.NextSampleWithCarrier(rdsCarrier)
  277. composite += rdsAmp * rdsValue
  278. }
  279. if g.fmMod != nil {
  280. iq_i, iq_q := g.fmMod.Modulate(composite)
  281. frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)}
  282. } else {
  283. frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
  284. }
  285. }
  286. return frame
  287. }
  288. func (g *Generator) WriteFile(path string, duration time.Duration) error {
  289. if path == "" {
  290. path = g.cfg.Backend.OutputPath
  291. }
  292. if path == "" {
  293. path = filepath.Join("build", "offline", "composite.iqf32")
  294. }
  295. backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
  296. Name: "offline-file",
  297. Description: "offline composite file backend",
  298. })
  299. if err != nil {
  300. return err
  301. }
  302. defer backend.Close(context.Background())
  303. if err := backend.Configure(context.Background(), output.BackendConfig{
  304. SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
  305. Channels: 2,
  306. IQLevel: float32(g.cfg.FM.OutputDrive),
  307. }); err != nil {
  308. return err
  309. }
  310. frame := g.GenerateFrame(duration)
  311. if _, err := backend.Write(context.Background(), frame); err != nil {
  312. return err
  313. }
  314. return backend.Flush(context.Background())
  315. }
  316. func (g *Generator) Summary(duration time.Duration) string {
  317. sampleRate := float64(g.cfg.FM.CompositeRateHz)
  318. if sampleRate <= 0 {
  319. sampleRate = 228000
  320. }
  321. _, info := g.sourceFor(sampleRate)
  322. preemph := "off"
  323. if g.cfg.FM.PreEmphasisTauUS > 0 {
  324. preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisTauUS)
  325. }
  326. modMode := "composite"
  327. if g.cfg.FM.FMModulationEnabled {
  328. modMode = fmt.Sprintf("FM-IQ(±%.0fHz)", g.cfg.FM.MaxDeviationHz)
  329. }
  330. 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",
  331. g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(),
  332. g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled,
  333. preemph, g.cfg.FM.LimiterEnabled, modMode, info.Kind, info.Detail)
  334. }