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.

191 satır
5.6KB

  1. package dsp
  2. import (
  3. "math"
  4. "github.com/jan/fm-rds-tx/internal/output"
  5. )
  6. // FMUpsampler converts a composite baseband signal at a low source rate into
  7. // FM-modulated IQ samples at a higher device rate, via phase-domain
  8. // interpolation.
  9. //
  10. // Architecture: accumulate FM phase at source rate (cheap, few trig ops),
  11. // then linearly interpolate the phase to device rate and emit sin/cos.
  12. // This is mathematically equivalent to running the full FMModulator at device
  13. // rate, but needs trig only at the output rate — saving all the DSP that
  14. // would otherwise run at the higher rate (stereo encode, RDS, limiter etc.).
  15. //
  16. // Cross-chunk boundary: the upsampler carries over prevPhase and srcPos
  17. // between calls. The interpolation coordinate system places prevPhase at
  18. // virtual index 0 and srcPhases[0..N-1] at indices 1..N. This guarantees
  19. // smooth phase transitions at every chunk boundary with zero discontinuity.
  20. //
  21. // Zero-allocation in steady state: all buffers are pre-allocated on first
  22. // call and reused. The returned CompositeFrame is an internal buffer —
  23. // valid only until the next Process() call.
  24. type FMUpsampler struct {
  25. srcRate float64 // composite rate (e.g. 228000)
  26. dstRate float64 // device rate (e.g. 2280000)
  27. maxDeviation float64 // peak FM deviation in Hz (e.g. 75000)
  28. step float64 // source-samples per output-sample = srcRate/dstRate
  29. // Persistent state across Process() calls
  30. phase float64 // accumulated FM phase in radians, continuous across chunks
  31. prevPhase float64 // phase at end of previous chunk (virtual index 0)
  32. srcPos float64 // fractional source position carry-over into next chunk
  33. seeded bool // true after first Process() call
  34. // Pre-allocated buffers — grown once, never shrunk
  35. srcPhases []float64
  36. outBuf []output.IQSample
  37. outFrame output.CompositeFrame
  38. }
  39. // NewFMUpsampler creates a phase-domain upsampler.
  40. //
  41. // srcRate: composite DSP rate (typ. 228000 Hz)
  42. // dstRate: device output rate (typ. 2280000 Hz)
  43. // maxDeviation: FM peak deviation (typ. 75000 Hz)
  44. func NewFMUpsampler(srcRate, dstRate, maxDeviation float64) *FMUpsampler {
  45. return &FMUpsampler{
  46. srcRate: srcRate,
  47. dstRate: dstRate,
  48. maxDeviation: maxDeviation,
  49. step: srcRate / dstRate,
  50. }
  51. }
  52. // Process takes a CompositeFrame where Samples[i].I contains the composite
  53. // baseband value (FM modulation must be OFF in the generator; Q is ignored).
  54. // Returns an FM-modulated IQ frame at dstRate.
  55. //
  56. // The returned frame is an internal buffer — valid until the next Process()
  57. // call. The caller must consume or copy the data before calling again.
  58. func (u *FMUpsampler) Process(frame *output.CompositeFrame) *output.CompositeFrame {
  59. if frame == nil || len(frame.Samples) == 0 {
  60. return frame
  61. }
  62. srcLen := len(frame.Samples)
  63. // --- Phase accumulation at source rate ---
  64. // Grow srcPhases buffer if needed
  65. if cap(u.srcPhases) < srcLen {
  66. u.srcPhases = make([]float64, srcLen)
  67. }
  68. srcPhases := u.srcPhases[:srcLen]
  69. phaseInc := 2 * math.Pi * u.maxDeviation / u.srcRate
  70. for i, s := range frame.Samples {
  71. u.phase += float64(s.I) * phaseInc
  72. srcPhases[i] = u.phase
  73. }
  74. // Phase wrapping — symmetric, shift prevPhase in lockstep
  75. if u.phase > math.Pi || u.phase < -math.Pi {
  76. offset := 2 * math.Pi * math.Floor((u.phase+math.Pi)/(2*math.Pi))
  77. u.phase -= offset
  78. for i := range srcPhases {
  79. srcPhases[i] -= offset
  80. }
  81. if u.seeded {
  82. u.prevPhase -= offset
  83. }
  84. }
  85. // Seed prevPhase on very first call
  86. if !u.seeded {
  87. // Extrapolate backwards from first two phases to get a virtual "previous"
  88. // phase, so the first chunk's boundary interpolation is smooth.
  89. if srcLen >= 2 {
  90. u.prevPhase = 2*srcPhases[0] - srcPhases[1]
  91. } else {
  92. u.prevPhase = srcPhases[0]
  93. }
  94. u.srcPos = 0
  95. u.seeded = true
  96. }
  97. // --- Interpolation coordinate system ---
  98. //
  99. // Virtual index 0 = prevPhase (end of previous chunk)
  100. // Virtual index 1 = srcPhases[0]
  101. // Virtual index 2 = srcPhases[1]
  102. // ...
  103. // Virtual index N = srcPhases[N-1]
  104. //
  105. // srcPos ranges from 0 (carry-over) to N (= srcLen).
  106. // We generate output samples while srcPos < srcLen (virtual index srcLen).
  107. // phaseAt returns the phase at a virtual index.
  108. phaseAt := func(vi int) float64 {
  109. if vi <= 0 {
  110. return u.prevPhase
  111. }
  112. if vi > srcLen {
  113. return srcPhases[srcLen-1]
  114. }
  115. return srcPhases[vi-1]
  116. }
  117. // Calculate output count: from srcPos to srcLen, stepping by u.step.
  118. // +1 for safety margin; we clamp below.
  119. maxOut := int(math.Ceil(float64(srcLen)-u.srcPos)/u.step) + 1
  120. if maxOut < 0 {
  121. maxOut = 0
  122. }
  123. // Grow output buffer if needed
  124. if cap(u.outBuf) < maxOut {
  125. u.outBuf = make([]output.IQSample, maxOut)
  126. }
  127. out := u.outBuf[:maxOut]
  128. // --- Generate output samples ---
  129. pos := u.srcPos
  130. n := 0
  131. for pos < float64(srcLen) && n < maxOut {
  132. vi := int(pos) // virtual index (integer part)
  133. frac := pos - float64(vi)
  134. pA := phaseAt(vi)
  135. pB := phaseAt(vi + 1)
  136. p := pA + frac*(pB-pA)
  137. out[n] = output.IQSample{
  138. I: float32(math.Cos(p)),
  139. Q: float32(math.Sin(p)),
  140. }
  141. n++
  142. pos += u.step
  143. }
  144. // Carry state for next chunk
  145. u.prevPhase = srcPhases[srcLen-1]
  146. u.srcPos = pos - float64(srcLen)
  147. // Package output
  148. u.outFrame.Samples = out[:n]
  149. u.outFrame.SampleRateHz = u.dstRate
  150. u.outFrame.Timestamp = frame.Timestamp
  151. u.outFrame.Sequence = frame.Sequence
  152. u.outFrame.GeneratedAt = frame.GeneratedAt
  153. return &u.outFrame
  154. }
  155. // Reset clears all accumulated state, as if freshly constructed.
  156. func (u *FMUpsampler) Reset() {
  157. u.phase = 0
  158. u.prevPhase = 0
  159. u.srcPos = 0
  160. u.seeded = false
  161. }
  162. // Stats returns internal state for diagnostics/testing.
  163. func (u *FMUpsampler) Stats() (phase, prevPhase, srcPos float64) {
  164. return u.phase, u.prevPhase, u.srcPos
  165. }