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.

315 lines
8.2KB

  1. // cmd/wmdecode — fm-rds-tx spread-spectrum watermark recovery tool.
  2. //
  3. // Records or reads a mono WAV of FM receiver audio output, extracts the
  4. // embedded key fingerprint using PN correlation with frame synchronisation,
  5. // applies Reed-Solomon erasure decoding, and checks against known keys.
  6. //
  7. // Usage:
  8. //
  9. // wmdecode <file.wav> [key ...]
  10. //
  11. // Examples:
  12. //
  13. // wmdecode aufnahme.wav
  14. // wmdecode aufnahme.wav free studio@sender.fm
  15. //
  16. // Recording hint (Windows, FM receiver line-in):
  17. //
  18. // ffmpeg -f dshow -i audio="Stereo Mix" -ar 48000 -ac 1 -t 30 aufnahme.wav
  19. package main
  20. import (
  21. "encoding/binary"
  22. "fmt"
  23. "math"
  24. "os"
  25. "sort"
  26. "github.com/jan/fm-rds-tx/internal/watermark"
  27. )
  28. func main() {
  29. if len(os.Args) < 2 {
  30. fmt.Fprintln(os.Stderr, "usage: wmdecode <file.wav> [key ...]")
  31. os.Exit(1)
  32. }
  33. samples, recRate, err := readMonoWAV(os.Args[1])
  34. if err != nil {
  35. fmt.Fprintf(os.Stderr, "read WAV: %v\n", err)
  36. os.Exit(1)
  37. }
  38. rms := rmsLevel(samples)
  39. fmt.Printf("WAV: %d samples @ %.0f Hz = %.2fs, RMS %.1f dBFS\n",
  40. len(samples), recRate, float64(len(samples))/recRate, 20*math.Log10(rms+1e-9))
  41. samplesPerBit := int(float64(watermark.PnChips) * recRate / float64(watermark.RecordingRate))
  42. if samplesPerBit < 1 {
  43. samplesPerBit = 1
  44. }
  45. frameLen := samplesPerBit * watermark.PayloadBits
  46. fmt.Printf("Frame: %d samples/bit, %d samples/frame (%.3fs), %d frames in recording\n",
  47. samplesPerBit, frameLen, float64(frameLen)/recRate, len(samples)/frameLen)
  48. if len(samples) < samplesPerBit*2 {
  49. fmt.Fprintln(os.Stderr, "recording too short for even 2 bits")
  50. os.Exit(1)
  51. }
  52. // ---------------------------------------------------------------
  53. // Step 1: Phase search — find sample offset of bit boundaries.
  54. //
  55. // Coarse pass: test every 8th offset in [0, samplesPerBit).
  56. // Fine pass: refine ±8 around the coarse peak.
  57. // For each candidate offset, average |correlation| over several bits.
  58. // ---------------------------------------------------------------
  59. const coarseStep = 8
  60. const syncBits = 64
  61. bestPhase := 0
  62. bestMag := 0.0
  63. for phase := 0; phase < samplesPerBit; phase += coarseStep {
  64. mag := avgCorrMag(samples, phase, samplesPerBit, syncBits, recRate)
  65. if mag > bestMag {
  66. bestMag = mag
  67. bestPhase = phase
  68. }
  69. }
  70. fineStart := bestPhase - coarseStep
  71. if fineStart < 0 {
  72. fineStart = 0
  73. }
  74. fineEnd := bestPhase + coarseStep
  75. if fineEnd > samplesPerBit {
  76. fineEnd = samplesPerBit
  77. }
  78. for phase := fineStart; phase < fineEnd; phase++ {
  79. mag := avgCorrMag(samples, phase, samplesPerBit, syncBits, recRate)
  80. if mag > bestMag {
  81. bestMag = mag
  82. bestPhase = phase
  83. }
  84. }
  85. fmt.Printf("Phase: offset=%d (%.3fms into recording), avg|corr|=%.4f\n",
  86. bestPhase, float64(bestPhase)/recRate*1000, bestMag)
  87. // ---------------------------------------------------------------
  88. // Step 2: Extract bit correlations at found phase, averaged over frames.
  89. // ---------------------------------------------------------------
  90. nCompleteBits := (len(samples) - bestPhase) / samplesPerBit
  91. nFrames := nCompleteBits / watermark.PayloadBits
  92. if nFrames == 0 {
  93. nFrames = 1
  94. }
  95. fmt.Printf("Sync: %d complete bits, %d usable frames\n", nCompleteBits, nFrames)
  96. corrs := make([]float64, watermark.PayloadBits)
  97. for i := 0; i < watermark.PayloadBits; i++ {
  98. for frame := 0; frame < nFrames; frame++ {
  99. bitGlobal := frame*watermark.PayloadBits + i
  100. start := bestPhase + bitGlobal*samplesPerBit
  101. if start+samplesPerBit > len(samples) {
  102. break
  103. }
  104. corrs[i] += watermark.CorrelateAt(samples, start, recRate)
  105. }
  106. }
  107. // ---------------------------------------------------------------
  108. // Step 3: Frame sync — try all 128 cyclic rotations.
  109. // The correct rotation yields a valid RS codeword.
  110. // ---------------------------------------------------------------
  111. type decodeResult struct {
  112. rotation int
  113. payload [watermark.RsDataBytes]byte
  114. erasures int
  115. }
  116. var best *decodeResult
  117. for rot := 0; rot < watermark.PayloadBits; rot++ {
  118. var recv [watermark.RsTotalBytes]byte
  119. confs := make([]float64, watermark.PayloadBits)
  120. for i := 0; i < watermark.PayloadBits; i++ {
  121. srcBit := (i + rot) % watermark.PayloadBits
  122. c := corrs[srcBit]
  123. confs[i] = math.Abs(c)
  124. if c < 0 {
  125. recv[i/8] |= 1 << uint(7-(i%8))
  126. }
  127. }
  128. // Sort by confidence ascending for erasure selection
  129. type bitConf struct {
  130. idx int
  131. conf float64
  132. }
  133. ranked := make([]bitConf, watermark.PayloadBits)
  134. for i := range ranked {
  135. ranked[i] = bitConf{i, confs[i]}
  136. }
  137. sort.Slice(ranked, func(a, b int) bool {
  138. return ranked[a].conf < ranked[b].conf
  139. })
  140. for nErase := 0; nErase <= watermark.RsCheckBytes*8; nErase++ {
  141. erasedBytes := map[int]bool{}
  142. for _, bc := range ranked[:nErase] {
  143. erasedBytes[bc.idx/8] = true
  144. }
  145. if len(erasedBytes) > watermark.RsCheckBytes {
  146. break
  147. }
  148. erasePos := make([]int, 0, len(erasedBytes))
  149. for pos := range erasedBytes {
  150. erasePos = append(erasePos, pos)
  151. }
  152. sort.Ints(erasePos)
  153. payload, ok := watermark.RSDecode(recv, erasePos)
  154. if ok {
  155. if best == nil || len(erasePos) < best.erasures {
  156. best = &decodeResult{
  157. rotation: rot,
  158. payload: payload,
  159. erasures: len(erasePos),
  160. }
  161. }
  162. break
  163. }
  164. }
  165. if best != nil && best.erasures == 0 {
  166. break
  167. }
  168. }
  169. if best == nil {
  170. fmt.Println("\nRS decode: FAILED — no valid frame alignment found.")
  171. fmt.Println("Watermark may not be present, or recording is too noisy/short.")
  172. var maxCorr, minCorr float64
  173. for _, c := range corrs {
  174. ac := math.Abs(c)
  175. if ac > maxCorr {
  176. maxCorr = ac
  177. }
  178. if minCorr == 0 || ac < minCorr {
  179. minCorr = ac
  180. }
  181. }
  182. fmt.Printf("Correlation range: min |c|=%.4f, max |c|=%.4f\n", minCorr, maxCorr)
  183. os.Exit(1)
  184. }
  185. fmt.Printf("\nFrame sync: rotation=%d, RS erasures=%d\n", best.rotation, best.erasures)
  186. fmt.Printf("Payload: %x\n\n", best.payload)
  187. keys := os.Args[2:]
  188. if len(keys) == 0 {
  189. fmt.Println("No keys supplied — payload shown above.")
  190. fmt.Println("Usage: wmdecode <file.wav> free [other-keys...]")
  191. return
  192. }
  193. fmt.Println("Key check:")
  194. matched := false
  195. for _, key := range keys {
  196. if watermark.KeyMatchesPayload(key, best.payload) {
  197. fmt.Printf(" ✓ MATCH: %q\n", key)
  198. matched = true
  199. } else {
  200. fmt.Printf(" ✗ : %q\n", key)
  201. }
  202. }
  203. if !matched {
  204. fmt.Println("\nNo key matched.")
  205. }
  206. }
  207. func avgCorrMag(samples []float64, phase, samplesPerBit, nBits int, recRate float64) float64 {
  208. var total float64
  209. var count int
  210. for b := 0; b < nBits; b++ {
  211. start := phase + b*samplesPerBit
  212. if start+samplesPerBit > len(samples) {
  213. break
  214. }
  215. c := watermark.CorrelateAt(samples, start, recRate)
  216. total += math.Abs(c)
  217. count++
  218. }
  219. if count == 0 {
  220. return 0
  221. }
  222. return total / float64(count)
  223. }
  224. func rmsLevel(s []float64) float64 {
  225. var acc float64
  226. for _, v := range s {
  227. acc += v * v
  228. }
  229. return math.Sqrt(acc / float64(len(s)))
  230. }
  231. func readMonoWAV(path string) ([]float64, float64, error) {
  232. data, err := os.ReadFile(path)
  233. if err != nil {
  234. return nil, 0, err
  235. }
  236. if len(data) < 44 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
  237. return nil, 0, fmt.Errorf("not a RIFF/WAVE file")
  238. }
  239. var channels, bitsPerSample uint16
  240. var sampleRate uint32
  241. var dataStart, dataLen int
  242. i := 12
  243. for i+8 <= len(data) {
  244. id := string(data[i : i+4])
  245. sz := int(binary.LittleEndian.Uint32(data[i+4 : i+8]))
  246. i += 8
  247. switch id {
  248. case "fmt ":
  249. if sz >= 16 {
  250. channels = binary.LittleEndian.Uint16(data[i+2 : i+4])
  251. sampleRate = binary.LittleEndian.Uint32(data[i+4 : i+8])
  252. bitsPerSample = binary.LittleEndian.Uint16(data[i+14 : i+16])
  253. }
  254. case "data":
  255. dataStart, dataLen = i, sz
  256. }
  257. i += sz
  258. if sz%2 != 0 {
  259. i++
  260. }
  261. if dataStart > 0 && channels > 0 {
  262. break
  263. }
  264. }
  265. if dataStart == 0 || bitsPerSample != 16 || channels == 0 {
  266. return nil, 0, fmt.Errorf("unsupported WAV (need 16-bit PCM, got bits=%d ch=%d)", bitsPerSample, channels)
  267. }
  268. if dataStart+dataLen > len(data) {
  269. dataLen = len(data) - dataStart
  270. }
  271. step := int(channels) * 2
  272. nFrames := dataLen / step
  273. out := make([]float64, nFrames)
  274. for j := 0; j < nFrames; j++ {
  275. off := dataStart + j*step
  276. l := float64(int16(binary.LittleEndian.Uint16(data[off : off+2])))
  277. r := l
  278. if channels >= 2 {
  279. r = float64(int16(binary.LittleEndian.Uint16(data[off+2 : off+4])))
  280. }
  281. out[j] = (l + r) / 2.0 / 32768.0
  282. }
  283. return out, float64(sampleRate), nil
  284. }