Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

312 líneas
9.7KB

  1. // Package dsp — CompositeClipper implements a broadcast-grade iterative
  2. // composite MPX clipper with optional soft-knee clipping and look-ahead
  3. // peak limiting.
  4. //
  5. // # Signal flow
  6. //
  7. // audioMPX (mono + stereo sub, no pilot/RDS)
  8. // → Look-ahead pre-limiter (optional, reduces peak:average before clipping)
  9. // → N iterations of: Clip → Notch19kHz → Notch57kHz
  10. // → Final hard-clip safety net
  11. // → output
  12. //
  13. // Each iteration reduces spectral splatter into the pilot (19 kHz) and RDS
  14. // (57 kHz) guard bands caused by the previous clip, while the subsequent
  15. // clip catches the filter overshoot. After 2-3 iterations the signal
  16. // converges: peaks stay below ceiling AND protected bands are clean.
  17. //
  18. // The look-ahead limiter uses a delay line so the envelope detector sees
  19. // peaks before they reach the clipper. This allows smooth gain reduction
  20. // instead of hard clipping, producing fewer harmonics per iteration.
  21. //
  22. // # Comparable to
  23. //
  24. // Omnia.11 composite processing, Orban Optimod 8700 composite clipper,
  25. // and similar ITU-R SM.1268-compliant broadcast processors.
  26. package dsp
  27. import "math"
  28. // CompositeClipperConfig holds the parameters for NewCompositeClipper.
  29. type CompositeClipperConfig struct {
  30. // Ceiling is the peak output level. Typically 1.0 (= 100% modulation
  31. // for the audio portion). Pilot and RDS are added externally after.
  32. Ceiling float64
  33. // Iterations is the number of clip-filter-clip passes (1-5).
  34. // More iterations = cleaner guard bands, slightly more latency from
  35. // filter group delay. 3 is a good default; 1 is conservative, 5 is
  36. // aggressive (Omnia "brick wall" territory).
  37. Iterations int
  38. // SoftKnee sets the width of the soft-clip transition zone below
  39. // ceiling, in linear amplitude. 0 = hard clip, 0.15 = moderate,
  40. // 0.3 = gentle. The soft clipper uses tanh() compression above
  41. // (ceiling - softKnee), producing fewer harmonics per iteration
  42. // at the cost of slightly lower peak density.
  43. SoftKnee float64
  44. // LookaheadMs sets the look-ahead delay in milliseconds. 0 = disabled.
  45. // Typical values: 0.5-2.0 ms. The limiter pre-reduces peaks before
  46. // they hit the clipper, so the clipper does less work and generates
  47. // fewer harmonics. Introduces LookaheadMs of audio latency.
  48. LookaheadMs float64
  49. // SampleRate must match the composite DSP rate (typically 228000 Hz).
  50. SampleRate float64
  51. }
  52. // CompositeClipper is a stateful per-sample processor. Not thread-safe —
  53. // call Process from the single DSP goroutine only.
  54. type CompositeClipper struct {
  55. ceiling float64
  56. softKnee float64
  57. iterations int
  58. // Per-iteration filter banks — each iteration needs independent state
  59. // because it processes a different signal (output of previous clip).
  60. notch19 []*FilterChain
  61. notch57 []*FilterChain
  62. // Look-ahead limiter state
  63. laEnabled bool
  64. laDelay []float64 // circular delay buffer
  65. laLen int // delay length in samples
  66. laWrite int // write position
  67. laEnv float64 // peak envelope (tracks input peaks)
  68. laGain float64 // current gain multiplier (applied to delayed output)
  69. laAttack float64 // envelope/gain attack coefficient
  70. laRelease float64 // envelope/gain release coefficient
  71. }
  72. // NewCompositeClipper creates a ready-to-use composite clipper.
  73. // Returns nil if cfg is invalid or sample rate is zero.
  74. func NewCompositeClipper(cfg CompositeClipperConfig) *CompositeClipper {
  75. if cfg.SampleRate <= 0 {
  76. return nil
  77. }
  78. if cfg.Ceiling <= 0 {
  79. cfg.Ceiling = 1.0
  80. }
  81. if cfg.Iterations < 1 {
  82. cfg.Iterations = 1
  83. }
  84. if cfg.Iterations > 5 {
  85. cfg.Iterations = 5
  86. }
  87. if cfg.SoftKnee < 0 {
  88. cfg.SoftKnee = 0
  89. }
  90. c := &CompositeClipper{
  91. ceiling: cfg.Ceiling,
  92. softKnee: cfg.SoftKnee,
  93. iterations: cfg.Iterations,
  94. notch19: make([]*FilterChain, cfg.Iterations),
  95. notch57: make([]*FilterChain, cfg.Iterations),
  96. laGain: 1.0,
  97. }
  98. // Build per-iteration filter pairs.
  99. // Double-cascade notch at each frequency for deep rejection.
  100. // Q=15 at 19 kHz → narrow (~1.3 kHz), preserves stereo sub.
  101. // Q=10 at 57 kHz → slightly wider (~5.7 kHz), covers RDS bandwidth.
  102. for i := 0; i < cfg.Iterations; i++ {
  103. c.notch19[i] = &FilterChain{
  104. Stages: []Biquad{
  105. *NewNotch(19000, cfg.SampleRate, 15),
  106. *NewNotch(19000, cfg.SampleRate, 15),
  107. },
  108. }
  109. c.notch57[i] = &FilterChain{
  110. Stages: []Biquad{
  111. *NewNotch(57000, cfg.SampleRate, 10),
  112. *NewNotch(57000, cfg.SampleRate, 10),
  113. },
  114. }
  115. }
  116. // Look-ahead limiter
  117. if cfg.LookaheadMs > 0 {
  118. c.laEnabled = true
  119. c.laLen = int(math.Round(cfg.LookaheadMs * cfg.SampleRate / 1000))
  120. if c.laLen < 1 {
  121. c.laLen = 1
  122. }
  123. c.laDelay = make([]float64, c.laLen)
  124. // Attack: ramp down over half the look-ahead window so gain is
  125. // fully reduced by the time the peak exits the delay line.
  126. // Using half the window gives ~86% reduction at exit (2 time constants).
  127. attackSamples := float64(c.laLen) / 2
  128. if attackSamples < 1 {
  129. attackSamples = 1
  130. }
  131. c.laAttack = 1.0 - math.Exp(-1.0/attackSamples)
  132. // Release: 20ms — fast enough to recover between transients,
  133. // slow enough to avoid gain pumping on dense material.
  134. releaseSamples := 0.020 * cfg.SampleRate
  135. if releaseSamples < 1 {
  136. releaseSamples = 1
  137. }
  138. c.laRelease = 1.0 - math.Exp(-1.0/releaseSamples)
  139. }
  140. return c
  141. }
  142. // Process runs one composite sample through the full chain.
  143. //
  144. // Input: audio-only MPX (mono + stereo sub, no pilot, no RDS).
  145. // Output: peak-limited, spectrally clean MPX. Never exceeds ceiling.
  146. //
  147. // The caller adds pilot and RDS to the output AFTER this call.
  148. func (c *CompositeClipper) Process(audioMPX float64) float64 {
  149. x := audioMPX
  150. // --- Stage 1: Look-ahead pre-limiting ---
  151. // Reduces peaks smoothly before the clipper sees them.
  152. // Fewer hard-clip events → fewer harmonics → cleaner output.
  153. if c.laEnabled {
  154. x = c.processLookahead(x)
  155. }
  156. // --- Stage 2: Iterative clip → notch → notch ---
  157. // Each pass:
  158. // - Clip catches peaks (from input or previous filter overshoot)
  159. // - Notch19 removes energy at pilot frequency
  160. // - Notch57 removes energy at RDS frequency
  161. // - Filter ringing creates small overshoots → next pass catches them
  162. // After N iterations the overshoot converges to near-zero.
  163. for i := 0; i < c.iterations; i++ {
  164. x = c.clip(x)
  165. x = c.notch19[i].Process(x)
  166. x = c.notch57[i].Process(x)
  167. }
  168. // --- Stage 3: Final safety hard-clip ---
  169. // Catches residual overshoot from the last iteration's notch filters.
  170. // Even with 3+ iterations there can be sub-0.1 dB overshoot from the
  171. // final notch; this guarantees the output never exceeds ceiling.
  172. x = HardClip(x, c.ceiling)
  173. return x
  174. }
  175. // processLookahead applies look-ahead peak limiting.
  176. //
  177. // The input enters a delay line. The envelope detector runs on the INPUT
  178. // (not the delayed output), so it sees peaks LookaheadMs before they exit
  179. // the delay. Gain is reduced smoothly over the look-ahead window, so when
  180. // the peak arrives at the output, the gain is already low enough.
  181. func (c *CompositeClipper) processLookahead(in float64) float64 {
  182. // Read delayed sample (from laLen samples ago)
  183. out := c.laDelay[c.laWrite]
  184. // Write new input into delay line
  185. c.laDelay[c.laWrite] = in
  186. c.laWrite++
  187. if c.laWrite >= c.laLen {
  188. c.laWrite = 0
  189. }
  190. // Envelope follower on the raw input — sees peaks before they exit
  191. absIn := math.Abs(in)
  192. if absIn > c.laEnv {
  193. // Attack: fast rise toward the peak
  194. c.laEnv += c.laAttack * (absIn - c.laEnv)
  195. } else {
  196. // Release: slow decay back down
  197. c.laEnv += c.laRelease * (absIn - c.laEnv)
  198. }
  199. // Target gain to keep output at ceiling
  200. targetGain := 1.0
  201. if c.laEnv > c.ceiling {
  202. targetGain = c.ceiling / c.laEnv
  203. }
  204. // Smooth gain transitions (same attack/release as envelope)
  205. if targetGain < c.laGain {
  206. c.laGain += c.laAttack * (targetGain - c.laGain)
  207. } else {
  208. c.laGain += c.laRelease * (targetGain - c.laGain)
  209. }
  210. return out * c.laGain
  211. }
  212. // clip applies either soft-knee or hard clipping depending on config.
  213. func (c *CompositeClipper) clip(x float64) float64 {
  214. if c.softKnee <= 0 {
  215. return HardClip(x, c.ceiling)
  216. }
  217. return SoftClip(x, c.ceiling, c.softKnee)
  218. }
  219. // SoftClip applies tanh-based soft clipping with a configurable knee.
  220. //
  221. // Below (ceiling - knee): linear, no distortion.
  222. // Above (ceiling - knee): tanh compression, asymptotically approaching ceiling.
  223. // The transition is C1-continuous (slope = 1.0 at the knee boundary).
  224. //
  225. // This generates significantly fewer harmonics than hard clipping, which
  226. // means the notch filters have less work to do and produce less overshoot.
  227. func SoftClip(x, ceiling, knee float64) float64 {
  228. if knee <= 0 {
  229. return HardClip(x, ceiling)
  230. }
  231. threshold := ceiling - knee
  232. if threshold < 0 {
  233. threshold = 0
  234. }
  235. ax := math.Abs(x)
  236. if ax <= threshold {
  237. return x // linear region — no distortion
  238. }
  239. s := 1.0
  240. if x < 0 {
  241. s = -1.0
  242. }
  243. // tanh compression: excess above threshold is compressed toward knee.
  244. // At excess=0: output = threshold, slope = 1.0 (C1 continuous).
  245. // At excess→∞: output → threshold + knee = ceiling.
  246. excess := ax - threshold
  247. compressed := threshold + knee*math.Tanh(excess/knee)
  248. return s * compressed
  249. }
  250. // Reset clears all filter and look-ahead state, as if freshly constructed.
  251. func (c *CompositeClipper) Reset() {
  252. for i := range c.notch19 {
  253. c.notch19[i].Reset()
  254. c.notch57[i].Reset()
  255. }
  256. if c.laEnabled {
  257. for i := range c.laDelay {
  258. c.laDelay[i] = 0
  259. }
  260. c.laWrite = 0
  261. c.laEnv = 0
  262. c.laGain = 1.0
  263. }
  264. }
  265. // Stats returns diagnostic values for monitoring/logging.
  266. func (c *CompositeClipper) Stats() CompositeClipperStats {
  267. return CompositeClipperStats{
  268. LookaheadGain: c.laGain,
  269. Envelope: c.laEnv,
  270. }
  271. }
  272. // CompositeClipperStats exposes internal state for diagnostics.
  273. type CompositeClipperStats struct {
  274. LookaheadGain float64 `json:"lookaheadGain"`
  275. Envelope float64 `json:"envelope"`
  276. }