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.

1013 líneas
33KB

  1. package offline
  2. import (
  3. "context"
  4. "encoding/binary"
  5. "fmt"
  6. "log"
  7. "math"
  8. "path/filepath"
  9. "sync/atomic"
  10. "time"
  11. "github.com/jan/fm-rds-tx/internal/audio"
  12. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  13. "github.com/jan/fm-rds-tx/internal/dsp"
  14. "github.com/jan/fm-rds-tx/internal/license"
  15. "github.com/jan/fm-rds-tx/internal/mpx"
  16. "github.com/jan/fm-rds-tx/internal/output"
  17. "github.com/jan/fm-rds-tx/internal/rds"
  18. "github.com/jan/fm-rds-tx/internal/stereo"
  19. "github.com/jan/fm-rds-tx/internal/watermark"
  20. )
  21. type frameSource interface {
  22. NextFrame() audio.Frame
  23. }
  24. // LiveParams carries DSP parameters that can be hot-swapped at runtime.
  25. // Loaded once per chunk via atomic pointer — zero per-sample overhead.
  26. type LiveParams struct {
  27. OutputDrive float64
  28. StereoEnabled bool
  29. StereoMode string
  30. PilotLevel float64
  31. RDSInjection float64
  32. RDSEnabled bool
  33. LimiterEnabled bool
  34. LimiterCeiling float64
  35. MpxGain float64 // hardware calibration factor for composite output
  36. // Tone + gain: live-patchable without DSP chain reinit.
  37. ToneLeftHz float64
  38. ToneRightHz float64
  39. ToneAmplitude float64
  40. AudioGain float64
  41. // Composite clipper: live-toggleable without DSP chain reinit.
  42. CompositeClipperEnabled bool
  43. }
  44. // PreEmphasizedSource wraps an audio source and applies pre-emphasis.
  45. // The source is expected to already output at composite rate (resampled
  46. // upstream). Pre-emphasis is applied per-sample at that rate.
  47. type PreEmphasizedSource struct {
  48. src frameSource
  49. preL *dsp.PreEmphasis
  50. preR *dsp.PreEmphasis
  51. gain float64
  52. }
  53. func NewPreEmphasizedSource(src frameSource, tauUS, sampleRate, gain float64) *PreEmphasizedSource {
  54. p := &PreEmphasizedSource{src: src, gain: gain}
  55. if tauUS > 0 {
  56. p.preL = dsp.NewPreEmphasis(tauUS, sampleRate)
  57. p.preR = dsp.NewPreEmphasis(tauUS, sampleRate)
  58. }
  59. return p
  60. }
  61. func (p *PreEmphasizedSource) NextFrame() audio.Frame {
  62. f := p.src.NextFrame()
  63. l := float64(f.L) * p.gain
  64. r := float64(f.R) * p.gain
  65. if p.preL != nil {
  66. l = p.preL.Process(l)
  67. r = p.preR.Process(r)
  68. }
  69. return audio.NewFrame(audio.Sample(l), audio.Sample(r))
  70. }
  71. type SourceInfo struct {
  72. Kind string
  73. SampleRate float64
  74. Detail string
  75. }
  76. type MeasurementFlags struct {
  77. StereoEnabled bool `json:"stereoEnabled"`
  78. StereoMode string `json:"stereoMode"`
  79. RDSEnabled bool `json:"rdsEnabled"`
  80. RDS2Enabled bool `json:"rds2Enabled"`
  81. BS412Enabled bool `json:"bs412Enabled"`
  82. CompositeClipperEnabled bool `json:"compositeClipperEnabled"`
  83. WatermarkEnabled bool `json:"watermarkEnabled"`
  84. LicenseInjectionActive bool `json:"licenseInjectionActive"`
  85. }
  86. type LRPreEncodePostWatermarkMeasurement struct {
  87. LRms float64 `json:"lRms"`
  88. RRms float64 `json:"rRms"`
  89. LPeakAbs float64 `json:"lPeakAbs"`
  90. RPeakAbs float64 `json:"rPeakAbs"`
  91. LRBalanceDB float64 `json:"lrBalanceDb"`
  92. LClipEvents uint32 `json:"lClipEvents"`
  93. RClipEvents uint32 `json:"rClipEvents"`
  94. }
  95. type AudioMPXPreBS412Measurement struct {
  96. RMS float64 `json:"rms"`
  97. PeakAbs float64 `json:"peakAbs"`
  98. MonoRMS float64 `json:"monoRms"`
  99. StereoRMS float64 `json:"stereoRms"`
  100. CrestFactor float64 `json:"crestFactor"`
  101. ClipperLookaheadGain float64 `json:"clipperLookaheadGain"`
  102. ClipperEnvelope float64 `json:"clipperEnvelope"`
  103. ClipperOrProtectionActive bool `json:"clipperOrProtectionActive"`
  104. }
  105. type AudioMPXPostBS412Measurement struct {
  106. RMS float64 `json:"rms"`
  107. PeakAbs float64 `json:"peakAbs"`
  108. BS412GainApplied float64 `json:"bs412GainApplied"`
  109. BS412AttenuationDB float64 `json:"bs412AttenuationDb"`
  110. EstimatedAudioPower float64 `json:"estimatedAudioPower"`
  111. }
  112. type CompositeFinalPreIQMeasurement struct {
  113. RMS float64 `json:"rms"`
  114. PeakAbs float64 `json:"peakAbs"`
  115. PilotRMS float64 `json:"pilotRms"`
  116. PilotPeakAbs float64 `json:"pilotPeakAbs"`
  117. PilotInjectionEquivalentPercent float64 `json:"pilotInjectionEquivalentPercent"`
  118. RDSRMS float64 `json:"rdsRms"`
  119. RDSPeakAbs float64 `json:"rdsPeakAbs"`
  120. OverNominalEvents uint32 `json:"overNominalEvents"`
  121. OverHeadroomEvents uint32 `json:"overHeadroomEvents"`
  122. }
  123. type MeasurementSnapshot struct {
  124. Timestamp time.Time `json:"timestamp"`
  125. SampleRateHz float64 `json:"sampleRateHz"`
  126. ChunkSamples int `json:"chunkSamples"`
  127. ChunkDurationMs float64 `json:"chunkDurationMs"`
  128. Sequence uint64 `json:"sequence"`
  129. Stale bool `json:"stale"`
  130. NoData bool `json:"noData"`
  131. Flags MeasurementFlags `json:"flags"`
  132. LRPreEncodePostWatermark LRPreEncodePostWatermarkMeasurement `json:"lrPreEncodePostWatermark"`
  133. AudioMPXPreBS412 AudioMPXPreBS412Measurement `json:"audioMpxPreBs412"`
  134. AudioMPXPostBS412 AudioMPXPostBS412Measurement `json:"audioMpxPostBs412"`
  135. CompositeFinalPreIQ CompositeFinalPreIQMeasurement `json:"compositeFinalPreIq"`
  136. }
  137. type Generator struct {
  138. cfg cfgpkg.Config
  139. // Persistent DSP state across GenerateFrame calls
  140. source *PreEmphasizedSource
  141. stereoEncoder stereo.StereoEncoder
  142. appliedStereoMode string // canonical mode currently applied to stereoEncoder; DSP goroutine only
  143. rdsEnc *rds.Encoder
  144. rds2Enc *rds.RDS2Encoder
  145. combiner mpx.DefaultCombiner
  146. fmMod *dsp.FMModulator
  147. sampleRate float64
  148. initialized bool
  149. frameSeq uint64
  150. // Broadcast-standard clip-filter-clip chain (per channel L/R):
  151. //
  152. // PreEmph → LPF₁(14kHz) → Notch(19kHz) → ×Drive
  153. // → StereoLimiter (slow AGC: raises average level)
  154. // → Clip₁ → LPF₂(14kHz) [cleanup] → Clip₂ [catches LPF overshoots]
  155. // → Stereo Encode → Composite Clip → Notch₁₉ → Notch₅₇
  156. // → + Pilot → + RDS → FM
  157. //
  158. audioLPF_L *dsp.FilterChain // 14kHz 8th-order (pre-clip)
  159. audioLPF_R *dsp.FilterChain
  160. pilotNotchL *dsp.FilterChain // 19kHz double-notch (guard band)
  161. pilotNotchR *dsp.FilterChain
  162. limiter *dsp.StereoLimiter // slow compressor (raises average, clips catch peaks)
  163. cleanupLPF_L *dsp.FilterChain // 14kHz 8th-order (post-clip cleanup)
  164. cleanupLPF_R *dsp.FilterChain
  165. mpxNotch19 *dsp.FilterChain // composite clipper protection
  166. mpxNotch57 *dsp.FilterChain
  167. bs412 *dsp.BS412Limiter // ITU-R BS.412 MPX power limiter (optional)
  168. compositeClip *dsp.CompositeClipper // ITU-R SM.1268 iterative composite clipper (optional)
  169. // Pre-allocated frame buffer — reused every GenerateFrame call.
  170. frameBuf *output.CompositeFrame
  171. bufCap int
  172. // Live-updatable DSP parameters — written by control API, read per chunk.
  173. liveParams atomic.Pointer[LiveParams]
  174. // Optional external audio source (e.g. StreamResampler for live audio).
  175. // When set, takes priority over WAV/tones in sourceFor().
  176. externalSource frameSource
  177. // Tone source reference — non-nil when a ToneSource is the active audio input.
  178. // Allows live-updating tone parameters via LiveParams each chunk.
  179. toneSource *audio.ToneSource
  180. // License: jingle injection when unlicensed.
  181. licenseState *license.State
  182. jingleFrames []license.JingleFrame
  183. // Watermark: explicit opt-in STFT-domain spread-spectrum (Kirovski & Malvar 2003).
  184. watermarkEnabled bool
  185. watermarkKey string
  186. stftEmbedder *watermark.STFTEmbedder
  187. wmDecimLPF *dsp.FilterChain // anti-alias LPF for composite→12k decimation
  188. wmInterpLPF *dsp.FilterChain // image-rejection LPF for 12k→composite upsample
  189. latestMeasurement atomic.Pointer[MeasurementSnapshot]
  190. }
  191. func NewGenerator(cfg cfgpkg.Config) *Generator {
  192. return &Generator{cfg: cfg}
  193. }
  194. // SetLicense configures license state for evaluation-jingle behavior only.
  195. // It intentionally does not enable or instantiate the audio watermark path.
  196. func (g *Generator) SetLicense(state *license.State) {
  197. g.licenseState = state
  198. }
  199. // ConfigureWatermark explicitly enables or disables the optional STFT watermark.
  200. // Must be called before the first GenerateFrame.
  201. func (g *Generator) ConfigureWatermark(enabled bool, key string) {
  202. g.watermarkEnabled = enabled
  203. g.watermarkKey = key
  204. if !enabled {
  205. g.stftEmbedder = nil
  206. g.wmDecimLPF = nil
  207. g.wmInterpLPF = nil
  208. return
  209. }
  210. g.stftEmbedder = watermark.NewSTFTEmbedder(key)
  211. }
  212. // SetExternalSource sets a live audio source (e.g. StreamResampler) that
  213. // takes priority over WAV/tone sources. Must be called before the first
  214. // GenerateFrame() call; calling it after init() has no effect because
  215. // g.source is already wired to the old source.
  216. func (g *Generator) SetExternalSource(src frameSource) error {
  217. if g.initialized {
  218. return fmt.Errorf("generator: SetExternalSource called after GenerateFrame; call it before the engine starts")
  219. }
  220. g.externalSource = src
  221. return nil
  222. }
  223. // UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API.
  224. // The DSP goroutine applies mode changes at the next chunk boundary.
  225. func (g *Generator) UpdateLive(p LiveParams) {
  226. p.StereoMode = canonicalStereoMode(p.StereoMode)
  227. g.liveParams.Store(&p)
  228. }
  229. // CurrentLiveParams returns the current live parameter snapshot.
  230. // Used by Engine.UpdateConfig to do read-modify-write on the params.
  231. func (g *Generator) CurrentLiveParams() LiveParams {
  232. if lp := g.liveParams.Load(); lp != nil {
  233. return *lp
  234. }
  235. return LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0}
  236. }
  237. func canonicalStereoMode(mode string) string {
  238. return stereo.ParseMode(mode).String()
  239. }
  240. // RDSEncoder returns the live RDS encoder, or nil if RDS is disabled.
  241. // Used by the Engine to forward text updates.
  242. func (g *Generator) RDSEncoder() *rds.Encoder {
  243. return g.rdsEnc
  244. }
  245. func (g *Generator) LatestMeasurement() *MeasurementSnapshot {
  246. if m := g.latestMeasurement.Load(); m != nil {
  247. copy := *m
  248. return &copy
  249. }
  250. return nil
  251. }
  252. func (g *Generator) resetSource() {
  253. rawSource, _ := g.sourceFor(g.sampleRate)
  254. g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain)
  255. }
  256. func (g *Generator) resetRDS2Encoder() {
  257. if !g.cfg.RDS.Enabled || !g.cfg.RDS.RDS2Enabled {
  258. g.rds2Enc = nil
  259. return
  260. }
  261. g.rds2Enc = rds.NewRDS2Encoder(g.sampleRate)
  262. g.rds2Enc.Enable(true)
  263. if g.cfg.RDS.StationLogoPath != "" {
  264. if err := g.rds2Enc.LoadLogo(g.cfg.RDS.StationLogoPath); err != nil {
  265. log.Printf("rds2: failed to load station logo: %v", err)
  266. }
  267. }
  268. }
  269. // Reset clears stateful DSP/runtime state so the next run starts from a clean baseline
  270. // without changing the current live parameters or feature enablement.
  271. func (g *Generator) Reset() {
  272. if !g.initialized {
  273. return
  274. }
  275. g.resetSource()
  276. mode := g.appliedStereoMode
  277. if lp := g.liveParams.Load(); lp != nil {
  278. mode = canonicalStereoMode(lp.StereoMode)
  279. }
  280. g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate)
  281. g.stereoEncoder.SetMode(stereo.ParseMode(mode), g.sampleRate)
  282. g.appliedStereoMode = mode
  283. if g.rdsEnc != nil {
  284. g.rdsEnc.Reset()
  285. }
  286. g.resetRDS2Encoder()
  287. if g.audioLPF_L != nil {
  288. g.audioLPF_L.Reset()
  289. }
  290. if g.audioLPF_R != nil {
  291. g.audioLPF_R.Reset()
  292. }
  293. if g.pilotNotchL != nil {
  294. g.pilotNotchL.Reset()
  295. }
  296. if g.pilotNotchR != nil {
  297. g.pilotNotchR.Reset()
  298. }
  299. if g.limiter != nil {
  300. g.limiter.Reset()
  301. }
  302. if g.cleanupLPF_L != nil {
  303. g.cleanupLPF_L.Reset()
  304. }
  305. if g.cleanupLPF_R != nil {
  306. g.cleanupLPF_R.Reset()
  307. }
  308. if g.mpxNotch19 != nil {
  309. g.mpxNotch19.Reset()
  310. }
  311. if g.mpxNotch57 != nil {
  312. g.mpxNotch57.Reset()
  313. }
  314. if g.bs412 != nil {
  315. g.bs412.Reset()
  316. }
  317. if g.compositeClip != nil {
  318. g.compositeClip.Reset()
  319. }
  320. if g.fmMod != nil {
  321. g.fmMod.Reset()
  322. }
  323. if g.watermarkEnabled {
  324. g.stftEmbedder = watermark.NewSTFTEmbedder(g.watermarkKey)
  325. g.wmDecimLPF = dsp.NewLPF4(5500, g.sampleRate)
  326. g.wmInterpLPF = dsp.NewLPF4(5500, g.sampleRate)
  327. } else {
  328. g.stftEmbedder = nil
  329. g.wmDecimLPF = nil
  330. g.wmInterpLPF = nil
  331. }
  332. }
  333. func (g *Generator) init() {
  334. if g.initialized {
  335. return
  336. }
  337. g.sampleRate = float64(g.cfg.FM.CompositeRateHz)
  338. if g.sampleRate <= 0 {
  339. g.sampleRate = 228000
  340. }
  341. if g.watermarkEnabled {
  342. if g.stftEmbedder == nil {
  343. g.stftEmbedder = watermark.NewSTFTEmbedder(g.watermarkKey)
  344. }
  345. } else {
  346. g.stftEmbedder = nil
  347. g.wmDecimLPF = nil
  348. g.wmInterpLPF = nil
  349. }
  350. rawSource, _ := g.sourceFor(g.sampleRate)
  351. g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain)
  352. g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate)
  353. g.appliedStereoMode = canonicalStereoMode(g.cfg.FM.StereoMode)
  354. g.stereoEncoder.SetMode(stereo.ParseMode(g.appliedStereoMode), g.sampleRate)
  355. g.combiner = mpx.DefaultCombiner{
  356. MonoGain: 1.0, StereoGain: 1.0,
  357. PilotGain: g.cfg.FM.PilotLevel, RDSGain: g.cfg.FM.RDSInjection,
  358. }
  359. if g.cfg.RDS.Enabled {
  360. piCode, _ := cfgpkg.ParsePI(g.cfg.RDS.PI)
  361. // Build EON entries
  362. var eonEntries []rds.EONEntry
  363. for _, e := range g.cfg.RDS.EON {
  364. eonPI, _ := cfgpkg.ParsePI(e.PI)
  365. eonEntries = append(eonEntries, rds.EONEntry{
  366. PI: eonPI, PS: e.PS, PTY: uint8(e.PTY),
  367. TP: e.TP, TA: e.TA, AF: e.AF,
  368. })
  369. }
  370. sep := g.cfg.RDS.RTPlusSeparator
  371. if sep == "" {
  372. sep = " - "
  373. }
  374. g.rdsEnc, _ = rds.NewEncoder(rds.RDSConfig{
  375. PI: piCode, PS: g.cfg.RDS.PS, RT: g.cfg.RDS.RadioText,
  376. PTY: uint8(g.cfg.RDS.PTY), SampleRate: g.sampleRate,
  377. TP: g.cfg.RDS.TP, TA: g.cfg.RDS.TA,
  378. MS: g.cfg.RDS.MS, DI: g.cfg.RDS.DI,
  379. AF: g.cfg.RDS.AF,
  380. CTEnabled: g.cfg.RDS.CTEnabled,
  381. CTOffsetHalfHours: g.cfg.RDS.CTOffsetHalfHours,
  382. PTYN: g.cfg.RDS.PTYN,
  383. LPS: g.cfg.RDS.LPS,
  384. RTPlusEnabled: g.cfg.RDS.RTPlusEnabled,
  385. RTPlusSeparator: sep,
  386. ERTEnabled: g.cfg.RDS.ERTEnabled,
  387. ERT: g.cfg.RDS.ERT,
  388. ERTGroupType: 12, // default: Group 12A
  389. EON: eonEntries,
  390. })
  391. // RDS2: additional subcarriers (66.5, 71.25, 76 kHz)
  392. g.resetRDS2Encoder()
  393. }
  394. ceiling := g.cfg.FM.LimiterCeiling
  395. if ceiling <= 0 {
  396. ceiling = 1.0
  397. }
  398. // Broadcast clip-filter-clip chain:
  399. // Pre-clip: 14kHz LPF (8th-order) + 19kHz double-notch (per channel)
  400. g.audioLPF_L = dsp.NewAudioLPF(g.sampleRate)
  401. g.audioLPF_R = dsp.NewAudioLPF(g.sampleRate)
  402. g.pilotNotchL = dsp.NewPilotNotch(g.sampleRate)
  403. g.pilotNotchR = dsp.NewPilotNotch(g.sampleRate)
  404. // Slow compressor: 5ms attack / 200ms release. Brings average level UP.
  405. // The clips after it catch the peaks the limiter's attack time misses.
  406. // This is the "slow-to-fast progression" from broadcast processing:
  407. // slow limiter → fast clips.
  408. // Burst-masking-optimized limiter (Bonello, JAES 2007):
  409. // 2ms attack lets initial transient peaks clip for <5ms (burst-masked).
  410. // 150ms release avoids audible pumping on sustained passages.
  411. g.limiter = dsp.NewStereoLimiter(ceiling, 2, 150, g.sampleRate)
  412. // Post-clip cleanup: second 14kHz LPF pass (removes clip harmonics)
  413. g.cleanupLPF_L = dsp.NewAudioLPF(g.sampleRate)
  414. g.cleanupLPF_R = dsp.NewAudioLPF(g.sampleRate)
  415. // Composite clipper protection: double-notch at 19kHz + 57kHz
  416. g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate)
  417. // ITU-R SM.1268 iterative composite clipper (optional, replaces simple clip+notch)
  418. // Always created so it can be live-toggled via CompositeClipperEnabled.
  419. g.compositeClip = dsp.NewCompositeClipper(dsp.CompositeClipperConfig{
  420. Ceiling: ceiling,
  421. Iterations: g.cfg.FM.CompositeClipper.Iterations,
  422. SoftKnee: g.cfg.FM.CompositeClipper.SoftKnee,
  423. LookaheadMs: g.cfg.FM.CompositeClipper.LookaheadMs,
  424. SampleRate: g.sampleRate,
  425. })
  426. // BS.412 MPX power limiter (EU/CH requirement for licensed FM)
  427. if g.cfg.FM.BS412Enabled {
  428. // chunkSec is not known at init time (Engine.chunkDuration may differ).
  429. // Pass 0 here; GenerateFrame computes the actual chunk duration from
  430. // the real sample count and updates BS.412 accordingly.
  431. g.bs412 = dsp.NewBS412Limiter(
  432. g.cfg.FM.BS412ThresholdDBr,
  433. g.cfg.FM.PilotLevel,
  434. g.cfg.FM.RDSInjection,
  435. 0,
  436. )
  437. }
  438. if g.cfg.FM.FMModulationEnabled {
  439. g.fmMod = dsp.NewFMModulator(g.sampleRate)
  440. maxDev := g.cfg.FM.MaxDeviationHz
  441. if maxDev > 0 {
  442. if g.cfg.FM.MpxGain > 0 && g.cfg.FM.MpxGain != 1.0 {
  443. maxDev *= g.cfg.FM.MpxGain
  444. }
  445. g.fmMod.MaxDeviation = maxDev
  446. }
  447. }
  448. // Seed initial live params from config
  449. g.liveParams.Store(&LiveParams{
  450. OutputDrive: g.cfg.FM.OutputDrive,
  451. StereoEnabled: g.cfg.FM.StereoEnabled,
  452. StereoMode: g.appliedStereoMode,
  453. PilotLevel: g.cfg.FM.PilotLevel,
  454. RDSInjection: g.cfg.FM.RDSInjection,
  455. RDSEnabled: g.cfg.RDS.Enabled,
  456. LimiterEnabled: g.cfg.FM.LimiterEnabled,
  457. LimiterCeiling: ceiling,
  458. MpxGain: g.cfg.FM.MpxGain,
  459. ToneLeftHz: g.cfg.Audio.ToneLeftHz,
  460. ToneRightHz: g.cfg.Audio.ToneRightHz,
  461. ToneAmplitude: g.cfg.Audio.ToneAmplitude,
  462. AudioGain: g.cfg.Audio.Gain,
  463. CompositeClipperEnabled: g.cfg.FM.CompositeClipper.Enabled,
  464. })
  465. if g.licenseState != nil {
  466. frames, err := license.LoadJingleFrames(license.JingleWAV(), g.sampleRate)
  467. if err != nil {
  468. log.Printf("license: jingle load failed: %v", err)
  469. } else {
  470. g.jingleFrames = frames
  471. }
  472. }
  473. // STFT watermark: anti-alias LPF for decimation to WMRate (12 kHz).
  474. // Nyquist at 12 kHz = 6 kHz. Cut at 5.5 kHz with margin.
  475. if g.stftEmbedder != nil {
  476. g.wmDecimLPF = dsp.NewLPF4(5500, g.sampleRate)
  477. g.wmInterpLPF = dsp.NewLPF4(5500, g.sampleRate) // separate instance for upsample
  478. }
  479. g.initialized = true
  480. }
  481. func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) {
  482. if g.externalSource != nil {
  483. return g.externalSource, SourceInfo{Kind: "stream", SampleRate: sampleRate, Detail: "live audio"}
  484. }
  485. if g.cfg.Audio.InputPath != "" {
  486. if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil {
  487. return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath}
  488. }
  489. ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude)
  490. g.toneSource = ts
  491. return ts, SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath}
  492. }
  493. ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude)
  494. g.toneSource = ts
  495. return ts, SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"}
  496. }
  497. func clamp01(v float64) float64 {
  498. if v < 0 {
  499. return 0
  500. }
  501. if v > 1 {
  502. return 1
  503. }
  504. return v
  505. }
  506. func safeRMS(sumSquares float64, n int) float64 {
  507. if n <= 0 {
  508. return 0
  509. }
  510. return math.Sqrt(sumSquares / float64(n))
  511. }
  512. func safeDBRatio(a, b float64) float64 {
  513. if a <= 0 || b <= 0 {
  514. return 0
  515. }
  516. return 20 * math.Log10(a/b)
  517. }
  518. func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
  519. g.init()
  520. samples := int(duration.Seconds() * g.sampleRate)
  521. if samples <= 0 {
  522. samples = int(g.sampleRate / 10)
  523. }
  524. // Reuse buffer — grow only if needed, never shrink
  525. if g.frameBuf == nil || g.bufCap < samples {
  526. g.frameBuf = &output.CompositeFrame{
  527. Samples: make([]output.IQSample, samples),
  528. }
  529. g.bufCap = samples
  530. }
  531. frame := g.frameBuf
  532. frame.Samples = frame.Samples[:samples]
  533. frame.SampleRateHz = g.sampleRate
  534. frame.Timestamp = time.Now().UTC()
  535. g.frameSeq++
  536. frame.Sequence = g.frameSeq
  537. // L/R buffers for two-pass processing (STFT watermark between stages 3 and 4)
  538. lBuf := make([]float64, samples)
  539. rBuf := make([]float64, samples)
  540. var lrLSumSq, lrRSumSq float64
  541. var lrLPeak, lrRPeak float64
  542. var lrLClip, lrRClip uint32
  543. var preMonoSumSq, preStereoSumSq, preAudioMpxSumSq float64
  544. var preAudioMpxPeak float64
  545. var postAudioMpxSumSq float64
  546. var postAudioMpxPeak float64
  547. var finalCompositeSumSq float64
  548. var finalCompositePeak float64
  549. var pilotSumSq, rdsSumSq float64
  550. var pilotPeak, rdsPeak float64
  551. var overNominal, overHeadroom uint32
  552. licenseInjectionActive := false
  553. // Load live params once per chunk — single atomic read, zero per-sample cost
  554. lp := g.liveParams.Load()
  555. if lp == nil {
  556. // Fallback: should never happen after init(), but be safe
  557. lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0}
  558. }
  559. if mode := canonicalStereoMode(lp.StereoMode); mode != g.appliedStereoMode {
  560. g.stereoEncoder.SetMode(stereo.ParseMode(mode), g.sampleRate)
  561. g.appliedStereoMode = mode
  562. }
  563. // Apply live tone and gain updates each chunk. GenerateFrame runs on a
  564. // single goroutine so these field writes are safe without additional locking.
  565. if g.toneSource != nil {
  566. g.toneSource.LeftFreq = lp.ToneLeftHz
  567. g.toneSource.RightFreq = lp.ToneRightHz
  568. g.toneSource.Amplitude = lp.ToneAmplitude
  569. }
  570. if g.source != nil {
  571. g.source.gain = lp.AudioGain
  572. }
  573. // Broadcast clip-filter-clip FM MPX signal chain:
  574. //
  575. // Audio L/R → PreEmphasis
  576. // → LPF₁ (14kHz, 8th-order) → 19kHz Notch (double)
  577. // → × OutputDrive → HardClip₁ (ceiling)
  578. // → LPF₂ (14kHz, 8th-order) [removes clip₁ harmonics]
  579. // → HardClip₂ (ceiling) [catches LPF₂ overshoots]
  580. // → Stereo Encode
  581. // Audio MPX (mono + stereo sub)
  582. // → HardClip₃ (ceiling) [composite deviation control]
  583. // → 19kHz Notch (double) [protect pilot band]
  584. // → 57kHz Notch (double) [protect RDS band]
  585. // + Pilot 19kHz (fixed, NEVER clipped)
  586. // + RDS 57kHz (fixed, NEVER clipped)
  587. // → FM Modulator
  588. //
  589. // Guard band depth at 19kHz: LPF₁(-21dB) + Notch(-60dB) + LPF₂(-21dB)
  590. // + CompNotch(-60dB) → broadband floor -42dB, exact 19kHz >-90dB
  591. ceiling := lp.LimiterCeiling
  592. if ceiling <= 0 {
  593. ceiling = 1.0
  594. }
  595. // Pilot and RDS are FIXED injection levels, independent of OutputDrive.
  596. // Config values directly represent percentage of ±75kHz deviation:
  597. // pilotLevel: 0.09 = 9% = ±6.75kHz (ITU standard)
  598. // rdsInjection: 0.04 = 4% = ±3.0kHz (typical)
  599. pilotAmp := lp.PilotLevel
  600. rdsAmp := lp.RDSInjection
  601. // BS.412 MPX power limiter: uses previous chunk's measurement to set gain.
  602. // Power is measured during this chunk and fed back at the end.
  603. bs412Gain := 1.0
  604. var bs412PowerAccum float64
  605. if g.bs412 != nil {
  606. bs412Gain = g.bs412.CurrentGain()
  607. }
  608. if g.licenseState != nil {
  609. g.licenseState.Tick()
  610. }
  611. for i := 0; i < samples; i++ {
  612. in := g.source.NextFrame()
  613. // --- Stage 1: Band-limit pre-emphasized audio ---
  614. l := g.audioLPF_L.Process(float64(in.L))
  615. l = g.pilotNotchL.Process(l)
  616. r := g.audioLPF_R.Process(float64(in.R))
  617. r = g.pilotNotchR.Process(r)
  618. // --- Stage 2: Drive + Compress + Clip₁ ---
  619. l *= lp.OutputDrive
  620. r *= lp.OutputDrive
  621. if lp.LimiterEnabled && g.limiter != nil {
  622. l, r = g.limiter.Process(l, r)
  623. }
  624. l = dsp.HardClip(l, ceiling)
  625. r = dsp.HardClip(r, ceiling)
  626. // --- Stage 3: Cleanup LPF + Clip₂ (overshoot compensator) ---
  627. l = g.cleanupLPF_L.Process(l)
  628. r = g.cleanupLPF_R.Process(r)
  629. l = dsp.HardClip(l, ceiling)
  630. r = dsp.HardClip(r, ceiling)
  631. lBuf[i] = l
  632. rBuf[i] = r
  633. }
  634. // --- STFT Watermark: decimate → embed → upsample → add to L/R ---
  635. if g.stftEmbedder != nil {
  636. decimFactor := int(g.sampleRate) / watermark.WMRate // 228000/12000 = 19
  637. if decimFactor < 1 {
  638. decimFactor = 1
  639. }
  640. nDown := samples / decimFactor
  641. // Anti-alias: LPF ALL composite-rate samples, THEN decimate.
  642. // The LPF must see every sample for correct IIR state update.
  643. mono12k := make([]float64, nDown)
  644. lpfState := 0.0
  645. decimCount := 0
  646. outIdx := 0
  647. for i := 0; i < samples && outIdx < nDown; i++ {
  648. mono := (lBuf[i] + rBuf[i]) / 2
  649. if g.wmDecimLPF != nil {
  650. lpfState = g.wmDecimLPF.Process(mono)
  651. } else {
  652. lpfState = mono
  653. }
  654. decimCount++
  655. if decimCount >= decimFactor {
  656. decimCount = 0
  657. mono12k[outIdx] = lpfState
  658. outIdx++
  659. }
  660. }
  661. // STFT embed at 12 kHz
  662. embedded := g.stftEmbedder.ProcessBlock(mono12k)
  663. // Extract watermark signal (difference) and upsample via ZOH + LPF.
  664. // ZOH creates spectral images at 12k, 24k, 36k... Hz.
  665. // The interpolation LPF removes these, keeping only 0-5.5 kHz.
  666. // Without this, the images leak into pilot (19k) and stereo sub (38k).
  667. for i := 0; i < samples; i++ {
  668. wmIdx := i / decimFactor
  669. if wmIdx >= nDown {
  670. wmIdx = nDown - 1
  671. }
  672. wmSig := embedded[wmIdx] - mono12k[wmIdx]
  673. if g.wmInterpLPF != nil {
  674. wmSig = g.wmInterpLPF.Process(wmSig)
  675. }
  676. lBuf[i] += wmSig
  677. rBuf[i] += wmSig
  678. }
  679. }
  680. // --- Pass 2: Stereo encode + composite processing ---
  681. // Feed RDS2 groups once per frame (not per sample)
  682. if g.rds2Enc != nil && g.rds2Enc.Enabled() {
  683. g.rds2Enc.FeedGroups()
  684. }
  685. for i := 0; i < samples; i++ {
  686. l := lBuf[i]
  687. r := rBuf[i]
  688. lrLSumSq += l * l
  689. lrRSumSq += r * r
  690. if abs := math.Abs(l); abs > lrLPeak {
  691. lrLPeak = abs
  692. }
  693. if abs := math.Abs(r); abs > lrRPeak {
  694. lrRPeak = abs
  695. }
  696. if math.Abs(l) >= ceiling {
  697. lrLClip++
  698. }
  699. if math.Abs(r) >= ceiling {
  700. lrRClip++
  701. }
  702. // --- Stage 4: Stereo encode ---
  703. limited := audio.NewFrame(audio.Sample(l), audio.Sample(r))
  704. comps := g.stereoEncoder.Encode(limited)
  705. // --- Stage 5: Composite clip + protection ---
  706. monoComponent := float64(comps.Mono)
  707. stereoComponent := 0.0
  708. if lp.StereoEnabled {
  709. stereoComponent = float64(comps.Stereo)
  710. }
  711. audioMPX := monoComponent + stereoComponent
  712. if lp.CompositeClipperEnabled && g.compositeClip != nil {
  713. // ITU-R SM.1268 iterative clipper: look-ahead + N×(clip→notch→notch) + final clip
  714. audioMPX = g.compositeClip.Process(audioMPX)
  715. } else {
  716. // Legacy single-pass: one clip, then notch, no final safety clip
  717. audioMPX = dsp.HardClip(audioMPX, ceiling)
  718. audioMPX = g.mpxNotch19.Process(audioMPX)
  719. audioMPX = g.mpxNotch57.Process(audioMPX)
  720. }
  721. preAudioMpxSumSq += audioMPX * audioMPX
  722. preMonoSumSq += monoComponent * monoComponent
  723. preStereoSumSq += stereoComponent * stereoComponent
  724. if abs := math.Abs(audioMPX); abs > preAudioMpxPeak {
  725. preAudioMpxPeak = abs
  726. }
  727. // BS.412: apply gain and measure power
  728. if bs412Gain < 1.0 {
  729. audioMPX *= bs412Gain
  730. }
  731. postAudioMpxSumSq += audioMPX * audioMPX
  732. if abs := math.Abs(audioMPX); abs > postAudioMpxPeak {
  733. postAudioMpxPeak = abs
  734. }
  735. bs412PowerAccum += audioMPX * audioMPX
  736. // --- Stage 6: Add protected components ---
  737. composite := audioMPX
  738. if lp.StereoEnabled {
  739. composite += pilotAmp * comps.Pilot
  740. }
  741. rdsContribution := 0.0
  742. if g.rdsEnc != nil && lp.RDSEnabled {
  743. rdsCarrier := g.stereoEncoder.RDSCarrier()
  744. rdsValue := g.rdsEnc.NextSampleWithCarrier(rdsCarrier)
  745. rdsContribution = rdsAmp * rdsValue
  746. composite += rdsContribution
  747. }
  748. // RDS2: three additional subcarriers (66.5, 71.25, 76 kHz)
  749. // Each at ≤3.5% injection (ITU-R BS.450-3 limit: 10% total for all RDS)
  750. if g.rds2Enc != nil && g.rds2Enc.Enabled() {
  751. pilotPhase := g.stereoEncoder.PilotPhase()
  752. rds2Value := g.rds2Enc.NextSampleWithPilot(pilotPhase)
  753. // rds2Injection: ~3% per stream × 3 streams, split evenly
  754. composite += rdsAmp * 0.75 * rds2Value // 75% of RDS injection per stream
  755. }
  756. // Jingle: injected when unlicensed, bypasses drive/gain controls.
  757. if g.licenseState != nil && len(g.jingleFrames) > 0 {
  758. jingleContribution := g.licenseState.NextSample(g.jingleFrames)
  759. if jingleContribution != 0 {
  760. licenseInjectionActive = true
  761. }
  762. composite += jingleContribution
  763. }
  764. pilotContribution := 0.0
  765. if lp.StereoEnabled {
  766. pilotContribution = pilotAmp * comps.Pilot
  767. }
  768. pilotSumSq += pilotContribution * pilotContribution
  769. if abs := math.Abs(pilotContribution); abs > pilotPeak {
  770. pilotPeak = abs
  771. }
  772. rdsSumSq += rdsContribution * rdsContribution
  773. if abs := math.Abs(rdsContribution); abs > rdsPeak {
  774. rdsPeak = abs
  775. }
  776. finalCompositeSumSq += composite * composite
  777. if abs := math.Abs(composite); abs > finalCompositePeak {
  778. finalCompositePeak = abs
  779. }
  780. if math.Abs(composite) > 1.0 {
  781. overNominal++
  782. }
  783. if math.Abs(composite) > 1.1 {
  784. overHeadroom++
  785. }
  786. if g.fmMod != nil {
  787. iq_i, iq_q := g.fmMod.Modulate(composite)
  788. frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)}
  789. } else {
  790. frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
  791. }
  792. }
  793. preAudioRMS := safeRMS(preAudioMpxSumSq, samples)
  794. postAudioRMS := safeRMS(postAudioMpxSumSq, samples)
  795. lRMS := safeRMS(lrLSumSq, samples)
  796. rRMS := safeRMS(lrRSumSq, samples)
  797. monoRMS := safeRMS(preMonoSumSq, samples)
  798. stereoRMS := safeRMS(preStereoSumSq, samples)
  799. finalCompositeRMS := safeRMS(finalCompositeSumSq, samples)
  800. pilotRMS := safeRMS(pilotSumSq, samples)
  801. rdsRMS := safeRMS(rdsSumSq, samples)
  802. lrBalanceDB := safeDBRatio(lRMS, rRMS)
  803. clipperStats := dsp.CompositeClipperStats{}
  804. if g.compositeClip != nil {
  805. clipperStats = g.compositeClip.Stats()
  806. }
  807. measurement := &MeasurementSnapshot{
  808. Timestamp: frame.Timestamp,
  809. SampleRateHz: g.sampleRate,
  810. ChunkSamples: samples,
  811. ChunkDurationMs: float64(samples) / g.sampleRate * 1000,
  812. Sequence: frame.Sequence,
  813. Flags: MeasurementFlags{
  814. StereoEnabled: lp.StereoEnabled,
  815. StereoMode: g.appliedStereoMode,
  816. RDSEnabled: lp.RDSEnabled,
  817. RDS2Enabled: g.rds2Enc != nil && g.rds2Enc.Enabled(),
  818. BS412Enabled: g.bs412 != nil,
  819. CompositeClipperEnabled: lp.CompositeClipperEnabled,
  820. WatermarkEnabled: g.stftEmbedder != nil,
  821. LicenseInjectionActive: licenseInjectionActive,
  822. },
  823. LRPreEncodePostWatermark: LRPreEncodePostWatermarkMeasurement{
  824. LRms: lRMS,
  825. RRms: rRMS,
  826. LPeakAbs: lrLPeak,
  827. RPeakAbs: lrRPeak,
  828. LRBalanceDB: lrBalanceDB,
  829. LClipEvents: lrLClip,
  830. RClipEvents: lrRClip,
  831. },
  832. AudioMPXPreBS412: AudioMPXPreBS412Measurement{
  833. RMS: preAudioRMS,
  834. PeakAbs: preAudioMpxPeak,
  835. MonoRMS: monoRMS,
  836. StereoRMS: stereoRMS,
  837. CrestFactor: func() float64 { if preAudioRMS > 0 { return preAudioMpxPeak / preAudioRMS }; return 0 }(),
  838. ClipperLookaheadGain: clipperStats.LookaheadGain,
  839. ClipperEnvelope: clipperStats.Envelope,
  840. ClipperOrProtectionActive: clipperStats.LookaheadGain < 0.999 || clipperStats.Envelope > 1.0,
  841. },
  842. AudioMPXPostBS412: AudioMPXPostBS412Measurement{
  843. RMS: postAudioRMS,
  844. PeakAbs: postAudioMpxPeak,
  845. BS412GainApplied: bs412Gain,
  846. BS412AttenuationDB: func() float64 { if bs412Gain > 0 { return 20 * math.Log10(bs412Gain) }; return 0 }(),
  847. EstimatedAudioPower: func() float64 { if samples > 0 { return bs412PowerAccum / float64(samples) }; return 0 }(),
  848. },
  849. CompositeFinalPreIQ: CompositeFinalPreIQMeasurement{
  850. RMS: finalCompositeRMS,
  851. PeakAbs: finalCompositePeak,
  852. PilotRMS: pilotRMS,
  853. PilotPeakAbs: pilotPeak,
  854. PilotInjectionEquivalentPercent: clamp01(pilotPeak) * 100,
  855. RDSRMS: rdsRMS,
  856. RDSPeakAbs: rdsPeak,
  857. OverNominalEvents: overNominal,
  858. OverHeadroomEvents: overHeadroom,
  859. },
  860. }
  861. g.latestMeasurement.Store(measurement)
  862. // BS.412: feed this chunk's actual duration and average audio power for
  863. // the next chunk's gain calculation. Using the real sample count avoids
  864. // the error that occurred when chunkSec was hardcoded to 0.05 — any
  865. // SetChunkDuration() call from the engine would silently miscalibrate
  866. // the ITU-R BS.412 power measurement window.
  867. if g.bs412 != nil && samples > 0 {
  868. chunkSec := float64(samples) / g.sampleRate
  869. g.bs412.UpdateChunkDuration(chunkSec)
  870. g.bs412.ProcessChunk(bs412PowerAccum / float64(samples))
  871. }
  872. return frame
  873. }
  874. func (g *Generator) WriteFile(path string, duration time.Duration) error {
  875. if path == "" {
  876. path = g.cfg.Backend.OutputPath
  877. }
  878. if path == "" {
  879. path = filepath.Join("build", "offline", "composite.iqf32")
  880. }
  881. backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
  882. Name: "offline-file",
  883. Description: "offline composite file backend",
  884. })
  885. if err != nil {
  886. return err
  887. }
  888. defer backend.Close(context.Background())
  889. if err := backend.Configure(context.Background(), output.BackendConfig{
  890. SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
  891. Channels: 2,
  892. IQLevel: float32(g.cfg.FM.OutputDrive),
  893. }); err != nil {
  894. return err
  895. }
  896. frame := g.GenerateFrame(duration)
  897. if _, err := backend.Write(context.Background(), frame); err != nil {
  898. return err
  899. }
  900. return backend.Flush(context.Background())
  901. }
  902. func (g *Generator) Summary(duration time.Duration) string {
  903. sampleRate := float64(g.cfg.FM.CompositeRateHz)
  904. if sampleRate <= 0 {
  905. sampleRate = 228000
  906. }
  907. _, info := g.sourceFor(sampleRate)
  908. preemph := "off"
  909. if g.cfg.FM.PreEmphasisTauUS > 0 {
  910. preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisTauUS)
  911. }
  912. modMode := "composite"
  913. if g.cfg.FM.FMModulationEnabled {
  914. modMode = fmt.Sprintf("FM-IQ(±%.0fHz)", g.cfg.FM.MaxDeviationHz)
  915. }
  916. 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",
  917. g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(),
  918. g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled,
  919. preemph, g.cfg.FM.LimiterEnabled, modMode, info.Kind, info.Detail)
  920. }