// Package license handles fm-rds-tx key validation and jingle injection. // // Key format: FMRTX- // 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) }