Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

691 lignes
23KB

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