Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.

236 satır
6.4KB

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