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.

246 line
7.1KB

  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. // NextSample returns the jingle contribution for one composite sample.
  54. // Call once per sample from the DSP loop — it is not thread-safe and must
  55. // be called from the single GenerateFrame goroutine only.
  56. // Returns 0 when licensed, not active, or no jingle loaded.
  57. func (s *State) NextSample(frames []JingleFrame) float64 {
  58. if s.licensed || len(frames) == 0 {
  59. return 0
  60. }
  61. if !s.active {
  62. return 0
  63. }
  64. f := frames[s.pos%len(frames)]
  65. jingleMono := float64(f.L+f.R) / 2.0
  66. s.pos++
  67. if s.pos >= len(frames) {
  68. s.active = false
  69. s.pos = 0
  70. s.nextFire = time.Now().Add(time.Duration(JingleIntervalMinutes) * time.Minute)
  71. }
  72. return s.jingleLevel * jingleMono
  73. }
  74. // Tick checks whether a new jingle playback should start.
  75. // Call once per chunk (not per sample) from GenerateFrame.
  76. // Safe to call from the single DSP goroutine — no locking needed after init.
  77. func (s *State) Tick() {
  78. if s.licensed || s.active {
  79. return
  80. }
  81. if time.Now().After(s.nextFire) {
  82. s.active = true
  83. s.pos = 0
  84. }
  85. }
  86. // MixComposite is kept for compatibility; prefer Tick()+NextSample() per sample.
  87. func (s *State) MixComposite(composite float64, frames []JingleFrame, _ time.Time) float64 {
  88. return composite + s.NextSample(frames)
  89. }
  90. // jingleFrame is a normalised stereo frame from the embedded WAV.
  91. type JingleFrame struct{ L, R float32 }
  92. // LoadJingleFrames decodes the embedded WAV bytes into normalised frames
  93. // and resamples them from jingleWAVRate to targetRate using linear interpolation.
  94. func LoadJingleFrames(wavBytes []byte, targetRate float64) ([]JingleFrame, error) {
  95. raw, err := decodeWAV(wavBytes)
  96. if err != nil {
  97. return nil, fmt.Errorf("license: decode jingle WAV: %w", err)
  98. }
  99. if targetRate <= 0 || math.Abs(targetRate-float64(jingleWAVRate)) < 1 {
  100. return raw, nil
  101. }
  102. // Linear resample to composite rate.
  103. ratio := float64(jingleWAVRate) / targetRate
  104. dstLen := int(float64(len(raw)) / ratio)
  105. out := make([]JingleFrame, dstLen)
  106. for i := range out {
  107. pos := float64(i) * ratio
  108. idx := int(pos)
  109. frac := float32(pos - float64(idx))
  110. if idx+1 < len(raw) {
  111. a, b := raw[idx], raw[idx+1]
  112. out[i] = JingleFrame{
  113. L: a.L*(1-frac) + b.L*frac,
  114. R: a.R*(1-frac) + b.R*frac,
  115. }
  116. } else if idx < len(raw) {
  117. out[i] = raw[idx]
  118. }
  119. }
  120. return out, nil
  121. }
  122. // decodeWAV parses a minimal PCM WAV (16-bit stereo) into normalised frames.
  123. func decodeWAV(data []byte) ([]JingleFrame, error) {
  124. if len(data) < 44 {
  125. return nil, fmt.Errorf("WAV too short")
  126. }
  127. if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
  128. return nil, fmt.Errorf("not a RIFF/WAVE file")
  129. }
  130. // Find fmt and data chunks.
  131. var (
  132. channels uint16
  133. bitsPerSample uint16
  134. dataStart int
  135. dataLen int
  136. )
  137. i := 12
  138. for i+8 <= len(data) {
  139. id := string(data[i : i+4])
  140. chunkSize := int(binary.LittleEndian.Uint32(data[i+4 : i+8]))
  141. i += 8
  142. switch id {
  143. case "fmt ":
  144. if chunkSize < 16 || i+16 > len(data) {
  145. return nil, fmt.Errorf("fmt chunk too small")
  146. }
  147. if binary.LittleEndian.Uint16(data[i:i+2]) != 1 {
  148. return nil, fmt.Errorf("only PCM WAV supported")
  149. }
  150. channels = binary.LittleEndian.Uint16(data[i+2 : i+4])
  151. bitsPerSample = binary.LittleEndian.Uint16(data[i+14 : i+16])
  152. case "data":
  153. dataStart = i
  154. dataLen = chunkSize
  155. }
  156. i += chunkSize
  157. if chunkSize%2 != 0 {
  158. i++
  159. }
  160. if dataStart > 0 && channels > 0 {
  161. break
  162. }
  163. }
  164. if dataStart == 0 || channels == 0 || bitsPerSample != 16 {
  165. return nil, fmt.Errorf("unsupported WAV format (need 16-bit PCM, got bits=%d ch=%d)", bitsPerSample, channels)
  166. }
  167. if dataStart+dataLen > len(data) {
  168. dataLen = len(data) - dataStart
  169. }
  170. step := int(channels) * 2
  171. frames := make([]JingleFrame, 0, dataLen/step)
  172. for j := dataStart; j+step <= dataStart+dataLen; j += step {
  173. l := float32(int16(binary.LittleEndian.Uint16(data[j:j+2]))) / 32768.0
  174. r := l
  175. if channels >= 2 {
  176. r = float32(int16(binary.LittleEndian.Uint16(data[j+2:j+4]))) / 32768.0
  177. }
  178. frames = append(frames, JingleFrame{L: l, R: r})
  179. }
  180. return frames, nil
  181. }
  182. // --- Key validation ---
  183. // ValidateKey returns true if key is a valid fm-rds-tx license key.
  184. func ValidateKey(key string) bool {
  185. key = strings.TrimSpace(key)
  186. if !strings.HasPrefix(key, "FMRTX-") {
  187. return false
  188. }
  189. body := strings.TrimPrefix(key, "FMRTX-")
  190. // Decode the base32 payload.
  191. // Format: BASE32(payload_len_byte || payload_bytes || mac_10_bytes)
  192. padded := body
  193. if pad := len(padded) % 8; pad != 0 {
  194. padded += strings.Repeat("=", 8-pad)
  195. }
  196. raw, err := base32.StdEncoding.DecodeString(strings.ToUpper(padded))
  197. if err != nil || len(raw) < 11 {
  198. return false
  199. }
  200. payloadLen := int(raw[0])
  201. if payloadLen+1+10 > len(raw) {
  202. return false
  203. }
  204. payload := raw[1 : 1+payloadLen]
  205. mac := raw[1+payloadLen : 1+payloadLen+10]
  206. expected := computeMAC(payload)
  207. return hmac.Equal(mac, expected[:10])
  208. }
  209. // GenerateKey generates a signed license key for the given payload string.
  210. // Call this from cmd/keygen — not from the main binary.
  211. func GenerateKey(payload string) string {
  212. p := []byte(payload)
  213. raw := make([]byte, 1+len(p)+10)
  214. raw[0] = byte(len(p))
  215. copy(raw[1:], p)
  216. mac := computeMAC(p)
  217. copy(raw[1+len(p):], mac[:10])
  218. encoded := base32.StdEncoding.EncodeToString(raw)
  219. encoded = strings.TrimRight(encoded, "=")
  220. return "FMRTX-" + encoded
  221. }
  222. func computeMAC(payload []byte) []byte {
  223. h := hmac.New(sha256.New, []byte(hmacSecret))
  224. h.Write(payload)
  225. return h.Sum(nil)
  226. }