Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

464 Zeilen
16KB

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