Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

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