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.

138 lines
4.5KB

  1. package offline
  2. import (
  3. "context"
  4. "encoding/binary"
  5. "fmt"
  6. "math"
  7. "path/filepath"
  8. "time"
  9. "github.com/jan/fm-rds-tx/internal/audio"
  10. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  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. stereoEncoder := stereo.NewStereoEncoder(sampleRate)
  55. combiner := mpx.NewDefaultCombiner()
  56. combiner.PilotGain = g.cfg.FM.PilotLevel
  57. combiner.RDSGain = g.cfg.FM.RDSInjection
  58. rdsEnc, _ := rds.NewEncoder(rds.RDSConfig{
  59. PI: 0x1234,
  60. PS: g.cfg.RDS.PS,
  61. RT: g.cfg.RDS.RadioText,
  62. PTY: uint8(g.cfg.RDS.PTY),
  63. SampleRate: sampleRate,
  64. })
  65. rdsSamples := rdsEnc.Generate(samples)
  66. source, _ := g.sourceFor(sampleRate)
  67. for i := 0; i < samples; i++ {
  68. t := float64(i) / sampleRate
  69. in := source.NextFrame()
  70. comps := stereoEncoder.Encode(in)
  71. stereoDSB := comps.Stereo * math.Sin(2*math.Pi*38000.0*t)
  72. rdsValue := 0.0
  73. if g.cfg.RDS.Enabled && i < len(rdsSamples) {
  74. rdsValue = rdsSamples[i]
  75. }
  76. composite := combiner.Combine(comps.Mono, stereoDSB, comps.Pilot, rdsValue) * g.cfg.FM.OutputDrive
  77. frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
  78. }
  79. return frame
  80. }
  81. func (g *Generator) WriteFile(path string, duration time.Duration) error {
  82. if path == "" {
  83. path = g.cfg.Backend.OutputPath
  84. }
  85. if path == "" {
  86. path = filepath.Join("build", "offline", "composite.iqf32")
  87. }
  88. backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
  89. Name: "offline-file",
  90. Description: "offline composite file backend",
  91. })
  92. if err != nil {
  93. return err
  94. }
  95. defer backend.Close(context.Background())
  96. if err := backend.Configure(context.Background(), output.BackendConfig{
  97. SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
  98. Channels: 2,
  99. IQLevel: float32(g.cfg.FM.OutputDrive),
  100. }); err != nil {
  101. return err
  102. }
  103. frame := g.GenerateFrame(duration)
  104. if _, err := backend.Write(context.Background(), frame); err != nil {
  105. return err
  106. }
  107. if err := backend.Flush(context.Background()); err != nil {
  108. return err
  109. }
  110. return nil
  111. }
  112. func (g *Generator) Summary(duration time.Duration) string {
  113. sampleRate := float64(g.cfg.FM.CompositeRateHz)
  114. if sampleRate <= 0 {
  115. sampleRate = 228000
  116. }
  117. _, info := g.sourceFor(sampleRate)
  118. return fmt.Sprintf("offline frame: freq=%.1fMHz sampleRate=%d duration=%s outputDrive=%.2f stereo=%t rds=%t source=%s detail=%s", g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(), g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled, info.Kind, info.Detail)
  119. }