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.

116 lines
3.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 Generator struct {
  17. cfg cfgpkg.Config
  18. }
  19. func NewGenerator(cfg cfgpkg.Config) *Generator {
  20. return &Generator{cfg: cfg}
  21. }
  22. func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
  23. sampleRate := float64(g.cfg.FM.CompositeRateHz)
  24. if sampleRate <= 0 {
  25. sampleRate = 228000
  26. }
  27. samples := int(duration.Seconds() * sampleRate)
  28. if samples <= 0 {
  29. samples = int(sampleRate / 10)
  30. }
  31. frame := &output.CompositeFrame{
  32. Samples: make([]output.IQSample, samples),
  33. SampleRateHz: sampleRate,
  34. Timestamp: time.Now().UTC(),
  35. Sequence: 1,
  36. }
  37. stereoEncoder := stereo.NewStereoEncoder(sampleRate)
  38. combiner := mpx.NewDefaultCombiner()
  39. combiner.PilotGain = g.cfg.FM.PilotLevel
  40. combiner.RDSGain = g.cfg.FM.RDSInjection
  41. rdsEnc, _ := rds.NewEncoder(rds.RDSConfig{
  42. PI: 0x1234,
  43. PS: g.cfg.RDS.PS,
  44. RT: g.cfg.RDS.RadioText,
  45. PTY: uint8(g.cfg.RDS.PTY),
  46. SampleRate: sampleRate,
  47. })
  48. rdsSamples := rdsEnc.Generate(samples)
  49. leftFreq := 1000.0
  50. rightFreq := 1600.0
  51. stereoCarrierFreq := 38000.0
  52. for i := 0; i < samples; i++ {
  53. t := float64(i) / sampleRate
  54. left := audio.Sample(0.4 * math.Sin(2*math.Pi*leftFreq*t))
  55. right := audio.Sample(0.4 * math.Sin(2*math.Pi*rightFreq*t+math.Pi/3))
  56. comps := stereoEncoder.Encode(audio.NewFrame(left, right))
  57. stereoDSB := comps.Stereo * math.Sin(2*math.Pi*stereoCarrierFreq*t)
  58. rdsValue := 0.0
  59. if g.cfg.RDS.Enabled && i < len(rdsSamples) {
  60. rdsValue = rdsSamples[i]
  61. }
  62. composite := combiner.Combine(comps.Mono, stereoDSB, comps.Pilot, rdsValue) * g.cfg.FM.OutputDrive
  63. frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
  64. }
  65. return frame
  66. }
  67. func (g *Generator) WriteFile(path string, duration time.Duration) error {
  68. if path == "" {
  69. path = g.cfg.Backend.OutputPath
  70. }
  71. if path == "" {
  72. path = filepath.Join("build", "offline", "composite.iqf32")
  73. }
  74. backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
  75. Name: "offline-file",
  76. Description: "offline composite file backend",
  77. })
  78. if err != nil {
  79. return err
  80. }
  81. defer backend.Close(context.Background())
  82. if err := backend.Configure(context.Background(), output.BackendConfig{
  83. SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
  84. Channels: 2,
  85. IQLevel: float32(g.cfg.FM.OutputDrive),
  86. }); err != nil {
  87. return err
  88. }
  89. frame := g.GenerateFrame(duration)
  90. if _, err := backend.Write(context.Background(), frame); err != nil {
  91. return err
  92. }
  93. if err := backend.Flush(context.Background()); err != nil {
  94. return err
  95. }
  96. return nil
  97. }
  98. func (g *Generator) Summary(duration time.Duration) string {
  99. return fmt.Sprintf("offline frame: freq=%.1fMHz sampleRate=%d duration=%s outputDrive=%.2f stereo=%t rds=%t", g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(), g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled)
  100. }