Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

234 linhas
7.0KB

  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.
  20. // The source is expected to already output at composite rate (resampled
  21. // upstream). Pre-emphasis is applied per-sample at that rate.
  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. // Persistent DSP state across GenerateFrame calls
  54. source *PreEmphasizedSource
  55. stereoEncoder stereo.StereoEncoder
  56. rdsEnc *rds.Encoder
  57. combiner mpx.DefaultCombiner
  58. limiter *dsp.MPXLimiter
  59. fmMod *dsp.FMModulator
  60. sampleRate float64
  61. initialized bool
  62. frameSeq uint64
  63. // Pre-allocated frame buffer — reused every GenerateFrame call.
  64. // Safe because driver.Write() is blocking: it returns only after
  65. // the hardware has consumed the data. Do NOT hold references to
  66. // frame.Samples beyond Write's return.
  67. frameBuf *output.CompositeFrame
  68. bufCap int
  69. }
  70. func NewGenerator(cfg cfgpkg.Config) *Generator {
  71. return &Generator{cfg: cfg}
  72. }
  73. func (g *Generator) init() {
  74. if g.initialized {
  75. return
  76. }
  77. g.sampleRate = float64(g.cfg.FM.CompositeRateHz)
  78. if g.sampleRate <= 0 {
  79. g.sampleRate = 228000
  80. }
  81. rawSource, _ := g.sourceFor(g.sampleRate)
  82. g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain)
  83. g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate)
  84. g.combiner = mpx.DefaultCombiner{
  85. MonoGain: 1.0, StereoGain: 1.0,
  86. PilotGain: g.cfg.FM.PilotLevel, RDSGain: g.cfg.FM.RDSInjection,
  87. }
  88. if g.cfg.RDS.Enabled {
  89. piCode, _ := cfgpkg.ParsePI(g.cfg.RDS.PI)
  90. g.rdsEnc, _ = rds.NewEncoder(rds.RDSConfig{
  91. PI: piCode, PS: g.cfg.RDS.PS, RT: g.cfg.RDS.RadioText,
  92. PTY: uint8(g.cfg.RDS.PTY), SampleRate: g.sampleRate,
  93. })
  94. }
  95. ceiling := g.cfg.FM.LimiterCeiling
  96. if ceiling <= 0 { ceiling = 1.0 }
  97. if g.cfg.FM.LimiterEnabled {
  98. g.limiter = dsp.NewMPXLimiter(ceiling, 0.1, 50, g.sampleRate)
  99. }
  100. if g.cfg.FM.FMModulationEnabled {
  101. g.fmMod = dsp.NewFMModulator(g.sampleRate)
  102. if g.cfg.FM.MaxDeviationHz > 0 { g.fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz }
  103. }
  104. g.initialized = true
  105. }
  106. func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) {
  107. if g.cfg.Audio.InputPath != "" {
  108. if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil {
  109. return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath}
  110. }
  111. 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}
  112. }
  113. return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"}
  114. }
  115. func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
  116. g.init()
  117. samples := int(duration.Seconds() * g.sampleRate)
  118. if samples <= 0 { samples = int(g.sampleRate / 10) }
  119. // Reuse buffer — grow only if needed, never shrink
  120. if g.frameBuf == nil || g.bufCap < samples {
  121. g.frameBuf = &output.CompositeFrame{
  122. Samples: make([]output.IQSample, samples),
  123. }
  124. g.bufCap = samples
  125. }
  126. frame := g.frameBuf
  127. frame.Samples = frame.Samples[:samples]
  128. frame.SampleRateHz = g.sampleRate
  129. frame.Timestamp = time.Now().UTC()
  130. g.frameSeq++
  131. frame.Sequence = g.frameSeq
  132. ceiling := g.cfg.FM.LimiterCeiling
  133. if ceiling <= 0 { ceiling = 1.0 }
  134. for i := 0; i < samples; i++ {
  135. in := g.source.NextFrame()
  136. comps := g.stereoEncoder.Encode(in)
  137. if !g.cfg.FM.StereoEnabled {
  138. comps.Stereo = 0; comps.Pilot = 0
  139. }
  140. rdsValue := 0.0
  141. if g.rdsEnc != nil {
  142. rdsCarrier := g.stereoEncoder.RDSCarrier()
  143. rdsValue = g.rdsEnc.NextSampleWithCarrier(rdsCarrier)
  144. }
  145. composite := g.combiner.Combine(comps.Mono, comps.Stereo, comps.Pilot, rdsValue)
  146. composite *= g.cfg.FM.OutputDrive
  147. if g.limiter != nil {
  148. composite = g.limiter.Process(composite)
  149. composite = dsp.HardClip(composite, ceiling)
  150. }
  151. if g.fmMod != nil {
  152. iq_i, iq_q := g.fmMod.Modulate(composite)
  153. frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)}
  154. } else {
  155. frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
  156. }
  157. }
  158. return frame
  159. }
  160. func (g *Generator) WriteFile(path string, duration time.Duration) error {
  161. if path == "" {
  162. path = g.cfg.Backend.OutputPath
  163. }
  164. if path == "" {
  165. path = filepath.Join("build", "offline", "composite.iqf32")
  166. }
  167. backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
  168. Name: "offline-file",
  169. Description: "offline composite file backend",
  170. })
  171. if err != nil {
  172. return err
  173. }
  174. defer backend.Close(context.Background())
  175. if err := backend.Configure(context.Background(), output.BackendConfig{
  176. SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
  177. Channels: 2,
  178. IQLevel: float32(g.cfg.FM.OutputDrive),
  179. }); err != nil {
  180. return err
  181. }
  182. frame := g.GenerateFrame(duration)
  183. if _, err := backend.Write(context.Background(), frame); err != nil {
  184. return err
  185. }
  186. return backend.Flush(context.Background())
  187. }
  188. func (g *Generator) Summary(duration time.Duration) string {
  189. sampleRate := float64(g.cfg.FM.CompositeRateHz)
  190. if sampleRate <= 0 {
  191. sampleRate = 228000
  192. }
  193. _, info := g.sourceFor(sampleRate)
  194. preemph := "off"
  195. if g.cfg.FM.PreEmphasisTauUS > 0 {
  196. preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisTauUS)
  197. }
  198. modMode := "composite"
  199. if g.cfg.FM.FMModulationEnabled {
  200. modMode = fmt.Sprintf("FM-IQ(±%.0fHz)", g.cfg.FM.MaxDeviationHz)
  201. }
  202. 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",
  203. g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(),
  204. g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled,
  205. preemph, g.cfg.FM.LimiterEnabled, modMode, info.Kind, info.Detail)
  206. }