Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.

216 wiersze
6.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. type SourceInfo struct {
  20. Kind string
  21. SampleRate float64
  22. Detail string
  23. }
  24. type Generator struct {
  25. cfg cfgpkg.Config
  26. }
  27. func NewGenerator(cfg cfgpkg.Config) *Generator {
  28. return &Generator{cfg: cfg}
  29. }
  30. func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) {
  31. if g.cfg.Audio.InputPath != "" {
  32. if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil {
  33. return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath}
  34. }
  35. 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}
  36. }
  37. return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"}
  38. }
  39. func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
  40. sampleRate := float64(g.cfg.FM.CompositeRateHz)
  41. if sampleRate <= 0 {
  42. sampleRate = 228000
  43. }
  44. samples := int(duration.Seconds() * sampleRate)
  45. if samples <= 0 {
  46. samples = int(sampleRate / 10)
  47. }
  48. frame := &output.CompositeFrame{
  49. Samples: make([]output.IQSample, samples),
  50. SampleRateHz: sampleRate,
  51. Timestamp: time.Now().UTC(),
  52. Sequence: 1,
  53. }
  54. // --- DSP chain ---
  55. // Pre-emphasis filters for L and R channels
  56. var preL, preR *dsp.PreEmphasis
  57. if g.cfg.FM.PreEmphasisUS > 0 {
  58. preL = dsp.NewPreEmphasis(g.cfg.FM.PreEmphasisUS, sampleRate)
  59. preR = dsp.NewPreEmphasis(g.cfg.FM.PreEmphasisUS, sampleRate)
  60. }
  61. // Stereo encoder (includes stateful 19kHz pilot and 38kHz subcarrier)
  62. stereoEncoder := stereo.NewStereoEncoder(sampleRate)
  63. // MPX combiner
  64. combiner := mpx.NewDefaultCombiner()
  65. combiner.PilotGain = g.cfg.FM.PilotLevel / 0.1 // normalize: pilot generator has 0.1 level built-in
  66. combiner.RDSGain = g.cfg.FM.RDSInjection / 0.05 // normalize: RDS encoder has 0.05 amplitude built-in
  67. // RDS encoder (standards-grade group framing + CRC + diff encoding)
  68. rdsEnc, _ := rds.NewEncoder(rds.RDSConfig{
  69. PI: 0x1234,
  70. PS: g.cfg.RDS.PS,
  71. RT: g.cfg.RDS.RadioText,
  72. PTY: uint8(g.cfg.RDS.PTY),
  73. SampleRate: sampleRate,
  74. })
  75. // MPX limiter
  76. var limiter *dsp.MPXLimiter
  77. ceiling := g.cfg.FM.LimiterCeiling
  78. if ceiling <= 0 {
  79. ceiling = 1.0
  80. }
  81. if g.cfg.FM.LimiterEnabled {
  82. limiter = dsp.NewMPXLimiter(ceiling, 0.1, 50, sampleRate)
  83. }
  84. // FM modulator for IQ output
  85. var fmMod *dsp.FMModulator
  86. if g.cfg.FM.FMModulationEnabled {
  87. fmMod = dsp.NewFMModulator(sampleRate)
  88. if g.cfg.FM.MaxDeviationHz > 0 {
  89. fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz
  90. }
  91. }
  92. // Audio source
  93. source, _ := g.sourceFor(sampleRate)
  94. // --- Sample loop ---
  95. for i := 0; i < samples; i++ {
  96. in := source.NextFrame()
  97. // Apply gain
  98. inL := float64(in.L) * g.cfg.Audio.Gain
  99. inR := float64(in.R) * g.cfg.Audio.Gain
  100. // Pre-emphasis
  101. if preL != nil {
  102. inL = preL.Process(inL)
  103. inR = preR.Process(inR)
  104. }
  105. // Stereo encode (produces mono, DSB-SC stereo, pilot)
  106. preFrame := audio.NewFrame(audio.Sample(inL), audio.Sample(inR))
  107. comps := stereoEncoder.Encode(preFrame)
  108. // RDS
  109. rdsValue := 0.0
  110. if g.cfg.RDS.Enabled {
  111. rdsBuf := rdsEnc.Generate(1)
  112. rdsValue = rdsBuf[0]
  113. }
  114. // Combine MPX
  115. composite := combiner.Combine(comps.Mono, comps.Stereo, comps.Pilot, rdsValue)
  116. // Apply output drive
  117. composite *= g.cfg.FM.OutputDrive
  118. // Limiter
  119. if limiter != nil {
  120. composite = limiter.Process(composite)
  121. }
  122. // Hard clip safety net
  123. composite = dsp.HardClip(composite, ceiling)
  124. // Output: FM modulated IQ or raw composite
  125. if fmMod != nil {
  126. iq_i, iq_q := fmMod.Modulate(composite)
  127. frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)}
  128. } else {
  129. frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
  130. }
  131. }
  132. return frame
  133. }
  134. func (g *Generator) WriteFile(path string, duration time.Duration) error {
  135. if path == "" {
  136. path = g.cfg.Backend.OutputPath
  137. }
  138. if path == "" {
  139. path = filepath.Join("build", "offline", "composite.iqf32")
  140. }
  141. backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
  142. Name: "offline-file",
  143. Description: "offline composite file backend",
  144. })
  145. if err != nil {
  146. return err
  147. }
  148. defer backend.Close(context.Background())
  149. if err := backend.Configure(context.Background(), output.BackendConfig{
  150. SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
  151. Channels: 2,
  152. IQLevel: float32(g.cfg.FM.OutputDrive),
  153. }); err != nil {
  154. return err
  155. }
  156. frame := g.GenerateFrame(duration)
  157. if _, err := backend.Write(context.Background(), frame); err != nil {
  158. return err
  159. }
  160. if err := backend.Flush(context.Background()); err != nil {
  161. return err
  162. }
  163. return nil
  164. }
  165. func (g *Generator) Summary(duration time.Duration) string {
  166. sampleRate := float64(g.cfg.FM.CompositeRateHz)
  167. if sampleRate <= 0 {
  168. sampleRate = 228000
  169. }
  170. _, info := g.sourceFor(sampleRate)
  171. preemph := "off"
  172. if g.cfg.FM.PreEmphasisUS > 0 {
  173. preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisUS)
  174. }
  175. modMode := "composite"
  176. if g.cfg.FM.FMModulationEnabled {
  177. modMode = fmt.Sprintf("FM-IQ(±%.0fHz)", g.cfg.FM.MaxDeviationHz)
  178. }
  179. 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",
  180. g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(),
  181. g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled,
  182. preemph, g.cfg.FM.LimiterEnabled, modMode, info.Kind, info.Detail)
  183. }