Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

772 lines
25KB

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