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.

653 líneas
22KB

  1. package offline
  2. import (
  3. "context"
  4. "encoding/binary"
  5. "fmt"
  6. "log"
  7. "path/filepath"
  8. "sync/atomic"
  9. "time"
  10. "github.com/jan/fm-rds-tx/internal/audio"
  11. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  12. "github.com/jan/fm-rds-tx/internal/license"
  13. "github.com/jan/fm-rds-tx/internal/watermark"
  14. "github.com/jan/fm-rds-tx/internal/dsp"
  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. )
  20. type frameSource interface {
  21. NextFrame() audio.Frame
  22. }
  23. // LiveParams carries DSP parameters that can be hot-swapped at runtime.
  24. // Loaded once per chunk via atomic pointer — zero per-sample overhead.
  25. type LiveParams struct {
  26. OutputDrive float64
  27. StereoEnabled bool
  28. StereoMode string
  29. PilotLevel float64
  30. RDSInjection float64
  31. RDSEnabled bool
  32. LimiterEnabled bool
  33. LimiterCeiling float64
  34. MpxGain float64 // hardware calibration factor for composite output
  35. // Tone + gain: live-patchable without DSP chain reinit.
  36. ToneLeftHz float64
  37. ToneRightHz float64
  38. ToneAmplitude float64
  39. AudioGain float64
  40. // Composite clipper: live-toggleable without DSP chain reinit.
  41. CompositeClipperEnabled bool
  42. }
  43. // PreEmphasizedSource wraps an audio source and applies pre-emphasis.
  44. // The source is expected to already output at composite rate (resampled
  45. // upstream). Pre-emphasis is applied per-sample at that rate.
  46. type PreEmphasizedSource struct {
  47. src frameSource
  48. preL *dsp.PreEmphasis
  49. preR *dsp.PreEmphasis
  50. gain float64
  51. }
  52. func NewPreEmphasizedSource(src frameSource, tauUS, sampleRate, gain float64) *PreEmphasizedSource {
  53. p := &PreEmphasizedSource{src: src, gain: gain}
  54. if tauUS > 0 {
  55. p.preL = dsp.NewPreEmphasis(tauUS, sampleRate)
  56. p.preR = dsp.NewPreEmphasis(tauUS, sampleRate)
  57. }
  58. return p
  59. }
  60. func (p *PreEmphasizedSource) NextFrame() audio.Frame {
  61. f := p.src.NextFrame()
  62. l := float64(f.L) * p.gain
  63. r := float64(f.R) * p.gain
  64. if p.preL != nil {
  65. l = p.preL.Process(l)
  66. r = p.preR.Process(r)
  67. }
  68. return audio.NewFrame(audio.Sample(l), audio.Sample(r))
  69. }
  70. type SourceInfo struct {
  71. Kind string
  72. SampleRate float64
  73. Detail string
  74. }
  75. type Generator struct {
  76. cfg cfgpkg.Config
  77. // Persistent DSP state across GenerateFrame calls
  78. source *PreEmphasizedSource
  79. stereoEncoder stereo.StereoEncoder
  80. rdsEnc *rds.Encoder
  81. rds2Enc *rds.RDS2Encoder
  82. combiner mpx.DefaultCombiner
  83. fmMod *dsp.FMModulator
  84. sampleRate float64
  85. initialized bool
  86. frameSeq uint64
  87. // Broadcast-standard clip-filter-clip chain (per channel L/R):
  88. //
  89. // PreEmph → LPF₁(14kHz) → Notch(19kHz) → ×Drive
  90. // → StereoLimiter (slow AGC: raises average level)
  91. // → Clip₁ → LPF₂(14kHz) [cleanup] → Clip₂ [catches LPF overshoots]
  92. // → Stereo Encode → Composite Clip → Notch₁₉ → Notch₅₇
  93. // → + Pilot → + RDS → FM
  94. //
  95. audioLPF_L *dsp.FilterChain // 14kHz 8th-order (pre-clip)
  96. audioLPF_R *dsp.FilterChain
  97. pilotNotchL *dsp.FilterChain // 19kHz double-notch (guard band)
  98. pilotNotchR *dsp.FilterChain
  99. limiter *dsp.StereoLimiter // slow compressor (raises average, clips catch peaks)
  100. cleanupLPF_L *dsp.FilterChain // 14kHz 8th-order (post-clip cleanup)
  101. cleanupLPF_R *dsp.FilterChain
  102. mpxNotch19 *dsp.FilterChain // composite clipper protection
  103. mpxNotch57 *dsp.FilterChain
  104. bs412 *dsp.BS412Limiter // ITU-R BS.412 MPX power limiter (optional)
  105. compositeClip *dsp.CompositeClipper // ITU-R SM.1268 iterative composite clipper (optional)
  106. // Pre-allocated frame buffer — reused every GenerateFrame call.
  107. frameBuf *output.CompositeFrame
  108. bufCap int
  109. // Live-updatable DSP parameters — written by control API, read per chunk.
  110. liveParams atomic.Pointer[LiveParams]
  111. // Optional external audio source (e.g. StreamResampler for live audio).
  112. // When set, takes priority over WAV/tones in sourceFor().
  113. externalSource frameSource
  114. // Tone source reference — non-nil when a ToneSource is the active audio input.
  115. // Allows live-updating tone parameters via LiveParams each chunk.
  116. toneSource *audio.ToneSource
  117. // License: jingle injection when unlicensed.
  118. licenseState *license.State
  119. jingleFrames []license.JingleFrame
  120. // Watermark: STFT-domain spread-spectrum (Kirovski & Malvar 2003).
  121. stftEmbedder *watermark.STFTEmbedder
  122. wmDecimLPF *dsp.FilterChain // anti-alias LPF for 228k→12k decimation
  123. wmInterpLPF *dsp.FilterChain // image-rejection LPF for 12k→228k upsample
  124. }
  125. func NewGenerator(cfg cfgpkg.Config) *Generator {
  126. return &Generator{cfg: cfg}
  127. }
  128. // SetLicense configures license state (jingle) and creates the STFT watermark
  129. // embedder. Must be called before the first GenerateFrame.
  130. func (g *Generator) SetLicense(state *license.State, key string) {
  131. g.licenseState = state
  132. g.stftEmbedder = watermark.NewSTFTEmbedder(key)
  133. }
  134. // SetExternalSource sets a live audio source (e.g. StreamResampler) that
  135. // takes priority over WAV/tone sources. Must be called before the first
  136. // GenerateFrame() call; calling it after init() has no effect because
  137. // g.source is already wired to the old source.
  138. func (g *Generator) SetExternalSource(src frameSource) error {
  139. if g.initialized {
  140. return fmt.Errorf("generator: SetExternalSource called after GenerateFrame; call it before the engine starts")
  141. }
  142. g.externalSource = src
  143. return nil
  144. }
  145. // UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API,
  146. // applied at the next chunk boundary by the DSP goroutine.
  147. func (g *Generator) UpdateLive(p LiveParams) {
  148. // Detect stereo mode change: requires reconfiguring the encoder's Hilbert filter.
  149. if old := g.liveParams.Load(); old != nil && old.StereoMode != p.StereoMode {
  150. g.stereoEncoder.SetMode(stereo.ParseMode(p.StereoMode), g.sampleRate)
  151. }
  152. g.liveParams.Store(&p)
  153. }
  154. // CurrentLiveParams returns the current live parameter snapshot.
  155. // Used by Engine.UpdateConfig to do read-modify-write on the params.
  156. func (g *Generator) CurrentLiveParams() LiveParams {
  157. if lp := g.liveParams.Load(); lp != nil {
  158. return *lp
  159. }
  160. return LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0}
  161. }
  162. // RDSEncoder returns the live RDS encoder, or nil if RDS is disabled.
  163. // Used by the Engine to forward text updates.
  164. func (g *Generator) RDSEncoder() *rds.Encoder {
  165. return g.rdsEnc
  166. }
  167. func (g *Generator) init() {
  168. if g.initialized {
  169. return
  170. }
  171. g.sampleRate = float64(g.cfg.FM.CompositeRateHz)
  172. if g.sampleRate <= 0 {
  173. g.sampleRate = 228000
  174. }
  175. rawSource, _ := g.sourceFor(g.sampleRate)
  176. g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain)
  177. g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate)
  178. g.stereoEncoder.SetMode(stereo.ParseMode(g.cfg.FM.StereoMode), g.sampleRate)
  179. g.combiner = mpx.DefaultCombiner{
  180. MonoGain: 1.0, StereoGain: 1.0,
  181. PilotGain: g.cfg.FM.PilotLevel, RDSGain: g.cfg.FM.RDSInjection,
  182. }
  183. if g.cfg.RDS.Enabled {
  184. piCode, _ := cfgpkg.ParsePI(g.cfg.RDS.PI)
  185. // Build EON entries
  186. var eonEntries []rds.EONEntry
  187. for _, e := range g.cfg.RDS.EON {
  188. eonPI, _ := cfgpkg.ParsePI(e.PI)
  189. eonEntries = append(eonEntries, rds.EONEntry{
  190. PI: eonPI, PS: e.PS, PTY: uint8(e.PTY),
  191. TP: e.TP, TA: e.TA, AF: e.AF,
  192. })
  193. }
  194. sep := g.cfg.RDS.RTPlusSeparator
  195. if sep == "" {
  196. sep = " - "
  197. }
  198. g.rdsEnc, _ = rds.NewEncoder(rds.RDSConfig{
  199. PI: piCode, PS: g.cfg.RDS.PS, RT: g.cfg.RDS.RadioText,
  200. PTY: uint8(g.cfg.RDS.PTY), SampleRate: g.sampleRate,
  201. TP: g.cfg.RDS.TP, TA: g.cfg.RDS.TA,
  202. MS: g.cfg.RDS.MS, DI: g.cfg.RDS.DI,
  203. AF: g.cfg.RDS.AF,
  204. CTEnabled: g.cfg.RDS.CTEnabled,
  205. CTOffsetHalfHours: g.cfg.RDS.CTOffsetHalfHours,
  206. PTYN: g.cfg.RDS.PTYN,
  207. LPS: g.cfg.RDS.LPS,
  208. RTPlusEnabled: g.cfg.RDS.RTPlusEnabled,
  209. RTPlusSeparator: sep,
  210. ERTEnabled: g.cfg.RDS.ERTEnabled,
  211. ERT: g.cfg.RDS.ERT,
  212. ERTGroupType: 12, // default: Group 12A
  213. EON: eonEntries,
  214. })
  215. // RDS2: additional subcarriers (66.5, 71.25, 76 kHz)
  216. if g.cfg.RDS.RDS2Enabled {
  217. g.rds2Enc = rds.NewRDS2Encoder(g.sampleRate)
  218. g.rds2Enc.Enable(true)
  219. if g.cfg.RDS.StationLogoPath != "" {
  220. if err := g.rds2Enc.LoadLogo(g.cfg.RDS.StationLogoPath); err != nil {
  221. log.Printf("rds2: failed to load station logo: %v", err)
  222. }
  223. }
  224. }
  225. }
  226. ceiling := g.cfg.FM.LimiterCeiling
  227. if ceiling <= 0 { ceiling = 1.0 }
  228. // Broadcast clip-filter-clip chain:
  229. // Pre-clip: 14kHz LPF (8th-order) + 19kHz double-notch (per channel)
  230. g.audioLPF_L = dsp.NewAudioLPF(g.sampleRate)
  231. g.audioLPF_R = dsp.NewAudioLPF(g.sampleRate)
  232. g.pilotNotchL = dsp.NewPilotNotch(g.sampleRate)
  233. g.pilotNotchR = dsp.NewPilotNotch(g.sampleRate)
  234. // Slow compressor: 5ms attack / 200ms release. Brings average level UP.
  235. // The clips after it catch the peaks the limiter's attack time misses.
  236. // This is the "slow-to-fast progression" from broadcast processing:
  237. // slow limiter → fast clips.
  238. // Burst-masking-optimized limiter (Bonello, JAES 2007):
  239. // 2ms attack lets initial transient peaks clip for <5ms (burst-masked).
  240. // 150ms release avoids audible pumping on sustained passages.
  241. g.limiter = dsp.NewStereoLimiter(ceiling, 2, 150, g.sampleRate)
  242. // Post-clip cleanup: second 14kHz LPF pass (removes clip harmonics)
  243. g.cleanupLPF_L = dsp.NewAudioLPF(g.sampleRate)
  244. g.cleanupLPF_R = dsp.NewAudioLPF(g.sampleRate)
  245. // Composite clipper protection: double-notch at 19kHz + 57kHz
  246. g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate)
  247. // ITU-R SM.1268 iterative composite clipper (optional, replaces simple clip+notch)
  248. // Always created so it can be live-toggled via CompositeClipperEnabled.
  249. g.compositeClip = dsp.NewCompositeClipper(dsp.CompositeClipperConfig{
  250. Ceiling: ceiling,
  251. Iterations: g.cfg.FM.CompositeClipper.Iterations,
  252. SoftKnee: g.cfg.FM.CompositeClipper.SoftKnee,
  253. LookaheadMs: g.cfg.FM.CompositeClipper.LookaheadMs,
  254. SampleRate: g.sampleRate,
  255. })
  256. // BS.412 MPX power limiter (EU/CH requirement for licensed FM)
  257. if g.cfg.FM.BS412Enabled {
  258. // chunkSec is not known at init time (Engine.chunkDuration may differ).
  259. // Pass 0 here; GenerateFrame computes the actual chunk duration from
  260. // the real sample count and updates BS.412 accordingly.
  261. g.bs412 = dsp.NewBS412Limiter(
  262. g.cfg.FM.BS412ThresholdDBr,
  263. g.cfg.FM.PilotLevel,
  264. g.cfg.FM.RDSInjection,
  265. 0,
  266. )
  267. }
  268. if g.cfg.FM.FMModulationEnabled {
  269. g.fmMod = dsp.NewFMModulator(g.sampleRate)
  270. maxDev := g.cfg.FM.MaxDeviationHz
  271. if maxDev > 0 {
  272. if g.cfg.FM.MpxGain > 0 && g.cfg.FM.MpxGain != 1.0 {
  273. maxDev *= g.cfg.FM.MpxGain
  274. }
  275. g.fmMod.MaxDeviation = maxDev
  276. }
  277. }
  278. // Seed initial live params from config
  279. g.liveParams.Store(&LiveParams{
  280. OutputDrive: g.cfg.FM.OutputDrive,
  281. StereoEnabled: g.cfg.FM.StereoEnabled,
  282. StereoMode: g.cfg.FM.StereoMode,
  283. PilotLevel: g.cfg.FM.PilotLevel,
  284. RDSInjection: g.cfg.FM.RDSInjection,
  285. RDSEnabled: g.cfg.RDS.Enabled,
  286. LimiterEnabled: g.cfg.FM.LimiterEnabled,
  287. LimiterCeiling: ceiling,
  288. MpxGain: g.cfg.FM.MpxGain,
  289. ToneLeftHz: g.cfg.Audio.ToneLeftHz,
  290. ToneRightHz: g.cfg.Audio.ToneRightHz,
  291. ToneAmplitude: g.cfg.Audio.ToneAmplitude,
  292. AudioGain: g.cfg.Audio.Gain,
  293. CompositeClipperEnabled: g.cfg.FM.CompositeClipper.Enabled,
  294. })
  295. if g.licenseState != nil {
  296. frames, err := license.LoadJingleFrames(license.JingleWAV(), g.sampleRate)
  297. if err != nil {
  298. log.Printf("license: jingle load failed: %v", err)
  299. } else {
  300. g.jingleFrames = frames
  301. }
  302. }
  303. // STFT watermark: anti-alias LPF for decimation to WMRate (12 kHz).
  304. // Nyquist at 12 kHz = 6 kHz. Cut at 5.5 kHz with margin.
  305. if g.stftEmbedder != nil {
  306. g.wmDecimLPF = dsp.NewLPF4(5500, g.sampleRate)
  307. g.wmInterpLPF = dsp.NewLPF4(5500, g.sampleRate) // separate instance for upsample
  308. }
  309. g.initialized = true
  310. }
  311. func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) {
  312. if g.externalSource != nil {
  313. return g.externalSource, SourceInfo{Kind: "stream", SampleRate: sampleRate, Detail: "live audio"}
  314. }
  315. if g.cfg.Audio.InputPath != "" {
  316. if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil {
  317. return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath}
  318. }
  319. ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude)
  320. g.toneSource = ts
  321. return ts, SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath}
  322. }
  323. ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude)
  324. g.toneSource = ts
  325. return ts, SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"}
  326. }
  327. func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
  328. g.init()
  329. samples := int(duration.Seconds() * g.sampleRate)
  330. if samples <= 0 { samples = int(g.sampleRate / 10) }
  331. // Reuse buffer — grow only if needed, never shrink
  332. if g.frameBuf == nil || g.bufCap < samples {
  333. g.frameBuf = &output.CompositeFrame{
  334. Samples: make([]output.IQSample, samples),
  335. }
  336. g.bufCap = samples
  337. }
  338. frame := g.frameBuf
  339. frame.Samples = frame.Samples[:samples]
  340. frame.SampleRateHz = g.sampleRate
  341. frame.Timestamp = time.Now().UTC()
  342. g.frameSeq++
  343. frame.Sequence = g.frameSeq
  344. // L/R buffers for two-pass processing (STFT watermark between stages 3 and 4)
  345. lBuf := make([]float64, samples)
  346. rBuf := make([]float64, samples)
  347. // Load live params once per chunk — single atomic read, zero per-sample cost
  348. lp := g.liveParams.Load()
  349. if lp == nil {
  350. // Fallback: should never happen after init(), but be safe
  351. lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0}
  352. }
  353. // Apply live tone and gain updates each chunk. GenerateFrame runs on a
  354. // single goroutine so these field writes are safe without additional locking.
  355. if g.toneSource != nil {
  356. g.toneSource.LeftFreq = lp.ToneLeftHz
  357. g.toneSource.RightFreq = lp.ToneRightHz
  358. g.toneSource.Amplitude = lp.ToneAmplitude
  359. }
  360. if g.source != nil {
  361. g.source.gain = lp.AudioGain
  362. }
  363. // Broadcast clip-filter-clip FM MPX signal chain:
  364. //
  365. // Audio L/R → PreEmphasis
  366. // → LPF₁ (14kHz, 8th-order) → 19kHz Notch (double)
  367. // → × OutputDrive → HardClip₁ (ceiling)
  368. // → LPF₂ (14kHz, 8th-order) [removes clip₁ harmonics]
  369. // → HardClip₂ (ceiling) [catches LPF₂ overshoots]
  370. // → Stereo Encode
  371. // Audio MPX (mono + stereo sub)
  372. // → HardClip₃ (ceiling) [composite deviation control]
  373. // → 19kHz Notch (double) [protect pilot band]
  374. // → 57kHz Notch (double) [protect RDS band]
  375. // + Pilot 19kHz (fixed, NEVER clipped)
  376. // + RDS 57kHz (fixed, NEVER clipped)
  377. // → FM Modulator
  378. //
  379. // Guard band depth at 19kHz: LPF₁(-21dB) + Notch(-60dB) + LPF₂(-21dB)
  380. // + CompNotch(-60dB) → broadband floor -42dB, exact 19kHz >-90dB
  381. ceiling := lp.LimiterCeiling
  382. if ceiling <= 0 { ceiling = 1.0 }
  383. // Pilot and RDS are FIXED injection levels, independent of OutputDrive.
  384. // Config values directly represent percentage of ±75kHz deviation:
  385. // pilotLevel: 0.09 = 9% = ±6.75kHz (ITU standard)
  386. // rdsInjection: 0.04 = 4% = ±3.0kHz (typical)
  387. pilotAmp := lp.PilotLevel
  388. rdsAmp := lp.RDSInjection
  389. // BS.412 MPX power limiter: uses previous chunk's measurement to set gain.
  390. // Power is measured during this chunk and fed back at the end.
  391. bs412Gain := 1.0
  392. var bs412PowerAccum float64
  393. if g.bs412 != nil {
  394. bs412Gain = g.bs412.CurrentGain()
  395. }
  396. if g.licenseState != nil {
  397. g.licenseState.Tick()
  398. }
  399. for i := 0; i < samples; i++ {
  400. in := g.source.NextFrame()
  401. // --- Stage 1: Band-limit pre-emphasized audio ---
  402. l := g.audioLPF_L.Process(float64(in.L))
  403. l = g.pilotNotchL.Process(l)
  404. r := g.audioLPF_R.Process(float64(in.R))
  405. r = g.pilotNotchR.Process(r)
  406. // --- Stage 2: Drive + Compress + Clip₁ ---
  407. l *= lp.OutputDrive
  408. r *= lp.OutputDrive
  409. if g.limiter != nil {
  410. l, r = g.limiter.Process(l, r)
  411. }
  412. l = dsp.HardClip(l, ceiling)
  413. r = dsp.HardClip(r, ceiling)
  414. // --- Stage 3: Cleanup LPF + Clip₂ (overshoot compensator) ---
  415. l = g.cleanupLPF_L.Process(l)
  416. r = g.cleanupLPF_R.Process(r)
  417. l = dsp.HardClip(l, ceiling)
  418. r = dsp.HardClip(r, ceiling)
  419. lBuf[i] = l
  420. rBuf[i] = r
  421. }
  422. // --- STFT Watermark: decimate → embed → upsample → add to L/R ---
  423. if g.stftEmbedder != nil {
  424. decimFactor := int(g.sampleRate) / watermark.WMRate // 228000/12000 = 19
  425. if decimFactor < 1 {
  426. decimFactor = 1
  427. }
  428. nDown := samples / decimFactor
  429. // Anti-alias: LPF ALL composite-rate samples, THEN decimate.
  430. // The LPF must see every sample for correct IIR state update.
  431. mono12k := make([]float64, nDown)
  432. lpfState := 0.0
  433. decimCount := 0
  434. outIdx := 0
  435. for i := 0; i < samples && outIdx < nDown; i++ {
  436. mono := (lBuf[i] + rBuf[i]) / 2
  437. if g.wmDecimLPF != nil {
  438. lpfState = g.wmDecimLPF.Process(mono)
  439. } else {
  440. lpfState = mono
  441. }
  442. decimCount++
  443. if decimCount >= decimFactor {
  444. decimCount = 0
  445. mono12k[outIdx] = lpfState
  446. outIdx++
  447. }
  448. }
  449. // STFT embed at 12 kHz
  450. embedded := g.stftEmbedder.ProcessBlock(mono12k)
  451. // Extract watermark signal (difference) and upsample via ZOH + LPF.
  452. // ZOH creates spectral images at 12k, 24k, 36k... Hz.
  453. // The interpolation LPF removes these, keeping only 0-5.5 kHz.
  454. // Without this, the images leak into pilot (19k) and stereo sub (38k).
  455. for i := 0; i < samples; i++ {
  456. wmIdx := i / decimFactor
  457. if wmIdx >= nDown {
  458. wmIdx = nDown - 1
  459. }
  460. wmSig := embedded[wmIdx] - mono12k[wmIdx]
  461. if g.wmInterpLPF != nil {
  462. wmSig = g.wmInterpLPF.Process(wmSig)
  463. }
  464. lBuf[i] += wmSig
  465. rBuf[i] += wmSig
  466. }
  467. }
  468. // --- Pass 2: Stereo encode + composite processing ---
  469. // Feed RDS2 groups once per frame (not per sample)
  470. if g.rds2Enc != nil && g.rds2Enc.Enabled() {
  471. g.rds2Enc.FeedGroups()
  472. }
  473. for i := 0; i < samples; i++ {
  474. l := lBuf[i]
  475. r := rBuf[i]
  476. // --- Stage 4: Stereo encode ---
  477. limited := audio.NewFrame(audio.Sample(l), audio.Sample(r))
  478. comps := g.stereoEncoder.Encode(limited)
  479. // --- Stage 5: Composite clip + protection ---
  480. audioMPX := float64(comps.Mono)
  481. if lp.StereoEnabled {
  482. audioMPX += float64(comps.Stereo)
  483. }
  484. if lp.CompositeClipperEnabled && g.compositeClip != nil {
  485. // ITU-R SM.1268 iterative clipper: look-ahead + N×(clip→notch→notch) + final clip
  486. audioMPX = g.compositeClip.Process(audioMPX)
  487. } else {
  488. // Legacy single-pass: one clip, then notch, no final safety clip
  489. audioMPX = dsp.HardClip(audioMPX, ceiling)
  490. audioMPX = g.mpxNotch19.Process(audioMPX)
  491. audioMPX = g.mpxNotch57.Process(audioMPX)
  492. }
  493. // BS.412: apply gain and measure power
  494. if bs412Gain < 1.0 {
  495. audioMPX *= bs412Gain
  496. }
  497. bs412PowerAccum += audioMPX * audioMPX
  498. // --- Stage 6: Add protected components ---
  499. composite := audioMPX
  500. if lp.StereoEnabled {
  501. composite += pilotAmp * comps.Pilot
  502. }
  503. if g.rdsEnc != nil && lp.RDSEnabled {
  504. rdsCarrier := g.stereoEncoder.RDSCarrier()
  505. rdsValue := g.rdsEnc.NextSampleWithCarrier(rdsCarrier)
  506. composite += rdsAmp * rdsValue
  507. }
  508. // RDS2: three additional subcarriers (66.5, 71.25, 76 kHz)
  509. // Each at ≤3.5% injection (ITU-R BS.450-3 limit: 10% total for all RDS)
  510. if g.rds2Enc != nil && g.rds2Enc.Enabled() {
  511. pilotPhase := g.stereoEncoder.PilotPhase()
  512. rds2Value := g.rds2Enc.NextSampleWithPilot(pilotPhase)
  513. // rds2Injection: ~3% per stream × 3 streams, split evenly
  514. composite += rdsAmp * 0.75 * rds2Value // 75% of RDS injection per stream
  515. }
  516. // Jingle: injected when unlicensed, bypasses drive/gain controls.
  517. if g.licenseState != nil && len(g.jingleFrames) > 0 {
  518. composite += g.licenseState.NextSample(g.jingleFrames)
  519. }
  520. if g.fmMod != nil {
  521. iq_i, iq_q := g.fmMod.Modulate(composite)
  522. frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)}
  523. } else {
  524. frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
  525. }
  526. }
  527. // BS.412: feed this chunk's actual duration and average audio power for
  528. // the next chunk's gain calculation. Using the real sample count avoids
  529. // the error that occurred when chunkSec was hardcoded to 0.05 — any
  530. // SetChunkDuration() call from the engine would silently miscalibrate
  531. // the ITU-R BS.412 power measurement window.
  532. if g.bs412 != nil && samples > 0 {
  533. chunkSec := float64(samples) / g.sampleRate
  534. g.bs412.UpdateChunkDuration(chunkSec)
  535. g.bs412.ProcessChunk(bs412PowerAccum / float64(samples))
  536. }
  537. return frame
  538. }
  539. func (g *Generator) WriteFile(path string, duration time.Duration) error {
  540. if path == "" {
  541. path = g.cfg.Backend.OutputPath
  542. }
  543. if path == "" {
  544. path = filepath.Join("build", "offline", "composite.iqf32")
  545. }
  546. backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
  547. Name: "offline-file",
  548. Description: "offline composite file backend",
  549. })
  550. if err != nil {
  551. return err
  552. }
  553. defer backend.Close(context.Background())
  554. if err := backend.Configure(context.Background(), output.BackendConfig{
  555. SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
  556. Channels: 2,
  557. IQLevel: float32(g.cfg.FM.OutputDrive),
  558. }); err != nil {
  559. return err
  560. }
  561. frame := g.GenerateFrame(duration)
  562. if _, err := backend.Write(context.Background(), frame); err != nil {
  563. return err
  564. }
  565. return backend.Flush(context.Background())
  566. }
  567. func (g *Generator) Summary(duration time.Duration) string {
  568. sampleRate := float64(g.cfg.FM.CompositeRateHz)
  569. if sampleRate <= 0 {
  570. sampleRate = 228000
  571. }
  572. _, info := g.sourceFor(sampleRate)
  573. preemph := "off"
  574. if g.cfg.FM.PreEmphasisTauUS > 0 {
  575. preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisTauUS)
  576. }
  577. modMode := "composite"
  578. if g.cfg.FM.FMModulationEnabled {
  579. modMode = fmt.Sprintf("FM-IQ(±%.0fHz)", g.cfg.FM.MaxDeviationHz)
  580. }
  581. 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",
  582. g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(),
  583. g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled,
  584. preemph, g.cfg.FM.LimiterEnabled, modMode, info.Kind, info.Detail)
  585. }