Wideband autonomous SDR analysis engine forked from sdr-visual-suite
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.

739 wiersze
21KB

  1. package main
  2. import (
  3. "context"
  4. "encoding/json"
  5. "flag"
  6. "log"
  7. "math"
  8. "net/http"
  9. "os"
  10. "os/signal"
  11. "path/filepath"
  12. "runtime/debug"
  13. "sort"
  14. "strconv"
  15. "strings"
  16. "sync"
  17. "syscall"
  18. "time"
  19. "github.com/gorilla/websocket"
  20. "sdr-visual-suite/internal/classifier"
  21. "sdr-visual-suite/internal/config"
  22. "sdr-visual-suite/internal/detector"
  23. "sdr-visual-suite/internal/dsp"
  24. "sdr-visual-suite/internal/events"
  25. fftutil "sdr-visual-suite/internal/fft"
  26. "sdr-visual-suite/internal/fft/gpufft"
  27. "sdr-visual-suite/internal/mock"
  28. "sdr-visual-suite/internal/recorder"
  29. "sdr-visual-suite/internal/runtime"
  30. "sdr-visual-suite/internal/sdr"
  31. "sdr-visual-suite/internal/sdrplay"
  32. )
  33. func main() {
  34. var cfgPath string
  35. var mockFlag bool
  36. flag.StringVar(&cfgPath, "config", "config.yaml", "path to config YAML")
  37. flag.BoolVar(&mockFlag, "mock", false, "use synthetic IQ source")
  38. flag.Parse()
  39. cfg, err := config.Load(cfgPath)
  40. if err != nil {
  41. log.Fatalf("load config: %v", err)
  42. }
  43. cfgManager := runtime.New(cfg)
  44. gpuState := &gpuStatus{Available: gpufft.Available()}
  45. newSource := func(cfg config.Config) (sdr.Source, error) {
  46. if mockFlag {
  47. src := mock.New(cfg.SampleRate)
  48. if updatable, ok := interface{}(src).(sdr.ConfigurableSource); ok {
  49. _ = updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC, cfg.TunerBwKHz)
  50. }
  51. return src, nil
  52. }
  53. src, err := sdrplay.New(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.TunerBwKHz)
  54. if err != nil {
  55. return nil, err
  56. }
  57. if updatable, ok := src.(sdr.ConfigurableSource); ok {
  58. _ = updatable.UpdateConfig(cfg.SampleRate, cfg.CenterHz, cfg.GainDb, cfg.AGC, cfg.TunerBwKHz)
  59. }
  60. return src, nil
  61. }
  62. src, err := newSource(cfg)
  63. if err != nil {
  64. log.Fatalf("sdrplay init failed: %v (try --mock or build with -tags sdrplay)", err)
  65. }
  66. srcMgr := newSourceManager(src, newSource)
  67. if err := srcMgr.Start(); err != nil {
  68. log.Fatalf("source start: %v", err)
  69. }
  70. defer srcMgr.Stop()
  71. if err := os.MkdirAll(filepath.Dir(cfg.EventPath), 0o755); err != nil {
  72. log.Fatalf("event path: %v", err)
  73. }
  74. eventFile, err := os.OpenFile(cfg.EventPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
  75. if err != nil {
  76. log.Fatalf("open events: %v", err)
  77. }
  78. defer eventFile.Close()
  79. eventMu := &sync.RWMutex{}
  80. det := detector.New(cfg.Detector, cfg.SampleRate, cfg.FFTSize)
  81. window := fftutil.Hann(cfg.FFTSize)
  82. h := newHub()
  83. dspUpdates := make(chan dspUpdate, 1)
  84. ctx, cancel := context.WithCancel(context.Background())
  85. defer cancel()
  86. decodeMap := buildDecoderMap(cfg)
  87. recMgr := recorder.New(cfg.SampleRate, cfg.FFTSize, recorder.Policy{
  88. Enabled: cfg.Recorder.Enabled,
  89. MinSNRDb: cfg.Recorder.MinSNRDb,
  90. MinDuration: mustParseDuration(cfg.Recorder.MinDuration, 1*time.Second),
  91. MaxDuration: mustParseDuration(cfg.Recorder.MaxDuration, 300*time.Second),
  92. PrerollMs: cfg.Recorder.PrerollMs,
  93. RecordIQ: cfg.Recorder.RecordIQ,
  94. RecordAudio: cfg.Recorder.RecordAudio,
  95. AutoDemod: cfg.Recorder.AutoDemod,
  96. AutoDecode: cfg.Recorder.AutoDecode,
  97. MaxDiskMB: cfg.Recorder.MaxDiskMB,
  98. OutputDir: cfg.Recorder.OutputDir,
  99. ClassFilter: cfg.Recorder.ClassFilter,
  100. RingSeconds: cfg.Recorder.RingSeconds,
  101. }, cfg.CenterHz, decodeMap)
  102. sigSnap := &signalSnapshot{}
  103. go runDSP(ctx, srcMgr, cfg, det, window, h, eventFile, eventMu, dspUpdates, gpuState, recMgr, sigSnap)
  104. upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool {
  105. origin := r.Header.Get("Origin")
  106. if origin == "" || origin == "null" {
  107. return true
  108. }
  109. // allow same-host or any local IP
  110. return true
  111. }}
  112. http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
  113. conn, err := upgrader.Upgrade(w, r, nil)
  114. if err != nil {
  115. log.Printf("ws upgrade failed: %v (origin: %s)", err, r.Header.Get("Origin"))
  116. return
  117. }
  118. c := &client{conn: conn, send: make(chan []byte, 32), done: make(chan struct{})}
  119. h.add(c)
  120. defer func() {
  121. h.remove(c)
  122. _ = conn.Close()
  123. }()
  124. conn.SetReadDeadline(time.Now().Add(60 * time.Second))
  125. conn.SetPongHandler(func(string) error {
  126. conn.SetReadDeadline(time.Now().Add(60 * time.Second))
  127. return nil
  128. })
  129. go func() {
  130. ping := time.NewTicker(30 * time.Second)
  131. defer ping.Stop()
  132. for {
  133. select {
  134. case msg, ok := <-c.send:
  135. if !ok {
  136. return
  137. }
  138. _ = conn.SetWriteDeadline(time.Now().Add(200 * time.Millisecond))
  139. if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
  140. return
  141. }
  142. case <-ping.C:
  143. _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
  144. if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
  145. log.Printf("ws ping error: %v", err)
  146. return
  147. }
  148. }
  149. }
  150. }()
  151. for {
  152. _, _, err := conn.ReadMessage()
  153. if err != nil {
  154. return
  155. }
  156. }
  157. })
  158. http.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) {
  159. w.Header().Set("Content-Type", "application/json")
  160. switch r.Method {
  161. case http.MethodGet:
  162. _ = json.NewEncoder(w).Encode(cfgManager.Snapshot())
  163. case http.MethodPost:
  164. var update runtime.ConfigUpdate
  165. if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
  166. http.Error(w, "invalid json", http.StatusBadRequest)
  167. return
  168. }
  169. prev := cfgManager.Snapshot()
  170. next, err := cfgManager.ApplyConfig(update)
  171. if err != nil {
  172. http.Error(w, err.Error(), http.StatusBadRequest)
  173. return
  174. }
  175. sourceChanged := prev.CenterHz != next.CenterHz || prev.SampleRate != next.SampleRate || prev.GainDb != next.GainDb || prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz
  176. if sourceChanged {
  177. if err := srcMgr.ApplyConfig(next); err != nil {
  178. cfgManager.Replace(prev)
  179. http.Error(w, "failed to apply source config", http.StatusInternalServerError)
  180. return
  181. }
  182. }
  183. if err := config.Save(cfgPath, next); err != nil {
  184. log.Printf("config save failed: %v", err)
  185. }
  186. detChanged := prev.Detector.ThresholdDb != next.Detector.ThresholdDb ||
  187. prev.Detector.MinDurationMs != next.Detector.MinDurationMs ||
  188. prev.Detector.HoldMs != next.Detector.HoldMs ||
  189. prev.Detector.EmaAlpha != next.Detector.EmaAlpha ||
  190. prev.Detector.HysteresisDb != next.Detector.HysteresisDb ||
  191. prev.Detector.MinStableFrames != next.Detector.MinStableFrames ||
  192. prev.Detector.GapToleranceMs != next.Detector.GapToleranceMs ||
  193. prev.Detector.CFARMode != next.Detector.CFARMode ||
  194. prev.Detector.CFARGuardCells != next.Detector.CFARGuardCells ||
  195. prev.Detector.CFARTrainCells != next.Detector.CFARTrainCells ||
  196. prev.Detector.CFARRank != next.Detector.CFARRank ||
  197. prev.Detector.CFARScaleDb != next.Detector.CFARScaleDb ||
  198. prev.Detector.CFARWrapAround != next.Detector.CFARWrapAround ||
  199. prev.SampleRate != next.SampleRate ||
  200. prev.FFTSize != next.FFTSize
  201. windowChanged := prev.FFTSize != next.FFTSize
  202. var newDet *detector.Detector
  203. var newWindow []float64
  204. if detChanged {
  205. newDet = detector.New(next.Detector, next.SampleRate, next.FFTSize)
  206. }
  207. if windowChanged {
  208. newWindow = fftutil.Hann(next.FFTSize)
  209. }
  210. pushDSPUpdate(dspUpdates, dspUpdate{
  211. cfg: next,
  212. det: newDet,
  213. window: newWindow,
  214. dcBlock: next.DCBlock,
  215. iqBalance: next.IQBalance,
  216. useGPUFFT: next.UseGPUFFT,
  217. })
  218. _ = json.NewEncoder(w).Encode(next)
  219. default:
  220. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  221. }
  222. })
  223. http.HandleFunc("/api/sdr/settings", func(w http.ResponseWriter, r *http.Request) {
  224. w.Header().Set("Content-Type", "application/json")
  225. if r.Method != http.MethodPost {
  226. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  227. return
  228. }
  229. var update runtime.SettingsUpdate
  230. if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
  231. http.Error(w, "invalid json", http.StatusBadRequest)
  232. return
  233. }
  234. prev := cfgManager.Snapshot()
  235. next, err := cfgManager.ApplySettings(update)
  236. if err != nil {
  237. http.Error(w, err.Error(), http.StatusBadRequest)
  238. return
  239. }
  240. if prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz {
  241. if err := srcMgr.ApplyConfig(next); err != nil {
  242. cfgManager.Replace(prev)
  243. http.Error(w, "failed to apply sdr settings", http.StatusInternalServerError)
  244. return
  245. }
  246. }
  247. if prev.DCBlock != next.DCBlock || prev.IQBalance != next.IQBalance {
  248. pushDSPUpdate(dspUpdates, dspUpdate{
  249. cfg: next,
  250. dcBlock: next.DCBlock,
  251. iqBalance: next.IQBalance,
  252. })
  253. }
  254. if err := config.Save(cfgPath, next); err != nil {
  255. log.Printf("config save failed: %v", err)
  256. }
  257. _ = json.NewEncoder(w).Encode(next)
  258. })
  259. http.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) {
  260. w.Header().Set("Content-Type", "application/json")
  261. _ = json.NewEncoder(w).Encode(srcMgr.Stats())
  262. })
  263. http.HandleFunc("/api/gpu", func(w http.ResponseWriter, r *http.Request) {
  264. w.Header().Set("Content-Type", "application/json")
  265. _ = json.NewEncoder(w).Encode(gpuState.snapshot())
  266. })
  267. http.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) {
  268. w.Header().Set("Content-Type", "application/json")
  269. limit := 200
  270. if v := r.URL.Query().Get("limit"); v != "" {
  271. if parsed, err := strconv.Atoi(v); err == nil {
  272. limit = parsed
  273. }
  274. }
  275. var since time.Time
  276. if v := r.URL.Query().Get("since"); v != "" {
  277. if parsed, err := parseSince(v); err == nil {
  278. since = parsed
  279. } else {
  280. http.Error(w, "invalid since", http.StatusBadRequest)
  281. return
  282. }
  283. }
  284. snap := cfgManager.Snapshot()
  285. eventMu.RLock()
  286. evs, err := events.ReadRecent(snap.EventPath, limit, since)
  287. eventMu.RUnlock()
  288. if err != nil {
  289. http.Error(w, "failed to read events", http.StatusInternalServerError)
  290. return
  291. }
  292. _ = json.NewEncoder(w).Encode(evs)
  293. })
  294. http.HandleFunc("/api/signals", func(w http.ResponseWriter, r *http.Request) {
  295. w.Header().Set("Content-Type", "application/json")
  296. if sigSnap == nil {
  297. _ = json.NewEncoder(w).Encode([]detector.Signal{})
  298. return
  299. }
  300. _ = json.NewEncoder(w).Encode(sigSnap.get())
  301. })
  302. http.HandleFunc("/api/decoders", func(w http.ResponseWriter, r *http.Request) {
  303. w.Header().Set("Content-Type", "application/json")
  304. _ = json.NewEncoder(w).Encode(decoderKeys(cfgManager.Snapshot()))
  305. })
  306. http.HandleFunc("/api/recordings", func(w http.ResponseWriter, r *http.Request) {
  307. if r.Method != http.MethodGet {
  308. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  309. return
  310. }
  311. w.Header().Set("Content-Type", "application/json")
  312. snap := cfgManager.Snapshot()
  313. list, err := recorder.ListRecordings(snap.Recorder.OutputDir)
  314. if err != nil {
  315. http.Error(w, "failed to list recordings", http.StatusInternalServerError)
  316. return
  317. }
  318. _ = json.NewEncoder(w).Encode(list)
  319. })
  320. http.HandleFunc("/api/recordings/", func(w http.ResponseWriter, r *http.Request) {
  321. w.Header().Set("Content-Type", "application/json")
  322. id := strings.TrimPrefix(r.URL.Path, "/api/recordings/")
  323. if id == "" {
  324. http.Error(w, "missing id", http.StatusBadRequest)
  325. return
  326. }
  327. snap := cfgManager.Snapshot()
  328. base := filepath.Clean(filepath.Join(snap.Recorder.OutputDir, id))
  329. if !strings.HasPrefix(base, filepath.Clean(snap.Recorder.OutputDir)) {
  330. http.Error(w, "invalid path", http.StatusBadRequest)
  331. return
  332. }
  333. if r.URL.Path == "/api/recordings/"+id+"/audio" {
  334. http.ServeFile(w, r, filepath.Join(base, "audio.wav"))
  335. return
  336. }
  337. if r.URL.Path == "/api/recordings/"+id+"/iq" {
  338. http.ServeFile(w, r, filepath.Join(base, "signal.cf32"))
  339. return
  340. }
  341. if r.URL.Path == "/api/recordings/"+id+"/decode" {
  342. mode := r.URL.Query().Get("mode")
  343. cmd := buildDecoderMap(cfgManager.Snapshot())[mode]
  344. if cmd == "" {
  345. http.Error(w, "decoder not configured", http.StatusBadRequest)
  346. return
  347. }
  348. meta, err := recorder.ReadMeta(filepath.Join(base, "meta.json"))
  349. if err != nil {
  350. http.Error(w, "meta read failed", http.StatusInternalServerError)
  351. return
  352. }
  353. audioPath := filepath.Join(base, "audio.wav")
  354. if _, errStat := os.Stat(audioPath); errStat != nil {
  355. audioPath = ""
  356. }
  357. res, err := recorder.DecodeOnDemand(cmd, filepath.Join(base, "signal.cf32"), meta.SampleRate, audioPath)
  358. if err != nil {
  359. http.Error(w, res.Stderr, http.StatusInternalServerError)
  360. return
  361. }
  362. _ = json.NewEncoder(w).Encode(res)
  363. return
  364. }
  365. // default: meta.json
  366. http.ServeFile(w, r, filepath.Join(base, "meta.json"))
  367. })
  368. http.HandleFunc("/api/demod", func(w http.ResponseWriter, r *http.Request) {
  369. if r.Method != http.MethodGet {
  370. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  371. return
  372. }
  373. q := r.URL.Query()
  374. freq, _ := strconv.ParseFloat(q.Get("freq"), 64)
  375. bw, _ := strconv.ParseFloat(q.Get("bw"), 64)
  376. sec, _ := strconv.Atoi(q.Get("sec"))
  377. if sec < 1 {
  378. sec = 1
  379. }
  380. if sec > 10 {
  381. sec = 10
  382. }
  383. mode := q.Get("mode")
  384. data, _, err := recMgr.DemodLive(freq, bw, mode, sec)
  385. if err != nil {
  386. http.Error(w, err.Error(), http.StatusBadRequest)
  387. return
  388. }
  389. w.Header().Set("Content-Type", "audio/wav")
  390. _, _ = w.Write(data)
  391. })
  392. http.Handle("/", http.FileServer(http.Dir(cfg.WebRoot)))
  393. server := &http.Server{Addr: cfg.WebAddr}
  394. go func() {
  395. log.Printf("web listening on %s", cfg.WebAddr)
  396. if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
  397. log.Fatalf("server: %v", err)
  398. }
  399. }()
  400. stop := make(chan os.Signal, 1)
  401. signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
  402. <-stop
  403. ctxTimeout, cancelTimeout := context.WithTimeout(context.Background(), 5*time.Second)
  404. defer cancelTimeout()
  405. _ = server.Shutdown(ctxTimeout)
  406. }
  407. func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det *detector.Detector, window []float64, h *hub, eventFile *os.File, eventMu *sync.RWMutex, updates <-chan dspUpdate, gpuState *gpuStatus, rec *recorder.Manager, sigSnap *signalSnapshot) {
  408. defer func() {
  409. if r := recover(); r != nil {
  410. log.Printf("FATAL: runDSP goroutine panic: %v\n%s", r, debug.Stack())
  411. }
  412. }()
  413. ticker := time.NewTicker(cfg.FrameInterval())
  414. defer ticker.Stop()
  415. logTicker := time.NewTicker(5 * time.Second)
  416. defer logTicker.Stop()
  417. enc := json.NewEncoder(eventFile)
  418. dcBlocker := dsp.NewDCBlocker(0.995)
  419. dcEnabled := cfg.DCBlock
  420. iqEnabled := cfg.IQBalance
  421. plan := fftutil.NewCmplxPlan(cfg.FFTSize)
  422. useGPU := cfg.UseGPUFFT
  423. var gpuEngine *gpufft.Engine
  424. if useGPU && gpuState != nil {
  425. snap := gpuState.snapshot()
  426. if snap.Available {
  427. if eng, err := gpufft.New(cfg.FFTSize); err == nil {
  428. gpuEngine = eng
  429. gpuState.set(true, nil)
  430. } else {
  431. gpuState.set(false, err)
  432. useGPU = false
  433. }
  434. } else {
  435. gpuState.set(false, nil)
  436. useGPU = false
  437. }
  438. } else if gpuState != nil {
  439. gpuState.set(false, nil)
  440. }
  441. gotSamples := false
  442. for {
  443. select {
  444. case <-ctx.Done():
  445. return
  446. case <-logTicker.C:
  447. st := srcMgr.Stats()
  448. log.Printf("stats: buf=%d drop=%d reset=%d last=%dms", st.BufferSamples, st.Dropped, st.Resets, st.LastSampleAgoMs)
  449. case upd := <-updates:
  450. prevFFT := cfg.FFTSize
  451. prevUseGPU := useGPU
  452. cfg = upd.cfg
  453. if rec != nil {
  454. rec.Update(cfg.SampleRate, cfg.FFTSize, recorder.Policy{
  455. Enabled: cfg.Recorder.Enabled,
  456. MinSNRDb: cfg.Recorder.MinSNRDb,
  457. MinDuration: mustParseDuration(cfg.Recorder.MinDuration, 1*time.Second),
  458. MaxDuration: mustParseDuration(cfg.Recorder.MaxDuration, 300*time.Second),
  459. PrerollMs: cfg.Recorder.PrerollMs,
  460. RecordIQ: cfg.Recorder.RecordIQ,
  461. RecordAudio: cfg.Recorder.RecordAudio,
  462. AutoDemod: cfg.Recorder.AutoDemod,
  463. AutoDecode: cfg.Recorder.AutoDecode,
  464. MaxDiskMB: cfg.Recorder.MaxDiskMB,
  465. OutputDir: cfg.Recorder.OutputDir,
  466. ClassFilter: cfg.Recorder.ClassFilter,
  467. RingSeconds: cfg.Recorder.RingSeconds,
  468. }, cfg.CenterHz, buildDecoderMap(cfg))
  469. }
  470. if upd.det != nil {
  471. det = upd.det
  472. }
  473. if upd.window != nil {
  474. window = upd.window
  475. plan = fftutil.NewCmplxPlan(cfg.FFTSize)
  476. }
  477. dcEnabled = upd.dcBlock
  478. iqEnabled = upd.iqBalance
  479. if cfg.FFTSize != prevFFT || cfg.UseGPUFFT != prevUseGPU {
  480. srcMgr.Flush()
  481. gotSamples = false
  482. if gpuEngine != nil {
  483. gpuEngine.Close()
  484. gpuEngine = nil
  485. }
  486. useGPU = cfg.UseGPUFFT
  487. if useGPU && gpuState != nil {
  488. snap := gpuState.snapshot()
  489. if snap.Available {
  490. if eng, err := gpufft.New(cfg.FFTSize); err == nil {
  491. gpuEngine = eng
  492. gpuState.set(true, nil)
  493. } else {
  494. gpuState.set(false, err)
  495. useGPU = false
  496. }
  497. } else {
  498. gpuState.set(false, nil)
  499. useGPU = false
  500. }
  501. } else if gpuState != nil {
  502. gpuState.set(false, nil)
  503. }
  504. }
  505. dcBlocker.Reset()
  506. ticker.Reset(cfg.FrameInterval())
  507. case <-ticker.C:
  508. iq, err := srcMgr.ReadIQ(cfg.FFTSize)
  509. if err != nil {
  510. log.Printf("read IQ: %v", err)
  511. if strings.Contains(err.Error(), "timeout") {
  512. if err := srcMgr.Restart(cfg); err != nil {
  513. log.Printf("restart failed: %v", err)
  514. }
  515. }
  516. continue
  517. }
  518. if rec != nil {
  519. rec.Ingest(time.Now(), iq)
  520. }
  521. if !gotSamples {
  522. log.Printf("received IQ samples")
  523. gotSamples = true
  524. }
  525. if dcEnabled {
  526. dcBlocker.Apply(iq)
  527. }
  528. if iqEnabled {
  529. dsp.IQBalance(iq)
  530. }
  531. var spectrum []float64
  532. if useGPU && gpuEngine != nil {
  533. if len(window) == len(iq) {
  534. for i := 0; i < len(iq); i++ {
  535. v := iq[i]
  536. w := float32(window[i])
  537. iq[i] = complex(real(v)*w, imag(v)*w)
  538. }
  539. }
  540. out, err := gpuEngine.Exec(iq)
  541. if err != nil {
  542. if gpuState != nil {
  543. gpuState.set(false, err)
  544. }
  545. useGPU = false
  546. spectrum = fftutil.SpectrumWithPlan(iq, nil, plan)
  547. } else {
  548. spectrum = fftutil.SpectrumFromFFT(out)
  549. }
  550. } else {
  551. spectrum = fftutil.SpectrumWithPlan(iq, window, plan)
  552. }
  553. for i := range spectrum {
  554. if math.IsNaN(spectrum[i]) || math.IsInf(spectrum[i], 0) {
  555. spectrum[i] = -200
  556. }
  557. }
  558. now := time.Now()
  559. finished, signals := det.Process(now, spectrum, cfg.CenterHz)
  560. thresholds := det.LastThresholds()
  561. noiseFloor := det.LastNoiseFloor()
  562. // enrich classification with temporal IQ features on per-signal snippet
  563. if len(iq) > 0 {
  564. for i := range signals {
  565. snip := extractSignalIQ(iq, cfg.SampleRate, cfg.CenterHz, signals[i].CenterHz, signals[i].BWHz)
  566. cls := classifier.Classify(classifier.SignalInput{FirstBin: signals[i].FirstBin, LastBin: signals[i].LastBin, SNRDb: signals[i].SNRDb}, spectrum, cfg.SampleRate, cfg.FFTSize, snip)
  567. signals[i].Class = cls
  568. }
  569. det.UpdateClasses(signals)
  570. }
  571. if sigSnap != nil {
  572. sigSnap.set(signals)
  573. }
  574. eventMu.Lock()
  575. for _, ev := range finished {
  576. _ = enc.Encode(ev)
  577. }
  578. eventMu.Unlock()
  579. if rec != nil && len(finished) > 0 {
  580. evCopy := make([]detector.Event, len(finished))
  581. copy(evCopy, finished)
  582. go rec.OnEvents(evCopy)
  583. }
  584. var debugInfo *SpectrumDebug
  585. if len(thresholds) > 0 || len(signals) > 0 || noiseFloor != 0 {
  586. scoreDebug := make([]map[string]any, 0, len(signals))
  587. for _, s := range signals {
  588. if s.Class == nil || len(s.Class.Scores) == 0 {
  589. scoreDebug = append(scoreDebug, map[string]any{
  590. "center_hz": s.CenterHz,
  591. "class": nil,
  592. })
  593. continue
  594. }
  595. scores := make(map[string]float64, len(s.Class.Scores))
  596. for k, v := range s.Class.Scores {
  597. scores[string(k)] = v
  598. }
  599. scoreDebug = append(scoreDebug, map[string]any{
  600. "center_hz": s.CenterHz,
  601. "mod_type": s.Class.ModType,
  602. "confidence": s.Class.Confidence,
  603. "second_best": s.Class.SecondBest,
  604. "scores": scores,
  605. })
  606. }
  607. debugInfo = &SpectrumDebug{
  608. Thresholds: thresholds,
  609. NoiseFloor: noiseFloor,
  610. Scores: scoreDebug,
  611. }
  612. }
  613. h.broadcast(SpectrumFrame{
  614. Timestamp: now.UnixMilli(),
  615. CenterHz: cfg.CenterHz,
  616. SampleHz: cfg.SampleRate,
  617. FFTSize: cfg.FFTSize,
  618. Spectrum: spectrum,
  619. Signals: signals,
  620. Debug: debugInfo,
  621. })
  622. }
  623. }
  624. }
  625. func mustParseDuration(raw string, fallback time.Duration) time.Duration {
  626. if raw == "" {
  627. return fallback
  628. }
  629. if d, err := time.ParseDuration(raw); err == nil {
  630. return d
  631. }
  632. return fallback
  633. }
  634. func buildDecoderMap(cfg config.Config) map[string]string {
  635. out := map[string]string{}
  636. if cfg.Decoder.FT8Cmd != "" {
  637. out["FT8"] = cfg.Decoder.FT8Cmd
  638. }
  639. if cfg.Decoder.WSPRCmd != "" {
  640. out["WSPR"] = cfg.Decoder.WSPRCmd
  641. }
  642. if cfg.Decoder.DMRCmd != "" {
  643. out["DMR"] = cfg.Decoder.DMRCmd
  644. }
  645. if cfg.Decoder.DStarCmd != "" {
  646. out["D-STAR"] = cfg.Decoder.DStarCmd
  647. }
  648. if cfg.Decoder.FSKCmd != "" {
  649. out["FSK"] = cfg.Decoder.FSKCmd
  650. }
  651. if cfg.Decoder.PSKCmd != "" {
  652. out["PSK"] = cfg.Decoder.PSKCmd
  653. }
  654. return out
  655. }
  656. func decoderKeys(cfg config.Config) []string {
  657. m := buildDecoderMap(cfg)
  658. keys := make([]string, 0, len(m))
  659. for k := range m {
  660. keys = append(keys, k)
  661. }
  662. sort.Strings(keys)
  663. return keys
  664. }
  665. func extractSignalIQ(iq []complex64, sampleRate int, centerHz float64, sigHz float64, bwHz float64) []complex64 {
  666. if len(iq) == 0 || sampleRate <= 0 {
  667. return nil
  668. }
  669. offset := sigHz - centerHz
  670. shifted := dsp.FreqShift(iq, sampleRate, offset)
  671. cutoff := bwHz / 2
  672. if cutoff < 200 {
  673. cutoff = 200
  674. }
  675. if cutoff > float64(sampleRate)/2-1 {
  676. cutoff = float64(sampleRate)/2 - 1
  677. }
  678. taps := dsp.LowpassFIR(cutoff, sampleRate, 101)
  679. filtered := dsp.ApplyFIR(shifted, taps)
  680. decim := sampleRate / 200000
  681. if decim < 1 {
  682. decim = 1
  683. }
  684. return dsp.Decimate(filtered, decim)
  685. }
  686. func parseSince(raw string) (time.Time, error) {
  687. if raw == "" {
  688. return time.Time{}, nil
  689. }
  690. if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
  691. if ms > 1e12 {
  692. return time.UnixMilli(ms), nil
  693. }
  694. return time.Unix(ms, 0), nil
  695. }
  696. if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
  697. return t, nil
  698. }
  699. return time.Parse(time.RFC3339, raw)
  700. }