Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

241 lines
7.1KB

  1. package offline
  2. import (
  3. "context"
  4. "encoding/binary"
  5. "fmt"
  6. "path/filepath"
  7. "time"
  8. "github.com/jan/fm-rds-tx/internal/audio"
  9. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  10. "github.com/jan/fm-rds-tx/internal/dsp"
  11. "github.com/jan/fm-rds-tx/internal/mpx"
  12. "github.com/jan/fm-rds-tx/internal/output"
  13. "github.com/jan/fm-rds-tx/internal/rds"
  14. "github.com/jan/fm-rds-tx/internal/stereo"
  15. )
  16. type frameSource interface {
  17. NextFrame() audio.Frame
  18. }
  19. // PreEmphasizedSource wraps an audio source and applies pre-emphasis at the
  20. // audio input rate, before upsampling to composite rate. This is more
  21. // efficient than filtering at composite rate and is the correct signal path.
  22. type PreEmphasizedSource struct {
  23. src frameSource
  24. preL *dsp.PreEmphasis
  25. preR *dsp.PreEmphasis
  26. gain float64
  27. }
  28. func NewPreEmphasizedSource(src frameSource, tauUS, sampleRate, gain float64) *PreEmphasizedSource {
  29. p := &PreEmphasizedSource{src: src, gain: gain}
  30. if tauUS > 0 {
  31. p.preL = dsp.NewPreEmphasis(tauUS, sampleRate)
  32. p.preR = dsp.NewPreEmphasis(tauUS, sampleRate)
  33. }
  34. return p
  35. }
  36. func (p *PreEmphasizedSource) NextFrame() audio.Frame {
  37. f := p.src.NextFrame()
  38. l := float64(f.L) * p.gain
  39. r := float64(f.R) * p.gain
  40. if p.preL != nil {
  41. l = p.preL.Process(l)
  42. r = p.preR.Process(r)
  43. }
  44. return audio.NewFrame(audio.Sample(l), audio.Sample(r))
  45. }
  46. type SourceInfo struct {
  47. Kind string
  48. SampleRate float64
  49. Detail string
  50. }
  51. type Generator struct {
  52. cfg cfgpkg.Config
  53. }
  54. func NewGenerator(cfg cfgpkg.Config) *Generator {
  55. return &Generator{cfg: cfg}
  56. }
  57. func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) {
  58. if g.cfg.Audio.InputPath != "" {
  59. if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil {
  60. return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath}
  61. }
  62. return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath}
  63. }
  64. return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"}
  65. }
  66. func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
  67. sampleRate := float64(g.cfg.FM.CompositeRateHz)
  68. if sampleRate <= 0 {
  69. sampleRate = 228000
  70. }
  71. samples := int(duration.Seconds() * sampleRate)
  72. if samples <= 0 {
  73. samples = int(sampleRate / 10)
  74. }
  75. frame := &output.CompositeFrame{
  76. Samples: make([]output.IQSample, samples),
  77. SampleRateHz: sampleRate,
  78. Timestamp: time.Now().UTC(),
  79. Sequence: 1,
  80. }
  81. // Audio source with pre-emphasis applied at audio rate (before stereo encoding)
  82. rawSource, _ := g.sourceFor(sampleRate)
  83. source := NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, sampleRate, g.cfg.Audio.Gain)
  84. // Stereo encoder (unity-normalized pilot + subcarrier)
  85. stereoEncoder := stereo.NewStereoEncoder(sampleRate)
  86. // MPX combiner — config values are direct linear injection levels, no magic numbers
  87. combiner := mpx.DefaultCombiner{
  88. MonoGain: 1.0,
  89. StereoGain: 1.0,
  90. PilotGain: g.cfg.FM.PilotLevel,
  91. RDSGain: g.cfg.FM.RDSInjection,
  92. }
  93. // RDS encoder (unity-normalized output)
  94. piCode, _ := cfgpkg.ParsePI(g.cfg.RDS.PI) // already validated
  95. rdsEnc, _ := rds.NewEncoder(rds.RDSConfig{
  96. PI: piCode,
  97. PS: g.cfg.RDS.PS,
  98. RT: g.cfg.RDS.RadioText,
  99. PTY: uint8(g.cfg.RDS.PTY),
  100. SampleRate: 228000, // RDS always at 228 kHz internally
  101. })
  102. // Limiter
  103. var limiter *dsp.MPXLimiter
  104. ceiling := g.cfg.FM.LimiterCeiling
  105. if ceiling <= 0 {
  106. ceiling = 1.0
  107. }
  108. if g.cfg.FM.LimiterEnabled {
  109. limiter = dsp.NewMPXLimiter(ceiling, 0.1, 50, sampleRate)
  110. }
  111. // FM modulator
  112. var fmMod *dsp.FMModulator
  113. if g.cfg.FM.FMModulationEnabled {
  114. fmMod = dsp.NewFMModulator(sampleRate)
  115. if g.cfg.FM.MaxDeviationHz > 0 {
  116. fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz
  117. }
  118. }
  119. // RDS runs at 228 kHz internally (4×57 kHz for exact carrier).
  120. // We resample to composite rate using a fractional accumulator.
  121. var rdsPhaseAcc float64 // fractional position in 228k stream
  122. var rdsRatio float64 // compositeRate / 228000
  123. var rdsPrev, rdsCur float64
  124. if g.cfg.RDS.Enabled {
  125. rdsRatio = sampleRate / 228000.0
  126. // Pre-fetch first sample
  127. rdsCur = rdsEnc.NextSample228k()
  128. }
  129. // --- Sample loop (zero-allocation hot path) ---
  130. for i := 0; i < samples; i++ {
  131. in := source.NextFrame()
  132. comps := stereoEncoder.Encode(in)
  133. if !g.cfg.FM.StereoEnabled {
  134. comps.Stereo = 0
  135. comps.Pilot = 0
  136. }
  137. rdsValue := 0.0
  138. if g.cfg.RDS.Enabled {
  139. // Advance through the 228k RDS stream at composite rate
  140. rdsPhaseAcc += 1.0 / rdsRatio // step in 228k domain
  141. for rdsPhaseAcc >= 1.0 {
  142. rdsPhaseAcc -= 1.0
  143. rdsPrev = rdsCur
  144. rdsCur = rdsEnc.NextSample228k()
  145. }
  146. // Linear interpolation between 228k samples
  147. rdsValue = rdsPrev*(1.0-rdsPhaseAcc) + rdsCur*rdsPhaseAcc
  148. }
  149. composite := combiner.Combine(comps.Mono, comps.Stereo, comps.Pilot, rdsValue)
  150. composite *= g.cfg.FM.OutputDrive
  151. if limiter != nil {
  152. composite = limiter.Process(composite)
  153. composite = dsp.HardClip(composite, ceiling) // safety net only with limiter
  154. }
  155. if fmMod != nil {
  156. iq_i, iq_q := fmMod.Modulate(composite)
  157. frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)}
  158. } else {
  159. frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
  160. }
  161. }
  162. return frame
  163. }
  164. func (g *Generator) WriteFile(path string, duration time.Duration) error {
  165. if path == "" {
  166. path = g.cfg.Backend.OutputPath
  167. }
  168. if path == "" {
  169. path = filepath.Join("build", "offline", "composite.iqf32")
  170. }
  171. backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
  172. Name: "offline-file",
  173. Description: "offline composite file backend",
  174. })
  175. if err != nil {
  176. return err
  177. }
  178. defer backend.Close(context.Background())
  179. if err := backend.Configure(context.Background(), output.BackendConfig{
  180. SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
  181. Channels: 2,
  182. IQLevel: float32(g.cfg.FM.OutputDrive),
  183. }); err != nil {
  184. return err
  185. }
  186. frame := g.GenerateFrame(duration)
  187. if _, err := backend.Write(context.Background(), frame); err != nil {
  188. return err
  189. }
  190. return backend.Flush(context.Background())
  191. }
  192. func (g *Generator) Summary(duration time.Duration) string {
  193. sampleRate := float64(g.cfg.FM.CompositeRateHz)
  194. if sampleRate <= 0 {
  195. sampleRate = 228000
  196. }
  197. _, info := g.sourceFor(sampleRate)
  198. preemph := "off"
  199. if g.cfg.FM.PreEmphasisTauUS > 0 {
  200. preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisTauUS)
  201. }
  202. modMode := "composite"
  203. if g.cfg.FM.FMModulationEnabled {
  204. modMode = fmt.Sprintf("FM-IQ(±%.0fHz)", g.cfg.FM.MaxDeviationHz)
  205. }
  206. 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",
  207. g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(),
  208. g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled,
  209. preemph, g.cfg.FM.LimiterEnabled, modMode, info.Kind, info.Detail)
  210. }