Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

570 行
20KB

  1. package offline
  2. import (
  3. "context"
  4. "encoding/binary"
  5. "fmt"
  6. "log"
  7. "math"
  8. "path/filepath"
  9. "sync/atomic"
  10. "time"
  11. "github.com/jan/fm-rds-tx/internal/audio"
  12. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  13. "github.com/jan/fm-rds-tx/internal/license"
  14. "github.com/jan/fm-rds-tx/internal/watermark"
  15. "github.com/jan/fm-rds-tx/internal/dsp"
  16. "github.com/jan/fm-rds-tx/internal/mpx"
  17. "github.com/jan/fm-rds-tx/internal/output"
  18. "github.com/jan/fm-rds-tx/internal/rds"
  19. "github.com/jan/fm-rds-tx/internal/stereo"
  20. )
  21. type frameSource interface {
  22. NextFrame() audio.Frame
  23. }
  24. // LiveParams carries DSP parameters that can be hot-swapped at runtime.
  25. // Loaded once per chunk via atomic pointer — zero per-sample overhead.
  26. type LiveParams struct {
  27. OutputDrive float64
  28. StereoEnabled bool
  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. combiner mpx.DefaultCombiner
  82. fmMod *dsp.FMModulator
  83. sampleRate float64
  84. initialized bool
  85. frameSeq uint64
  86. // Broadcast-standard clip-filter-clip chain (per channel L/R):
  87. //
  88. // PreEmph → LPF₁(14kHz) → Notch(19kHz) → ×Drive
  89. // → StereoLimiter (slow AGC: raises average level)
  90. // → Clip₁ → LPF₂(14kHz) [cleanup] → Clip₂ [catches LPF overshoots]
  91. // → Stereo Encode → Composite Clip → Notch₁₉ → Notch₅₇
  92. // → + Pilot → + RDS → FM
  93. //
  94. audioLPF_L *dsp.FilterChain // 14kHz 8th-order (pre-clip)
  95. audioLPF_R *dsp.FilterChain
  96. pilotNotchL *dsp.FilterChain // 19kHz double-notch (guard band)
  97. pilotNotchR *dsp.FilterChain
  98. limiter *dsp.StereoLimiter // slow compressor (raises average, clips catch peaks)
  99. cleanupLPF_L *dsp.FilterChain // 14kHz 8th-order (post-clip cleanup)
  100. cleanupLPF_R *dsp.FilterChain
  101. mpxNotch19 *dsp.FilterChain // composite clipper protection
  102. mpxNotch57 *dsp.FilterChain
  103. bs412 *dsp.BS412Limiter // ITU-R BS.412 MPX power limiter (optional)
  104. compositeClip *dsp.CompositeClipper // ITU-R SM.1268 iterative composite clipper (optional)
  105. // Pre-allocated frame buffer — reused every GenerateFrame call.
  106. frameBuf *output.CompositeFrame
  107. bufCap int
  108. // Live-updatable DSP parameters — written by control API, read per chunk.
  109. liveParams atomic.Pointer[LiveParams]
  110. // Optional external audio source (e.g. StreamResampler for live audio).
  111. // When set, takes priority over WAV/tones in sourceFor().
  112. externalSource frameSource
  113. // Tone source reference — non-nil when a ToneSource is the active audio input.
  114. // Allows live-updating tone parameters via LiveParams each chunk.
  115. toneSource *audio.ToneSource
  116. // License: jingle injection when unlicensed.
  117. licenseState *license.State
  118. jingleFrames []license.JingleFrame
  119. // Watermark: spread-spectrum key fingerprint, always active.
  120. watermark *watermark.Embedder
  121. wmShapeLPF *dsp.FilterChain // pulse-shaping: confines PN energy to 0-6kHz
  122. }
  123. func NewGenerator(cfg cfgpkg.Config) *Generator {
  124. return &Generator{cfg: cfg}
  125. }
  126. // SetLicense configures license state (jingle) and creates the watermark
  127. // embedder. Must be called before the first GenerateFrame.
  128. func (g *Generator) SetLicense(state *license.State, key string) {
  129. g.licenseState = state
  130. g.watermark = watermark.NewEmbedder(key)
  131. // Gate threshold: -40 dBFS ≈ 0.01 linear amplitude.
  132. // Watermark is muted during silence to prevent audibility.
  133. // Composite rate will be set in init(); use 228000 as default.
  134. g.watermark.EnableGate(0.001, 228000)
  135. }
  136. // SetExternalSource sets a live audio source (e.g. StreamResampler) that
  137. // takes priority over WAV/tone sources. Must be called before the first
  138. // GenerateFrame() call; calling it after init() has no effect because
  139. // g.source is already wired to the old source.
  140. func (g *Generator) SetExternalSource(src frameSource) error {
  141. if g.initialized {
  142. return fmt.Errorf("generator: SetExternalSource called after GenerateFrame; call it before the engine starts")
  143. }
  144. g.externalSource = src
  145. return nil
  146. }
  147. // UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API,
  148. // applied at the next chunk boundary by the DSP goroutine.
  149. func (g *Generator) UpdateLive(p LiveParams) {
  150. g.liveParams.Store(&p)
  151. }
  152. // CurrentLiveParams returns the current live parameter snapshot.
  153. // Used by Engine.UpdateConfig to do read-modify-write on the params.
  154. func (g *Generator) CurrentLiveParams() LiveParams {
  155. if lp := g.liveParams.Load(); lp != nil {
  156. return *lp
  157. }
  158. return LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0}
  159. }
  160. // RDSEncoder returns the live RDS encoder, or nil if RDS is disabled.
  161. // Used by the Engine to forward text updates.
  162. func (g *Generator) RDSEncoder() *rds.Encoder {
  163. return g.rdsEnc
  164. }
  165. func (g *Generator) init() {
  166. if g.initialized {
  167. return
  168. }
  169. g.sampleRate = float64(g.cfg.FM.CompositeRateHz)
  170. if g.sampleRate <= 0 {
  171. g.sampleRate = 228000
  172. }
  173. rawSource, _ := g.sourceFor(g.sampleRate)
  174. g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain)
  175. g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate)
  176. g.combiner = mpx.DefaultCombiner{
  177. MonoGain: 1.0, StereoGain: 1.0,
  178. PilotGain: g.cfg.FM.PilotLevel, RDSGain: g.cfg.FM.RDSInjection,
  179. }
  180. if g.cfg.RDS.Enabled {
  181. piCode, _ := cfgpkg.ParsePI(g.cfg.RDS.PI)
  182. g.rdsEnc, _ = rds.NewEncoder(rds.RDSConfig{
  183. PI: piCode, PS: g.cfg.RDS.PS, RT: g.cfg.RDS.RadioText,
  184. PTY: uint8(g.cfg.RDS.PTY), SampleRate: g.sampleRate,
  185. })
  186. }
  187. ceiling := g.cfg.FM.LimiterCeiling
  188. if ceiling <= 0 { ceiling = 1.0 }
  189. // Broadcast clip-filter-clip chain:
  190. // Pre-clip: 14kHz LPF (8th-order) + 19kHz double-notch (per channel)
  191. g.audioLPF_L = dsp.NewAudioLPF(g.sampleRate)
  192. g.audioLPF_R = dsp.NewAudioLPF(g.sampleRate)
  193. g.pilotNotchL = dsp.NewPilotNotch(g.sampleRate)
  194. g.pilotNotchR = dsp.NewPilotNotch(g.sampleRate)
  195. // Slow compressor: 5ms attack / 200ms release. Brings average level UP.
  196. // The clips after it catch the peaks the limiter's attack time misses.
  197. // This is the "slow-to-fast progression" from broadcast processing:
  198. // slow limiter → fast clips.
  199. g.limiter = dsp.NewStereoLimiter(ceiling, 5, 200, g.sampleRate)
  200. // Post-clip cleanup: second 14kHz LPF pass (removes clip harmonics)
  201. g.cleanupLPF_L = dsp.NewAudioLPF(g.sampleRate)
  202. g.cleanupLPF_R = dsp.NewAudioLPF(g.sampleRate)
  203. // Composite clipper protection: double-notch at 19kHz + 57kHz
  204. g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate)
  205. // ITU-R SM.1268 iterative composite clipper (optional, replaces simple clip+notch)
  206. // Always created so it can be live-toggled via CompositeClipperEnabled.
  207. g.compositeClip = dsp.NewCompositeClipper(dsp.CompositeClipperConfig{
  208. Ceiling: ceiling,
  209. Iterations: g.cfg.FM.CompositeClipper.Iterations,
  210. SoftKnee: g.cfg.FM.CompositeClipper.SoftKnee,
  211. LookaheadMs: g.cfg.FM.CompositeClipper.LookaheadMs,
  212. SampleRate: g.sampleRate,
  213. })
  214. // BS.412 MPX power limiter (EU/CH requirement for licensed FM)
  215. if g.cfg.FM.BS412Enabled {
  216. // chunkSec is not known at init time (Engine.chunkDuration may differ).
  217. // Pass 0 here; GenerateFrame computes the actual chunk duration from
  218. // the real sample count and updates BS.412 accordingly.
  219. g.bs412 = dsp.NewBS412Limiter(
  220. g.cfg.FM.BS412ThresholdDBr,
  221. g.cfg.FM.PilotLevel,
  222. g.cfg.FM.RDSInjection,
  223. 0,
  224. )
  225. }
  226. if g.cfg.FM.FMModulationEnabled {
  227. g.fmMod = dsp.NewFMModulator(g.sampleRate)
  228. maxDev := g.cfg.FM.MaxDeviationHz
  229. if maxDev > 0 {
  230. if g.cfg.FM.MpxGain > 0 && g.cfg.FM.MpxGain != 1.0 {
  231. maxDev *= g.cfg.FM.MpxGain
  232. }
  233. g.fmMod.MaxDeviation = maxDev
  234. }
  235. }
  236. // Seed initial live params from config
  237. g.liveParams.Store(&LiveParams{
  238. OutputDrive: g.cfg.FM.OutputDrive,
  239. StereoEnabled: g.cfg.FM.StereoEnabled,
  240. PilotLevel: g.cfg.FM.PilotLevel,
  241. RDSInjection: g.cfg.FM.RDSInjection,
  242. RDSEnabled: g.cfg.RDS.Enabled,
  243. LimiterEnabled: g.cfg.FM.LimiterEnabled,
  244. LimiterCeiling: ceiling,
  245. MpxGain: g.cfg.FM.MpxGain,
  246. ToneLeftHz: g.cfg.Audio.ToneLeftHz,
  247. ToneRightHz: g.cfg.Audio.ToneRightHz,
  248. ToneAmplitude: g.cfg.Audio.ToneAmplitude,
  249. AudioGain: g.cfg.Audio.Gain,
  250. CompositeClipperEnabled: g.cfg.FM.CompositeClipper.Enabled,
  251. })
  252. if g.licenseState != nil {
  253. frames, err := license.LoadJingleFrames(license.JingleWAV(), g.sampleRate)
  254. if err != nil {
  255. log.Printf("license: jingle load failed: %v", err)
  256. } else {
  257. g.jingleFrames = frames
  258. }
  259. }
  260. // Update watermark gate ramp rate with actual composite rate (may differ
  261. // from the 228000 default used in SetLicense).
  262. if g.watermark != nil {
  263. g.watermark.EnableGate(0.001, g.sampleRate)
  264. // Pulse-shaping: PN chips are rectangular → sinc spectrum → broadband.
  265. // This LPF confines all PN energy to 0–6 kHz (ChipRate/2) so the
  266. // watermark doesn't leak into pilot (19 kHz), stereo sub (38 kHz),
  267. // or RDS (57 kHz) bands. Also prevents audible wideband noise.
  268. // 4th-order Butterworth: -24 dB/octave rolloff. At 12 kHz: -24 dB,
  269. // at 19 kHz: -38 dB, at 38 kHz: -62 dB. Clean enough.
  270. g.wmShapeLPF = dsp.NewLPF4(float64(watermark.ChipRate)/2, g.sampleRate)
  271. }
  272. g.initialized = true
  273. }
  274. func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) {
  275. if g.externalSource != nil {
  276. return g.externalSource, SourceInfo{Kind: "stream", SampleRate: sampleRate, Detail: "live audio"}
  277. }
  278. if g.cfg.Audio.InputPath != "" {
  279. if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil {
  280. return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath}
  281. }
  282. ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude)
  283. g.toneSource = ts
  284. return ts, SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath}
  285. }
  286. ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude)
  287. g.toneSource = ts
  288. return ts, SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"}
  289. }
  290. func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
  291. g.init()
  292. samples := int(duration.Seconds() * g.sampleRate)
  293. if samples <= 0 { samples = int(g.sampleRate / 10) }
  294. // Reuse buffer — grow only if needed, never shrink
  295. if g.frameBuf == nil || g.bufCap < samples {
  296. g.frameBuf = &output.CompositeFrame{
  297. Samples: make([]output.IQSample, samples),
  298. }
  299. g.bufCap = samples
  300. }
  301. frame := g.frameBuf
  302. frame.Samples = frame.Samples[:samples]
  303. frame.SampleRateHz = g.sampleRate
  304. frame.Timestamp = time.Now().UTC()
  305. g.frameSeq++
  306. frame.Sequence = g.frameSeq
  307. // Load live params once per chunk — single atomic read, zero per-sample cost
  308. lp := g.liveParams.Load()
  309. if lp == nil {
  310. // Fallback: should never happen after init(), but be safe
  311. lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0}
  312. }
  313. // Apply live tone and gain updates each chunk. GenerateFrame runs on a
  314. // single goroutine so these field writes are safe without additional locking.
  315. if g.toneSource != nil {
  316. g.toneSource.LeftFreq = lp.ToneLeftHz
  317. g.toneSource.RightFreq = lp.ToneRightHz
  318. g.toneSource.Amplitude = lp.ToneAmplitude
  319. }
  320. if g.source != nil {
  321. g.source.gain = lp.AudioGain
  322. }
  323. // Broadcast clip-filter-clip FM MPX signal chain:
  324. //
  325. // Audio L/R → PreEmphasis
  326. // → LPF₁ (14kHz, 8th-order) → 19kHz Notch (double)
  327. // → × OutputDrive → HardClip₁ (ceiling)
  328. // → LPF₂ (14kHz, 8th-order) [removes clip₁ harmonics]
  329. // → HardClip₂ (ceiling) [catches LPF₂ overshoots]
  330. // → Stereo Encode
  331. // Audio MPX (mono + stereo sub)
  332. // → HardClip₃ (ceiling) [composite deviation control]
  333. // → 19kHz Notch (double) [protect pilot band]
  334. // → 57kHz Notch (double) [protect RDS band]
  335. // + Pilot 19kHz (fixed, NEVER clipped)
  336. // + RDS 57kHz (fixed, NEVER clipped)
  337. // → FM Modulator
  338. //
  339. // Guard band depth at 19kHz: LPF₁(-21dB) + Notch(-60dB) + LPF₂(-21dB)
  340. // + CompNotch(-60dB) → broadband floor -42dB, exact 19kHz >-90dB
  341. ceiling := lp.LimiterCeiling
  342. if ceiling <= 0 { ceiling = 1.0 }
  343. // Pilot and RDS are FIXED injection levels, independent of OutputDrive.
  344. // Config values directly represent percentage of ±75kHz deviation:
  345. // pilotLevel: 0.09 = 9% = ±6.75kHz (ITU standard)
  346. // rdsInjection: 0.04 = 4% = ±3.0kHz (typical)
  347. pilotAmp := lp.PilotLevel
  348. rdsAmp := lp.RDSInjection
  349. // BS.412 MPX power limiter: uses previous chunk's measurement to set gain.
  350. // Power is measured during this chunk and fed back at the end.
  351. bs412Gain := 1.0
  352. var bs412PowerAccum float64
  353. if g.bs412 != nil {
  354. bs412Gain = g.bs412.CurrentGain()
  355. }
  356. if g.licenseState != nil {
  357. g.licenseState.Tick()
  358. }
  359. for i := 0; i < samples; i++ {
  360. in := g.source.NextFrame()
  361. // --- Stage 1: Band-limit pre-emphasized audio ---
  362. l := g.audioLPF_L.Process(float64(in.L))
  363. l = g.pilotNotchL.Process(l)
  364. r := g.audioLPF_R.Process(float64(in.R))
  365. r = g.pilotNotchR.Process(r)
  366. // Watermark gate level measurement — done BEFORE drive/clip/cleanup.
  367. // The gate needs to see the actual audio content level, not the
  368. // processed/clipped version. But injection happens later (after
  369. // composite clip) so the PN signal bypasses all audio filters.
  370. if g.watermark != nil {
  371. audioLevel := (math.Abs(l) + math.Abs(r)) / 2.0
  372. g.watermark.SetAudioLevel(audioLevel)
  373. }
  374. // --- Stage 2: Drive + Compress + Clip₁ ---
  375. l *= lp.OutputDrive
  376. r *= lp.OutputDrive
  377. if g.limiter != nil {
  378. l, r = g.limiter.Process(l, r)
  379. }
  380. l = dsp.HardClip(l, ceiling)
  381. r = dsp.HardClip(r, ceiling)
  382. // --- Stage 3: Cleanup LPF + Clip₂ (overshoot compensator) ---
  383. l = g.cleanupLPF_L.Process(l)
  384. r = g.cleanupLPF_R.Process(r)
  385. l = dsp.HardClip(l, ceiling)
  386. r = dsp.HardClip(r, ceiling)
  387. // --- Stage 4: Stereo encode ---
  388. limited := audio.NewFrame(audio.Sample(l), audio.Sample(r))
  389. comps := g.stereoEncoder.Encode(limited)
  390. // --- Stage 5: Composite clip + protection ---
  391. audioMPX := float64(comps.Mono)
  392. if lp.StereoEnabled {
  393. audioMPX += float64(comps.Stereo)
  394. }
  395. if lp.CompositeClipperEnabled && g.compositeClip != nil {
  396. // ITU-R SM.1268 iterative clipper: look-ahead + N×(clip→notch→notch) + final clip
  397. audioMPX = g.compositeClip.Process(audioMPX)
  398. } else {
  399. // Legacy single-pass: one clip, then notch, no final safety clip
  400. audioMPX = dsp.HardClip(audioMPX, ceiling)
  401. audioMPX = g.mpxNotch19.Process(audioMPX)
  402. audioMPX = g.mpxNotch57.Process(audioMPX)
  403. }
  404. // BS.412: apply gain and measure power
  405. if bs412Gain < 1.0 {
  406. audioMPX *= bs412Gain
  407. }
  408. bs412PowerAccum += audioMPX * audioMPX
  409. // --- Watermark injection: into audio composite AFTER all processing ---
  410. // Injected after the entire clip-filter-clip chain, notch filters, and
  411. // BS.412 power measurement. The PN signal at ChipRate=12kHz has bandwidth
  412. // 0-6kHz, well below the notch frequencies (19/57 kHz), so it's unaffected.
  413. // At -48 dBFS the watermark causes <0.05 dB of over-modulation, negligible.
  414. // Critically: this is AFTER HardClip, so the watermark cannot be clipped
  415. // away when audio peaks hit the ceiling (which was destroying it at the
  416. // previous L/R injection point).
  417. if g.watermark != nil {
  418. wm := g.watermark.NextSample()
  419. if g.wmShapeLPF != nil {
  420. wm = g.wmShapeLPF.Process(wm)
  421. }
  422. audioMPX += wm
  423. }
  424. // --- Stage 6: Add protected components ---
  425. composite := audioMPX
  426. if lp.StereoEnabled {
  427. composite += pilotAmp * comps.Pilot
  428. }
  429. if g.rdsEnc != nil && lp.RDSEnabled {
  430. rdsCarrier := g.stereoEncoder.RDSCarrier()
  431. rdsValue := g.rdsEnc.NextSampleWithCarrier(rdsCarrier)
  432. composite += rdsAmp * rdsValue
  433. }
  434. // Jingle: injected when unlicensed, bypasses drive/gain controls.
  435. if g.licenseState != nil && len(g.jingleFrames) > 0 {
  436. composite += g.licenseState.NextSample(g.jingleFrames)
  437. }
  438. if g.fmMod != nil {
  439. iq_i, iq_q := g.fmMod.Modulate(composite)
  440. frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)}
  441. } else {
  442. frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
  443. }
  444. }
  445. // BS.412: feed this chunk's actual duration and average audio power for
  446. // the next chunk's gain calculation. Using the real sample count avoids
  447. // the error that occurred when chunkSec was hardcoded to 0.05 — any
  448. // SetChunkDuration() call from the engine would silently miscalibrate
  449. // the ITU-R BS.412 power measurement window.
  450. if g.bs412 != nil && samples > 0 {
  451. chunkSec := float64(samples) / g.sampleRate
  452. g.bs412.UpdateChunkDuration(chunkSec)
  453. g.bs412.ProcessChunk(bs412PowerAccum / float64(samples))
  454. }
  455. // Watermark diagnostic: log state every 100 chunks (~5s) so we can verify
  456. // the embedder is actually running and producing non-zero output.
  457. if g.watermark != nil && g.frameSeq%100 == 1 {
  458. wm := g.watermark.NextSample()
  459. // Push chip state back (we consumed one sample for diagnostic)
  460. // Actually just log — the one extra chip advance is negligible.
  461. stats := g.watermark.DiagnosticState()
  462. log.Printf("watermark diag: frame=%d gateGain=%.4f chipIdx=%d bitIdx=%d symbol=%d lastSample=%.6f enabled=%t",
  463. g.frameSeq, stats.GateGain, stats.ChipIdx, stats.BitIdx, stats.Symbol, wm, stats.GateEnabled)
  464. }
  465. return frame
  466. }
  467. func (g *Generator) WriteFile(path string, duration time.Duration) error {
  468. if path == "" {
  469. path = g.cfg.Backend.OutputPath
  470. }
  471. if path == "" {
  472. path = filepath.Join("build", "offline", "composite.iqf32")
  473. }
  474. backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
  475. Name: "offline-file",
  476. Description: "offline composite file backend",
  477. })
  478. if err != nil {
  479. return err
  480. }
  481. defer backend.Close(context.Background())
  482. if err := backend.Configure(context.Background(), output.BackendConfig{
  483. SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
  484. Channels: 2,
  485. IQLevel: float32(g.cfg.FM.OutputDrive),
  486. }); err != nil {
  487. return err
  488. }
  489. frame := g.GenerateFrame(duration)
  490. if _, err := backend.Write(context.Background(), frame); err != nil {
  491. return err
  492. }
  493. return backend.Flush(context.Background())
  494. }
  495. func (g *Generator) Summary(duration time.Duration) string {
  496. sampleRate := float64(g.cfg.FM.CompositeRateHz)
  497. if sampleRate <= 0 {
  498. sampleRate = 228000
  499. }
  500. _, info := g.sourceFor(sampleRate)
  501. preemph := "off"
  502. if g.cfg.FM.PreEmphasisTauUS > 0 {
  503. preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisTauUS)
  504. }
  505. modMode := "composite"
  506. if g.cfg.FM.FMModulationEnabled {
  507. modMode = fmt.Sprintf("FM-IQ(±%.0fHz)", g.cfg.FM.MaxDeviationHz)
  508. }
  509. 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",
  510. g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(),
  511. g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled,
  512. preemph, g.cfg.FM.LimiterEnabled, modMode, info.Kind, info.Detail)
  513. }