Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

584 Zeilen
19KB

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