Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.

249 wiersze
7.3KB

  1. // Package license handles fm-rds-tx key validation and jingle injection.
  2. //
  3. // Key format: FMRTX-<BASE32(HMAC-SHA256(payload, secret)[:10])>
  4. // payload: "free" for gratis keys, email for paid keys.
  5. //
  6. // Without a valid key the jingle WAV is mixed into the composite output
  7. // every JingleIntervalMinutes minutes. With a valid key the jingle is silent.
  8. package license
  9. import (
  10. "crypto/hmac"
  11. "crypto/sha256"
  12. "encoding/base32"
  13. "encoding/binary"
  14. "fmt"
  15. "math"
  16. "strings"
  17. "time"
  18. )
  19. // hmacSecret is the shared secret used to sign and verify keys.
  20. // Change this value in your private fork — keys signed with the old
  21. // secret stop working, forcing a re-issue. Never commit the real secret.
  22. const hmacSecret = "Q7m!xP2#rL9$vN4@tK8%hD3&yF6*zC1+uB5"
  23. // JingleIntervalMinutes is how often the jingle fires when unlicensed.
  24. const JingleIntervalMinutes = 20
  25. // jingleSampleRate and jingleChannels must match the embedded jingle.wav.
  26. // The mixer resamples on-the-fly if the composite rate differs.
  27. const (
  28. jingleWAVRate = 44100
  29. jingleWAVChannels = 2
  30. )
  31. // State holds the runtime license + jingle state for a running generator.
  32. type State struct {
  33. licensed bool
  34. active bool // jingle currently playing
  35. pos int // playback position in jingleFrames
  36. nextFire time.Time
  37. jingleLevel float64 // composite injection amplitude (0..1)
  38. }
  39. // NewState validates the provided key and returns a ready State.
  40. // If key is empty or invalid, the jingle fires every JingleIntervalMinutes.
  41. func NewState(key string) *State {
  42. s := &State{
  43. licensed: ValidateKey(key),
  44. jingleLevel: 0.25, // 25% composite injection — loud but not clipping
  45. }
  46. if !s.licensed {
  47. s.nextFire = time.Now().Add(time.Duration(JingleIntervalMinutes) * time.Minute)
  48. }
  49. return s
  50. }
  51. // Licensed reports whether a valid key was supplied.
  52. func (s *State) Licensed() bool { return s.licensed }
  53. // Active reports whether the jingle is currently playing.
  54. func (s *State) Active() bool { return s.active }
  55. // NextSample returns the jingle contribution for one composite sample.
  56. // Call once per sample from the DSP loop — it is not thread-safe and must
  57. // be called from the single GenerateFrame goroutine only.
  58. // Returns 0 when licensed, not active, or no jingle loaded.
  59. func (s *State) NextSample(frames []JingleFrame) float64 {
  60. if s.licensed || len(frames) == 0 {
  61. return 0
  62. }
  63. if !s.active {
  64. return 0
  65. }
  66. f := frames[s.pos%len(frames)]
  67. jingleMono := float64(f.L+f.R) / 2.0
  68. s.pos++
  69. if s.pos >= len(frames) {
  70. s.active = false
  71. s.pos = 0
  72. s.nextFire = time.Now().Add(time.Duration(JingleIntervalMinutes) * time.Minute)
  73. }
  74. return s.jingleLevel * jingleMono
  75. }
  76. // Tick checks whether a new jingle playback should start.
  77. // Call once per chunk (not per sample) from GenerateFrame.
  78. // Safe to call from the single DSP goroutine — no locking needed after init.
  79. func (s *State) Tick() {
  80. if s.licensed || s.active {
  81. return
  82. }
  83. if time.Now().After(s.nextFire) {
  84. s.active = true
  85. s.pos = 0
  86. }
  87. }
  88. // MixComposite is kept for compatibility; prefer Tick()+NextSample() per sample.
  89. func (s *State) MixComposite(composite float64, frames []JingleFrame, _ time.Time) float64 {
  90. return composite + s.NextSample(frames)
  91. }
  92. // jingleFrame is a normalised stereo frame from the embedded WAV.
  93. type JingleFrame struct{ L, R float32 }
  94. // LoadJingleFrames decodes the embedded WAV bytes into normalised frames
  95. // and resamples them from jingleWAVRate to targetRate using linear interpolation.
  96. func LoadJingleFrames(wavBytes []byte, targetRate float64) ([]JingleFrame, error) {
  97. raw, err := decodeWAV(wavBytes)
  98. if err != nil {
  99. return nil, fmt.Errorf("license: decode jingle WAV: %w", err)
  100. }
  101. if targetRate <= 0 || math.Abs(targetRate-float64(jingleWAVRate)) < 1 {
  102. return raw, nil
  103. }
  104. // Linear resample to composite rate.
  105. ratio := float64(jingleWAVRate) / targetRate
  106. dstLen := int(float64(len(raw)) / ratio)
  107. out := make([]JingleFrame, dstLen)
  108. for i := range out {
  109. pos := float64(i) * ratio
  110. idx := int(pos)
  111. frac := float32(pos - float64(idx))
  112. if idx+1 < len(raw) {
  113. a, b := raw[idx], raw[idx+1]
  114. out[i] = JingleFrame{
  115. L: a.L*(1-frac) + b.L*frac,
  116. R: a.R*(1-frac) + b.R*frac,
  117. }
  118. } else if idx < len(raw) {
  119. out[i] = raw[idx]
  120. }
  121. }
  122. return out, nil
  123. }
  124. // decodeWAV parses a minimal PCM WAV (16-bit stereo) into normalised frames.
  125. func decodeWAV(data []byte) ([]JingleFrame, error) {
  126. if len(data) < 44 {
  127. return nil, fmt.Errorf("WAV too short")
  128. }
  129. if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
  130. return nil, fmt.Errorf("not a RIFF/WAVE file")
  131. }
  132. // Find fmt and data chunks.
  133. var (
  134. channels uint16
  135. bitsPerSample uint16
  136. dataStart int
  137. dataLen int
  138. )
  139. i := 12
  140. for i+8 <= len(data) {
  141. id := string(data[i : i+4])
  142. chunkSize := int(binary.LittleEndian.Uint32(data[i+4 : i+8]))
  143. i += 8
  144. switch id {
  145. case "fmt ":
  146. if chunkSize < 16 || i+16 > len(data) {
  147. return nil, fmt.Errorf("fmt chunk too small")
  148. }
  149. if binary.LittleEndian.Uint16(data[i:i+2]) != 1 {
  150. return nil, fmt.Errorf("only PCM WAV supported")
  151. }
  152. channels = binary.LittleEndian.Uint16(data[i+2 : i+4])
  153. bitsPerSample = binary.LittleEndian.Uint16(data[i+14 : i+16])
  154. case "data":
  155. dataStart = i
  156. dataLen = chunkSize
  157. }
  158. i += chunkSize
  159. if chunkSize%2 != 0 {
  160. i++
  161. }
  162. if dataStart > 0 && channels > 0 {
  163. break
  164. }
  165. }
  166. if dataStart == 0 || channels == 0 || bitsPerSample != 16 {
  167. return nil, fmt.Errorf("unsupported WAV format (need 16-bit PCM, got bits=%d ch=%d)", bitsPerSample, channels)
  168. }
  169. if dataStart+dataLen > len(data) {
  170. dataLen = len(data) - dataStart
  171. }
  172. step := int(channels) * 2
  173. frames := make([]JingleFrame, 0, dataLen/step)
  174. for j := dataStart; j+step <= dataStart+dataLen; j += step {
  175. l := float32(int16(binary.LittleEndian.Uint16(data[j:j+2]))) / 32768.0
  176. r := l
  177. if channels >= 2 {
  178. r = float32(int16(binary.LittleEndian.Uint16(data[j+2:j+4]))) / 32768.0
  179. }
  180. frames = append(frames, JingleFrame{L: l, R: r})
  181. }
  182. return frames, nil
  183. }
  184. // --- Key validation ---
  185. // ValidateKey returns true if key is a valid fm-rds-tx license key.
  186. func ValidateKey(key string) bool {
  187. key = strings.TrimSpace(key)
  188. if !strings.HasPrefix(key, "FMRTX-") {
  189. return false
  190. }
  191. body := strings.TrimPrefix(key, "FMRTX-")
  192. // Decode the base32 payload.
  193. // Format: BASE32(payload_len_byte || payload_bytes || mac_10_bytes)
  194. padded := body
  195. if pad := len(padded) % 8; pad != 0 {
  196. padded += strings.Repeat("=", 8-pad)
  197. }
  198. raw, err := base32.StdEncoding.DecodeString(strings.ToUpper(padded))
  199. if err != nil || len(raw) < 11 {
  200. return false
  201. }
  202. payloadLen := int(raw[0])
  203. if payloadLen+1+10 > len(raw) {
  204. return false
  205. }
  206. payload := raw[1 : 1+payloadLen]
  207. mac := raw[1+payloadLen : 1+payloadLen+10]
  208. expected := computeMAC(payload)
  209. return hmac.Equal(mac, expected[:10])
  210. }
  211. // GenerateKey generates a signed license key for the given payload string.
  212. // Call this from cmd/keygen — not from the main binary.
  213. func GenerateKey(payload string) string {
  214. p := []byte(payload)
  215. raw := make([]byte, 1+len(p)+10)
  216. raw[0] = byte(len(p))
  217. copy(raw[1:], p)
  218. mac := computeMAC(p)
  219. copy(raw[1+len(p):], mac[:10])
  220. encoded := base32.StdEncoding.EncodeToString(raw)
  221. encoded = strings.TrimRight(encoded, "=")
  222. return "FMRTX-" + encoded
  223. }
  224. func computeMAC(payload []byte) []byte {
  225. h := hmac.New(sha256.New, []byte(hmacSecret))
  226. h.Write(payload)
  227. return h.Sum(nil)
  228. }