Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.

390 satır
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. MpxGain float64 // hardware calibration factor for composite output
  31. }
  32. // PreEmphasizedSource wraps an audio source and applies pre-emphasis.
  33. // The source is expected to already output at composite rate (resampled
  34. // upstream). Pre-emphasis is applied per-sample at that rate.
  35. type PreEmphasizedSource struct {
  36. src frameSource
  37. preL *dsp.PreEmphasis
  38. preR *dsp.PreEmphasis
  39. gain float64
  40. }
  41. func NewPreEmphasizedSource(src frameSource, tauUS, sampleRate, gain float64) *PreEmphasizedSource {
  42. p := &PreEmphasizedSource{src: src, gain: gain}
  43. if tauUS > 0 {
  44. p.preL = dsp.NewPreEmphasis(tauUS, sampleRate)
  45. p.preR = dsp.NewPreEmphasis(tauUS, sampleRate)
  46. }
  47. return p
  48. }
  49. func (p *PreEmphasizedSource) NextFrame() audio.Frame {
  50. f := p.src.NextFrame()
  51. l := float64(f.L) * p.gain
  52. r := float64(f.R) * p.gain
  53. if p.preL != nil {
  54. l = p.preL.Process(l)
  55. r = p.preR.Process(r)
  56. }
  57. return audio.NewFrame(audio.Sample(l), audio.Sample(r))
  58. }
  59. type SourceInfo struct {
  60. Kind string
  61. SampleRate float64
  62. Detail string
  63. }
  64. type Generator struct {
  65. cfg cfgpkg.Config
  66. // Persistent DSP state across GenerateFrame calls
  67. source *PreEmphasizedSource
  68. stereoEncoder stereo.StereoEncoder
  69. rdsEnc *rds.Encoder
  70. combiner mpx.DefaultCombiner
  71. fmMod *dsp.FMModulator
  72. sampleRate float64
  73. initialized bool
  74. frameSeq uint64
  75. // Broadcast-standard clip-filter-clip chain (per channel L/R):
  76. //
  77. // PreEmph → LPF₁(14kHz) → Notch(19kHz) → ×Drive
  78. // → StereoLimiter (slow AGC: raises average level)
  79. // → Clip₁ → LPF₂(14kHz) [cleanup] → Clip₂ [catches LPF overshoots]
  80. // → Stereo Encode → Composite Clip → Notch₁₉ → Notch₅₇
  81. // → + Pilot → + RDS → FM
  82. //
  83. audioLPF_L *dsp.FilterChain // 14kHz 8th-order (pre-clip)
  84. audioLPF_R *dsp.FilterChain
  85. pilotNotchL *dsp.FilterChain // 19kHz double-notch (guard band)
  86. pilotNotchR *dsp.FilterChain
  87. limiter *dsp.StereoLimiter // slow compressor (raises average, clips catch peaks)
  88. cleanupLPF_L *dsp.FilterChain // 14kHz 8th-order (post-clip cleanup)
  89. cleanupLPF_R *dsp.FilterChain
  90. mpxNotch19 *dsp.FilterChain // composite clipper protection
  91. mpxNotch57 *dsp.FilterChain
  92. // Pre-allocated frame buffer — reused every GenerateFrame call.
  93. frameBuf *output.CompositeFrame
  94. bufCap int
  95. // Live-updatable DSP parameters — written by control API, read per chunk.
  96. liveParams atomic.Pointer[LiveParams]
  97. // Optional external audio source (e.g. StreamResampler for live audio).
  98. // When set, takes priority over WAV/tones in sourceFor().
  99. externalSource frameSource
  100. }
  101. func NewGenerator(cfg cfgpkg.Config) *Generator {
  102. return &Generator{cfg: cfg}
  103. }
  104. // SetExternalSource sets a live audio source (e.g. StreamResampler) that
  105. // takes priority over WAV/tone sources. Must be called before the first
  106. // GenerateFrame() call (i.e. before init).
  107. func (g *Generator) SetExternalSource(src frameSource) {
  108. g.externalSource = src
  109. }
  110. // UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API,
  111. // applied at the next chunk boundary by the DSP goroutine.
  112. func (g *Generator) UpdateLive(p LiveParams) {
  113. g.liveParams.Store(&p)
  114. }
  115. // CurrentLiveParams returns the current live parameter snapshot.
  116. // Used by Engine.UpdateConfig to do read-modify-write on the params.
  117. func (g *Generator) CurrentLiveParams() LiveParams {
  118. if lp := g.liveParams.Load(); lp != nil {
  119. return *lp
  120. }
  121. return LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0}
  122. }
  123. // RDSEncoder returns the live RDS encoder, or nil if RDS is disabled.
  124. // Used by the Engine to forward text updates.
  125. func (g *Generator) RDSEncoder() *rds.Encoder {
  126. return g.rdsEnc
  127. }
  128. func (g *Generator) init() {
  129. if g.initialized {
  130. return
  131. }
  132. g.sampleRate = float64(g.cfg.FM.CompositeRateHz)
  133. if g.sampleRate <= 0 {
  134. g.sampleRate = 228000
  135. }
  136. rawSource, _ := g.sourceFor(g.sampleRate)
  137. g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain)
  138. g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate)
  139. g.combiner = mpx.DefaultCombiner{
  140. MonoGain: 1.0, StereoGain: 1.0,
  141. PilotGain: g.cfg.FM.PilotLevel, RDSGain: g.cfg.FM.RDSInjection,
  142. }
  143. if g.cfg.RDS.Enabled {
  144. piCode, _ := cfgpkg.ParsePI(g.cfg.RDS.PI)
  145. g.rdsEnc, _ = rds.NewEncoder(rds.RDSConfig{
  146. PI: piCode, PS: g.cfg.RDS.PS, RT: g.cfg.RDS.RadioText,
  147. PTY: uint8(g.cfg.RDS.PTY), SampleRate: g.sampleRate,
  148. })
  149. }
  150. ceiling := g.cfg.FM.LimiterCeiling
  151. if ceiling <= 0 { ceiling = 1.0 }
  152. // Broadcast clip-filter-clip chain:
  153. // Pre-clip: 14kHz LPF (8th-order) + 19kHz double-notch (per channel)
  154. g.audioLPF_L = dsp.NewAudioLPF(g.sampleRate)
  155. g.audioLPF_R = dsp.NewAudioLPF(g.sampleRate)
  156. g.pilotNotchL = dsp.NewPilotNotch(g.sampleRate)
  157. g.pilotNotchR = dsp.NewPilotNotch(g.sampleRate)
  158. // Slow compressor: 5ms attack / 200ms release. Brings average level UP.
  159. // The clips after it catch the peaks the limiter's attack time misses.
  160. // This is the "slow-to-fast progression" from broadcast processing:
  161. // slow limiter → fast clips.
  162. g.limiter = dsp.NewStereoLimiter(ceiling, 5, 200, g.sampleRate)
  163. // Post-clip cleanup: second 14kHz LPF pass (removes clip harmonics)
  164. g.cleanupLPF_L = dsp.NewAudioLPF(g.sampleRate)
  165. g.cleanupLPF_R = dsp.NewAudioLPF(g.sampleRate)
  166. // Composite clipper protection: double-notch at 19kHz + 57kHz
  167. g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate)
  168. if g.cfg.FM.FMModulationEnabled {
  169. g.fmMod = dsp.NewFMModulator(g.sampleRate)
  170. maxDev := g.cfg.FM.MaxDeviationHz
  171. if maxDev > 0 {
  172. if g.cfg.FM.MpxGain > 0 && g.cfg.FM.MpxGain != 1.0 {
  173. maxDev *= g.cfg.FM.MpxGain
  174. }
  175. g.fmMod.MaxDeviation = maxDev
  176. }
  177. }
  178. // Seed initial live params from config
  179. g.liveParams.Store(&LiveParams{
  180. OutputDrive: g.cfg.FM.OutputDrive,
  181. StereoEnabled: g.cfg.FM.StereoEnabled,
  182. PilotLevel: g.cfg.FM.PilotLevel,
  183. RDSInjection: g.cfg.FM.RDSInjection,
  184. RDSEnabled: g.cfg.RDS.Enabled,
  185. LimiterEnabled: g.cfg.FM.LimiterEnabled,
  186. LimiterCeiling: ceiling,
  187. MpxGain: g.cfg.FM.MpxGain,
  188. })
  189. g.initialized = true
  190. }
  191. func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) {
  192. if g.externalSource != nil {
  193. return g.externalSource, SourceInfo{Kind: "stream", SampleRate: sampleRate, Detail: "live audio"}
  194. }
  195. if g.cfg.Audio.InputPath != "" {
  196. if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil {
  197. return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath}
  198. }
  199. 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}
  200. }
  201. return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"}
  202. }
  203. func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
  204. g.init()
  205. samples := int(duration.Seconds() * g.sampleRate)
  206. if samples <= 0 { samples = int(g.sampleRate / 10) }
  207. // Reuse buffer — grow only if needed, never shrink
  208. if g.frameBuf == nil || g.bufCap < samples {
  209. g.frameBuf = &output.CompositeFrame{
  210. Samples: make([]output.IQSample, samples),
  211. }
  212. g.bufCap = samples
  213. }
  214. frame := g.frameBuf
  215. frame.Samples = frame.Samples[:samples]
  216. frame.SampleRateHz = g.sampleRate
  217. frame.Timestamp = time.Now().UTC()
  218. g.frameSeq++
  219. frame.Sequence = g.frameSeq
  220. // Load live params once per chunk — single atomic read, zero per-sample cost
  221. lp := g.liveParams.Load()
  222. if lp == nil {
  223. // Fallback: should never happen after init(), but be safe
  224. lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0}
  225. }
  226. // Broadcast clip-filter-clip FM MPX signal chain:
  227. //
  228. // Audio L/R → PreEmphasis
  229. // → LPF₁ (14kHz, 8th-order) → 19kHz Notch (double)
  230. // → × OutputDrive → HardClip₁ (ceiling)
  231. // → LPF₂ (14kHz, 8th-order) [removes clip₁ harmonics]
  232. // → HardClip₂ (ceiling) [catches LPF₂ overshoots]
  233. // → Stereo Encode
  234. // Audio MPX (mono + stereo sub)
  235. // → HardClip₃ (ceiling) [composite deviation control]
  236. // → 19kHz Notch (double) [protect pilot band]
  237. // → 57kHz Notch (double) [protect RDS band]
  238. // + Pilot 19kHz (fixed, NEVER clipped)
  239. // + RDS 57kHz (fixed, NEVER clipped)
  240. // → FM Modulator
  241. //
  242. // Guard band depth at 19kHz: LPF₁(-21dB) + Notch(-60dB) + LPF₂(-21dB)
  243. // + CompNotch(-60dB) → broadband floor -42dB, exact 19kHz >-90dB
  244. ceiling := lp.LimiterCeiling
  245. if ceiling <= 0 { ceiling = 1.0 }
  246. // Pilot and RDS are FIXED injection levels, independent of OutputDrive.
  247. // Config values directly represent percentage of ±75kHz deviation:
  248. // pilotLevel: 0.09 = 9% = ±6.75kHz (ITU standard)
  249. // rdsInjection: 0.04 = 4% = ±3.0kHz (typical)
  250. pilotAmp := lp.PilotLevel
  251. rdsAmp := lp.RDSInjection
  252. for i := 0; i < samples; i++ {
  253. in := g.source.NextFrame()
  254. // --- Stage 1: Band-limit pre-emphasized audio ---
  255. l := g.audioLPF_L.Process(float64(in.L))
  256. l = g.pilotNotchL.Process(l)
  257. r := g.audioLPF_R.Process(float64(in.R))
  258. r = g.pilotNotchR.Process(r)
  259. // --- Stage 2: Drive + Compress + Clip₁ ---
  260. l *= lp.OutputDrive
  261. r *= lp.OutputDrive
  262. if g.limiter != nil {
  263. l, r = g.limiter.Process(l, r)
  264. }
  265. l = dsp.HardClip(l, ceiling)
  266. r = dsp.HardClip(r, ceiling)
  267. // --- Stage 3: Cleanup LPF + Clip₂ (overshoot compensator) ---
  268. l = g.cleanupLPF_L.Process(l)
  269. r = g.cleanupLPF_R.Process(r)
  270. l = dsp.HardClip(l, ceiling)
  271. r = dsp.HardClip(r, ceiling)
  272. // --- Stage 4: Stereo encode ---
  273. limited := audio.NewFrame(audio.Sample(l), audio.Sample(r))
  274. comps := g.stereoEncoder.Encode(limited)
  275. // --- Stage 5: Composite clip + protection ---
  276. audioMPX := float64(comps.Mono)
  277. if lp.StereoEnabled {
  278. audioMPX += float64(comps.Stereo)
  279. }
  280. audioMPX = dsp.HardClip(audioMPX, ceiling)
  281. audioMPX = g.mpxNotch19.Process(audioMPX)
  282. audioMPX = g.mpxNotch57.Process(audioMPX)
  283. // --- Stage 6: Add protected components ---
  284. composite := audioMPX
  285. if lp.StereoEnabled {
  286. composite += pilotAmp * comps.Pilot
  287. }
  288. if g.rdsEnc != nil && lp.RDSEnabled {
  289. rdsCarrier := g.stereoEncoder.RDSCarrier()
  290. rdsValue := g.rdsEnc.NextSampleWithCarrier(rdsCarrier)
  291. composite += rdsAmp * rdsValue
  292. }
  293. if g.fmMod != nil {
  294. iq_i, iq_q := g.fmMod.Modulate(composite)
  295. frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)}
  296. } else {
  297. frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
  298. }
  299. }
  300. return frame
  301. }
  302. func (g *Generator) WriteFile(path string, duration time.Duration) error {
  303. if path == "" {
  304. path = g.cfg.Backend.OutputPath
  305. }
  306. if path == "" {
  307. path = filepath.Join("build", "offline", "composite.iqf32")
  308. }
  309. backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
  310. Name: "offline-file",
  311. Description: "offline composite file backend",
  312. })
  313. if err != nil {
  314. return err
  315. }
  316. defer backend.Close(context.Background())
  317. if err := backend.Configure(context.Background(), output.BackendConfig{
  318. SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
  319. Channels: 2,
  320. IQLevel: float32(g.cfg.FM.OutputDrive),
  321. }); err != nil {
  322. return err
  323. }
  324. frame := g.GenerateFrame(duration)
  325. if _, err := backend.Write(context.Background(), frame); err != nil {
  326. return err
  327. }
  328. return backend.Flush(context.Background())
  329. }
  330. func (g *Generator) Summary(duration time.Duration) string {
  331. sampleRate := float64(g.cfg.FM.CompositeRateHz)
  332. if sampleRate <= 0 {
  333. sampleRate = 228000
  334. }
  335. _, info := g.sourceFor(sampleRate)
  336. preemph := "off"
  337. if g.cfg.FM.PreEmphasisTauUS > 0 {
  338. preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisTauUS)
  339. }
  340. modMode := "composite"
  341. if g.cfg.FM.FMModulationEnabled {
  342. modMode = fmt.Sprintf("FM-IQ(±%.0fHz)", g.cfg.FM.MaxDeviationHz)
  343. }
  344. 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",
  345. g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(),
  346. g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled,
  347. preemph, g.cfg.FM.LimiterEnabled, modMode, info.Kind, info.Detail)
  348. }