|
- // Package license handles fm-rds-tx key validation and jingle injection.
- //
- // Key format: FMRTX-<BASE32(HMAC-SHA256(payload, secret)[:10])>
- // payload: "free" for gratis keys, email for paid keys.
- //
- // Without a valid key the jingle WAV is mixed into the composite output
- // every JingleIntervalMinutes minutes. With a valid key the jingle is silent.
- package license
-
- import (
- "crypto/hmac"
- "crypto/sha256"
- "encoding/base32"
- "encoding/binary"
- "fmt"
- "math"
- "strings"
- "time"
- )
-
- // hmacSecret is the shared secret used to sign and verify keys.
- // Change this value in your private fork — keys signed with the old
- // secret stop working, forcing a re-issue. Never commit the real secret.
- const hmacSecret = "Q7m!xP2#rL9$vN4@tK8%hD3&yF6*zC1+uB5"
-
- // JingleIntervalMinutes is how often the jingle fires when unlicensed.
- const JingleIntervalMinutes = 20
-
- // jingleSampleRate and jingleChannels must match the embedded jingle.wav.
- // The mixer resamples on-the-fly if the composite rate differs.
- const (
- jingleWAVRate = 44100
- jingleWAVChannels = 2
- )
-
- // State holds the runtime license + jingle state for a running generator.
- type State struct {
- licensed bool
-
- active bool // jingle currently playing
- pos int // playback position in jingleFrames
- nextFire time.Time
- jingleLevel float64 // composite injection amplitude (0..1)
- }
-
- // NewState validates the provided key and returns a ready State.
- // If key is empty or invalid, the jingle fires every JingleIntervalMinutes.
- func NewState(key string) *State {
- s := &State{
- licensed: ValidateKey(key),
- jingleLevel: 0.25, // 25% composite injection — loud but not clipping
- }
- if !s.licensed {
- s.nextFire = time.Now().Add(time.Duration(JingleIntervalMinutes) * time.Minute)
- }
- return s
- }
-
- // Licensed reports whether a valid key was supplied.
- func (s *State) Licensed() bool { return s.licensed }
-
- // Active reports whether the jingle is currently playing.
- func (s *State) Active() bool { return s.active }
-
- // NextSample returns the jingle contribution for one composite sample.
- // Call once per sample from the DSP loop — it is not thread-safe and must
- // be called from the single GenerateFrame goroutine only.
- // Returns 0 when licensed, not active, or no jingle loaded.
- func (s *State) NextSample(frames []JingleFrame) float64 {
- if s.licensed || len(frames) == 0 {
- return 0
- }
- if !s.active {
- return 0
- }
- f := frames[s.pos%len(frames)]
- jingleMono := float64(f.L+f.R) / 2.0
- s.pos++
- if s.pos >= len(frames) {
- s.active = false
- s.pos = 0
- s.nextFire = time.Now().Add(time.Duration(JingleIntervalMinutes) * time.Minute)
- }
- return s.jingleLevel * jingleMono
- }
-
- // Tick checks whether a new jingle playback should start.
- // Call once per chunk (not per sample) from GenerateFrame.
- // Safe to call from the single DSP goroutine — no locking needed after init.
- func (s *State) Tick() {
- if s.licensed || s.active {
- return
- }
- if time.Now().After(s.nextFire) {
- s.active = true
- s.pos = 0
- }
- }
-
- // MixComposite is kept for compatibility; prefer Tick()+NextSample() per sample.
- func (s *State) MixComposite(composite float64, frames []JingleFrame, _ time.Time) float64 {
- return composite + s.NextSample(frames)
- }
-
- // jingleFrame is a normalised stereo frame from the embedded WAV.
- type JingleFrame struct{ L, R float32 }
-
- // LoadJingleFrames decodes the embedded WAV bytes into normalised frames
- // and resamples them from jingleWAVRate to targetRate using linear interpolation.
- func LoadJingleFrames(wavBytes []byte, targetRate float64) ([]JingleFrame, error) {
- raw, err := decodeWAV(wavBytes)
- if err != nil {
- return nil, fmt.Errorf("license: decode jingle WAV: %w", err)
- }
- if targetRate <= 0 || math.Abs(targetRate-float64(jingleWAVRate)) < 1 {
- return raw, nil
- }
- // Linear resample to composite rate.
- ratio := float64(jingleWAVRate) / targetRate
- dstLen := int(float64(len(raw)) / ratio)
- out := make([]JingleFrame, dstLen)
- for i := range out {
- pos := float64(i) * ratio
- idx := int(pos)
- frac := float32(pos - float64(idx))
- if idx+1 < len(raw) {
- a, b := raw[idx], raw[idx+1]
- out[i] = JingleFrame{
- L: a.L*(1-frac) + b.L*frac,
- R: a.R*(1-frac) + b.R*frac,
- }
- } else if idx < len(raw) {
- out[i] = raw[idx]
- }
- }
- return out, nil
- }
-
- // decodeWAV parses a minimal PCM WAV (16-bit stereo) into normalised frames.
- func decodeWAV(data []byte) ([]JingleFrame, error) {
- if len(data) < 44 {
- return nil, fmt.Errorf("WAV too short")
- }
- if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
- return nil, fmt.Errorf("not a RIFF/WAVE file")
- }
- // Find fmt and data chunks.
- var (
- channels uint16
- bitsPerSample uint16
- dataStart int
- dataLen int
- )
- i := 12
- for i+8 <= len(data) {
- id := string(data[i : i+4])
- chunkSize := int(binary.LittleEndian.Uint32(data[i+4 : i+8]))
- i += 8
- switch id {
- case "fmt ":
- if chunkSize < 16 || i+16 > len(data) {
- return nil, fmt.Errorf("fmt chunk too small")
- }
- if binary.LittleEndian.Uint16(data[i:i+2]) != 1 {
- return nil, fmt.Errorf("only PCM WAV supported")
- }
- channels = binary.LittleEndian.Uint16(data[i+2 : i+4])
- bitsPerSample = binary.LittleEndian.Uint16(data[i+14 : i+16])
- case "data":
- dataStart = i
- dataLen = chunkSize
- }
- i += chunkSize
- if chunkSize%2 != 0 {
- i++
- }
- if dataStart > 0 && channels > 0 {
- break
- }
- }
- if dataStart == 0 || channels == 0 || bitsPerSample != 16 {
- return nil, fmt.Errorf("unsupported WAV format (need 16-bit PCM, got bits=%d ch=%d)", bitsPerSample, channels)
- }
- if dataStart+dataLen > len(data) {
- dataLen = len(data) - dataStart
- }
-
- step := int(channels) * 2
- frames := make([]JingleFrame, 0, dataLen/step)
- for j := dataStart; j+step <= dataStart+dataLen; j += step {
- l := float32(int16(binary.LittleEndian.Uint16(data[j:j+2]))) / 32768.0
- r := l
- if channels >= 2 {
- r = float32(int16(binary.LittleEndian.Uint16(data[j+2:j+4]))) / 32768.0
- }
- frames = append(frames, JingleFrame{L: l, R: r})
- }
- return frames, nil
- }
-
- // --- Key validation ---
-
- // ValidateKey returns true if key is a valid fm-rds-tx license key.
- func ValidateKey(key string) bool {
- key = strings.TrimSpace(key)
- if !strings.HasPrefix(key, "FMRTX-") {
- return false
- }
- body := strings.TrimPrefix(key, "FMRTX-")
- // Decode the base32 payload.
- // Format: BASE32(payload_len_byte || payload_bytes || mac_10_bytes)
- padded := body
- if pad := len(padded) % 8; pad != 0 {
- padded += strings.Repeat("=", 8-pad)
- }
- raw, err := base32.StdEncoding.DecodeString(strings.ToUpper(padded))
- if err != nil || len(raw) < 11 {
- return false
- }
- payloadLen := int(raw[0])
- if payloadLen+1+10 > len(raw) {
- return false
- }
- payload := raw[1 : 1+payloadLen]
- mac := raw[1+payloadLen : 1+payloadLen+10]
- expected := computeMAC(payload)
- return hmac.Equal(mac, expected[:10])
- }
-
- // GenerateKey generates a signed license key for the given payload string.
- // Call this from cmd/keygen — not from the main binary.
- func GenerateKey(payload string) string {
- p := []byte(payload)
- raw := make([]byte, 1+len(p)+10)
- raw[0] = byte(len(p))
- copy(raw[1:], p)
- mac := computeMAC(p)
- copy(raw[1+len(p):], mac[:10])
- encoded := base32.StdEncoding.EncodeToString(raw)
- encoded = strings.TrimRight(encoded, "=")
- return "FMRTX-" + encoded
- }
-
- func computeMAC(payload []byte) []byte {
- h := hmac.New(sha256.New, []byte(hmacSecret))
- h.Write(payload)
- return h.Sum(nil)
- }
|