Wideband autonomous SDR analysis engine forked from sdr-visual-suite
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

1944 lines
63KB

  1. package recorder
  2. import (
  3. "bufio"
  4. "encoding/binary"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "log"
  9. "math"
  10. "os"
  11. "path/filepath"
  12. "strconv"
  13. "strings"
  14. "sync"
  15. "time"
  16. "sdr-wideband-suite/internal/classifier"
  17. "sdr-wideband-suite/internal/demod"
  18. "sdr-wideband-suite/internal/detector"
  19. "sdr-wideband-suite/internal/dsp"
  20. "sdr-wideband-suite/internal/logging"
  21. "sdr-wideband-suite/internal/telemetry"
  22. )
  23. // ---------------------------------------------------------------------------
  24. // streamSession — one open demod session for one signal
  25. // ---------------------------------------------------------------------------
  26. type streamSession struct {
  27. sessionID string
  28. signalID int64
  29. centerHz float64
  30. bwHz float64
  31. snrDb float64
  32. peakDb float64
  33. class *classifier.Classification
  34. startTime time.Time
  35. lastFeed time.Time
  36. playbackMode string
  37. stereoState string
  38. lastAudioTs time.Time
  39. debugDumpStart time.Time
  40. debugDumpUntil time.Time
  41. debugDumpBase string
  42. demodDump []float32
  43. finalDump []float32
  44. lastAudioL float32
  45. lastAudioR float32
  46. prevAudioL float64 // second-to-last L sample for boundary transient detection
  47. lastAudioSet bool
  48. lastDecIQ complex64
  49. prevDecIQ complex64
  50. lastDecIQSet bool
  51. lastDemodL float32
  52. prevDemodL float64
  53. lastDemodSet bool
  54. snippetSeq uint64
  55. // listenOnly sessions have no WAV file and no disk I/O.
  56. // They exist solely to feed audio to live-listen subscribers.
  57. listenOnly bool
  58. // Recording state (nil/zero for listen-only sessions)
  59. dir string
  60. wavFile *os.File
  61. wavBuf *bufio.Writer
  62. wavSamples int64
  63. segmentIdx int
  64. sampleRate int // actual output audio sample rate (always streamAudioRate)
  65. channels int
  66. demodName string
  67. // --- Persistent DSP state for click-free streaming ---
  68. // Overlap-save: tail of previous extracted IQ snippet.
  69. // Currently unused for live demod after removing the extra discriminator
  70. // overlap prepend, but kept in DSP snapshot state for compatibility.
  71. overlapIQ []complex64
  72. // De-emphasis IIR state (persists across frames)
  73. deemphL float64
  74. deemphR float64
  75. // Stereo lock state for live WFM streaming
  76. stereoEnabled bool
  77. stereoOnCount int
  78. stereoOffCount int
  79. // Pilot-locked stereo PLL state (19kHz pilot)
  80. pilotPhase float64
  81. pilotFreq float64
  82. pilotAlpha float64
  83. pilotBeta float64
  84. pilotErrAvg float64
  85. pilotI float64
  86. pilotQ float64
  87. pilotLPAlpha float64
  88. // Polyphase resampler (replaces integer-decimate hack)
  89. monoResampler *dsp.Resampler
  90. monoResamplerRate int
  91. stereoResampler *dsp.StereoResampler
  92. stereoResamplerRate int
  93. // AQ-4: Stateful FIR filters for click-free stereo decode
  94. stereoFilterRate int
  95. stereoLPF *dsp.StatefulFIRReal // 15kHz lowpass for L+R
  96. stereoBPHi *dsp.StatefulFIRReal // 53kHz LP for bandpass high
  97. stereoBPLo *dsp.StatefulFIRReal // 23kHz LP for bandpass low
  98. stereoLRLPF *dsp.StatefulFIRReal // 15kHz LP for demodulated L-R
  99. stereoAALPF *dsp.StatefulFIRReal // Anti-alias LP for pre-decim (mono path)
  100. pilotLPFHi *dsp.StatefulFIRReal // ~21kHz LP for pilot bandpass high
  101. pilotLPFLo *dsp.StatefulFIRReal // ~17kHz LP for pilot bandpass low
  102. // Stateful pre-demod anti-alias FIR (eliminates cold-start transients
  103. // and avoids per-frame FIR recomputation)
  104. preDemodFIR *dsp.StatefulFIRComplex
  105. preDemodDecimator *dsp.StatefulDecimatingFIRComplex
  106. preDemodDecim int // cached decimation factor
  107. preDemodRate int // cached snipRate this FIR was built for
  108. preDemodCutoff float64 // cached cutoff
  109. preDemodDecimPhase int // retained for backward compatibility in snapshots/debug
  110. // AQ-2: De-emphasis config (µs, 0 = disabled)
  111. deemphasisUs float64
  112. // Scratch buffers — reused across frames to avoid GC pressure.
  113. // Grown as needed, never shrunk.
  114. scratchIQ []complex64 // for pre-demod FIR output + decimate input
  115. scratchAudio []float32 // for stereo decode intermediates
  116. scratchPCM []byte // for PCM encoding
  117. // live-listen subscribers
  118. audioSubs []audioSub
  119. }
  120. type audioSub struct {
  121. id int64
  122. ch chan []byte
  123. }
  124. type RuntimeSignalInfo struct {
  125. DemodName string
  126. PlaybackMode string
  127. StereoState string
  128. Channels int
  129. SampleRate int
  130. }
  131. // AudioInfo describes the audio format of a live-listen subscription.
  132. // Sent to the WebSocket client as the first message.
  133. type AudioInfo struct {
  134. SampleRate int `json:"sample_rate"`
  135. Channels int `json:"channels"`
  136. Format string `json:"format"` // always "s16le"
  137. DemodName string `json:"demod"`
  138. PlaybackMode string `json:"playback_mode,omitempty"`
  139. StereoState string `json:"stereo_state,omitempty"`
  140. }
  141. const (
  142. streamAudioRate = 48000
  143. resamplerTaps = 32 // taps per polyphase arm — good quality
  144. )
  145. var debugDumpDelay = func() time.Duration {
  146. raw := strings.TrimSpace(os.Getenv("SDR_DEBUG_DUMP_DELAY_SECONDS"))
  147. if raw == "" {
  148. return 5 * time.Second
  149. }
  150. v, err := strconv.Atoi(raw)
  151. if err != nil || v < 0 {
  152. return 5 * time.Second
  153. }
  154. return time.Duration(v) * time.Second
  155. }()
  156. var debugDumpDuration = func() time.Duration {
  157. raw := strings.TrimSpace(os.Getenv("SDR_DEBUG_DUMP_DURATION_SECONDS"))
  158. if raw == "" {
  159. return 15 * time.Second
  160. }
  161. v, err := strconv.Atoi(raw)
  162. if err != nil || v <= 0 {
  163. return 15 * time.Second
  164. }
  165. return time.Duration(v) * time.Second
  166. }()
  167. var audioDumpEnabled = func() bool {
  168. raw := strings.TrimSpace(os.Getenv("SDR_DEBUG_AUDIO_DUMP_ENABLED"))
  169. if raw == "" {
  170. return false
  171. }
  172. v, err := strconv.ParseBool(raw)
  173. if err != nil {
  174. return false
  175. }
  176. return v
  177. }()
  178. var decHeadTrimSamples = func() int {
  179. raw := strings.TrimSpace(os.Getenv("SDR_DEC_HEAD_TRIM"))
  180. if raw == "" {
  181. return 0
  182. }
  183. v, err := strconv.Atoi(raw)
  184. if err != nil || v < 0 {
  185. return 0
  186. }
  187. return v
  188. }()
  189. // ---------------------------------------------------------------------------
  190. // Streamer — manages all active streaming sessions
  191. // ---------------------------------------------------------------------------
  192. type streamFeedItem struct {
  193. signal detector.Signal
  194. snippet []complex64
  195. snipRate int
  196. }
  197. type streamFeedMsg struct {
  198. traceID uint64
  199. items []streamFeedItem
  200. enqueuedAt time.Time
  201. }
  202. type Streamer struct {
  203. mu sync.Mutex
  204. sessions map[int64]*streamSession
  205. policy Policy
  206. centerHz float64
  207. nextSub int64
  208. feedCh chan streamFeedMsg
  209. done chan struct{}
  210. droppedFeed uint64
  211. droppedPCM uint64
  212. lastFeedTS time.Time
  213. lastProcTS time.Time
  214. // pendingListens are subscribers waiting for a matching session.
  215. pendingListens map[int64]*pendingListen
  216. telemetry *telemetry.Collector
  217. }
  218. type pendingListen struct {
  219. freq float64
  220. bw float64
  221. mode string
  222. ch chan []byte
  223. }
  224. func newStreamer(policy Policy, centerHz float64, coll *telemetry.Collector) *Streamer {
  225. st := &Streamer{
  226. sessions: make(map[int64]*streamSession),
  227. policy: policy,
  228. centerHz: centerHz,
  229. feedCh: make(chan streamFeedMsg, 2),
  230. done: make(chan struct{}),
  231. pendingListens: make(map[int64]*pendingListen),
  232. telemetry: coll,
  233. }
  234. go st.worker()
  235. return st
  236. }
  237. func (st *Streamer) worker() {
  238. for msg := range st.feedCh {
  239. st.processFeed(msg)
  240. }
  241. close(st.done)
  242. }
  243. func (st *Streamer) updatePolicy(policy Policy, centerHz float64) {
  244. st.mu.Lock()
  245. defer st.mu.Unlock()
  246. wasEnabled := st.policy.Enabled
  247. st.policy = policy
  248. st.centerHz = centerHz
  249. // If recording was just disabled, close recording sessions
  250. // but keep listen-only sessions alive.
  251. if wasEnabled && !policy.Enabled {
  252. for id, sess := range st.sessions {
  253. if sess.listenOnly {
  254. continue
  255. }
  256. if len(sess.audioSubs) > 0 {
  257. // Convert to listen-only: close WAV but keep session
  258. convertToListenOnly(sess)
  259. } else {
  260. closeSession(sess, &st.policy)
  261. delete(st.sessions, id)
  262. }
  263. }
  264. }
  265. }
  266. // HasListeners returns true if any sessions have audio subscribers
  267. // or there are pending listen requests. Used by the DSP loop to
  268. // decide whether to feed snippets even when recording is disabled.
  269. func (st *Streamer) HasListeners() bool {
  270. st.mu.Lock()
  271. defer st.mu.Unlock()
  272. return st.hasListenersLocked()
  273. }
  274. func (st *Streamer) hasListenersLocked() bool {
  275. if len(st.pendingListens) > 0 {
  276. return true
  277. }
  278. for _, sess := range st.sessions {
  279. if len(sess.audioSubs) > 0 {
  280. return true
  281. }
  282. }
  283. return false
  284. }
  285. // FeedSnippets is called from the DSP loop with pre-extracted IQ snippets.
  286. // Feeds are accepted if:
  287. // - Recording is enabled (policy.Enabled && RecordAudio/RecordIQ), OR
  288. // - Any live-listen subscribers exist (listen-only mode)
  289. //
  290. // IMPORTANT: The caller (Manager.FeedSnippets) already copies the snippet
  291. // data, so items can be passed directly without another copy.
  292. func (st *Streamer) FeedSnippets(items []streamFeedItem, traceID uint64) {
  293. st.mu.Lock()
  294. recEnabled := st.policy.Enabled && (st.policy.RecordAudio || st.policy.RecordIQ)
  295. hasListeners := st.hasListenersLocked()
  296. pending := len(st.pendingListens)
  297. debugLiveAudio := st.policy.DebugLiveAudio
  298. now := time.Now()
  299. if !st.lastFeedTS.IsZero() {
  300. gap := now.Sub(st.lastFeedTS)
  301. if gap > 150*time.Millisecond {
  302. logging.Warn("gap", "feed_gap", "gap_ms", gap.Milliseconds())
  303. }
  304. }
  305. st.lastFeedTS = now
  306. st.mu.Unlock()
  307. if debugLiveAudio {
  308. log.Printf("LIVEAUDIO STREAM: feedSnippets items=%d recEnabled=%v hasListeners=%v pending=%d", len(items), recEnabled, hasListeners, pending)
  309. }
  310. if (!recEnabled && !hasListeners) || len(items) == 0 {
  311. return
  312. }
  313. if st.telemetry != nil {
  314. st.telemetry.SetGauge("streamer.feed.queue_len", float64(len(st.feedCh)), nil)
  315. st.telemetry.SetGauge("streamer.pending_listeners", float64(pending), nil)
  316. st.telemetry.Observe("streamer.feed.batch_size", float64(len(items)), nil)
  317. }
  318. select {
  319. case st.feedCh <- streamFeedMsg{traceID: traceID, items: items, enqueuedAt: time.Now()}:
  320. default:
  321. st.droppedFeed++
  322. logging.Warn("drop", "feed_drop", "count", st.droppedFeed)
  323. if st.telemetry != nil {
  324. st.telemetry.IncCounter("streamer.feed.drop", 1, nil)
  325. st.telemetry.Event("stream_feed_drop", "warn", "feed queue full", nil, map[string]any{
  326. "trace_id": traceID,
  327. "queue_len": len(st.feedCh),
  328. })
  329. }
  330. }
  331. }
  332. // processFeed runs in the worker goroutine.
  333. func (st *Streamer) processFeed(msg streamFeedMsg) {
  334. procStart := time.Now()
  335. lockStart := time.Now()
  336. st.mu.Lock()
  337. lockWait := time.Since(lockStart)
  338. recEnabled := st.policy.Enabled && (st.policy.RecordAudio || st.policy.RecordIQ)
  339. hasListeners := st.hasListenersLocked()
  340. now := time.Now()
  341. if !st.lastProcTS.IsZero() {
  342. gap := now.Sub(st.lastProcTS)
  343. if gap > 150*time.Millisecond {
  344. logging.Warn("gap", "process_gap", "gap_ms", gap.Milliseconds(), "trace", msg.traceID)
  345. if st.telemetry != nil {
  346. st.telemetry.IncCounter("streamer.process.gap.count", 1, nil)
  347. st.telemetry.Observe("streamer.process.gap_ms", float64(gap.Milliseconds()), nil)
  348. }
  349. }
  350. }
  351. st.lastProcTS = now
  352. defer st.mu.Unlock()
  353. defer func() {
  354. if st.telemetry != nil {
  355. st.telemetry.Observe("streamer.process.total_ms", float64(time.Since(procStart).Microseconds())/1000.0, nil)
  356. st.telemetry.Observe("streamer.lock_wait_ms", float64(lockWait.Microseconds())/1000.0, telemetry.TagsFromPairs("lock", "process"))
  357. }
  358. }()
  359. if st.telemetry != nil {
  360. st.telemetry.Observe("streamer.feed.enqueue_delay_ms", float64(now.Sub(msg.enqueuedAt).Microseconds())/1000.0, nil)
  361. st.telemetry.SetGauge("streamer.sessions.active", float64(len(st.sessions)), nil)
  362. }
  363. logging.Debug("trace", "process_feed", "trace", msg.traceID, "items", len(msg.items))
  364. if !recEnabled && !hasListeners {
  365. return
  366. }
  367. seen := make(map[int64]bool, len(msg.items))
  368. for i := range msg.items {
  369. item := &msg.items[i]
  370. sig := &item.signal
  371. seen[sig.ID] = true
  372. if sig.ID == 0 || sig.Class == nil {
  373. continue
  374. }
  375. if len(item.snippet) == 0 || item.snipRate <= 0 {
  376. continue
  377. }
  378. // Decide whether this signal needs a session
  379. needsRecording := recEnabled && sig.SNRDb >= st.policy.MinSNRDb && st.classAllowed(sig.Class)
  380. needsListen := st.signalHasListenerLocked(sig)
  381. className := "<nil>"
  382. demodName := ""
  383. if sig.Class != nil {
  384. className = string(sig.Class.ModType)
  385. demodName, _ = resolveDemod(sig)
  386. }
  387. if st.policy.DebugLiveAudio {
  388. log.Printf("LIVEAUDIO STREAM: signal id=%d center=%.3fMHz bw=%.0f snr=%.1f class=%s demod=%s needsRecord=%v needsListen=%v", sig.ID, sig.CenterHz/1e6, sig.BWHz, sig.SNRDb, className, demodName, needsRecording, needsListen)
  389. }
  390. if !needsRecording && !needsListen {
  391. continue
  392. }
  393. sess, exists := st.sessions[sig.ID]
  394. requestedMode := ""
  395. for _, pl := range st.pendingListens {
  396. if math.Abs(sig.CenterHz-pl.freq) < 200000 {
  397. if m := normalizeRequestedMode(pl.mode); m != "" {
  398. requestedMode = m
  399. break
  400. }
  401. }
  402. }
  403. if exists && sess.listenOnly && requestedMode != "" && sess.demodName != requestedMode {
  404. for _, sub := range sess.audioSubs {
  405. st.pendingListens[sub.id] = &pendingListen{freq: sig.CenterHz, bw: sig.BWHz, mode: requestedMode, ch: sub.ch}
  406. }
  407. delete(st.sessions, sig.ID)
  408. sess = nil
  409. exists = false
  410. }
  411. if !exists {
  412. if needsRecording {
  413. s, err := st.openRecordingSession(sig, now)
  414. if err != nil {
  415. log.Printf("STREAM: open failed signal=%d %.1fMHz: %v",
  416. sig.ID, sig.CenterHz/1e6, err)
  417. if st.telemetry != nil {
  418. st.telemetry.IncCounter("streamer.session.open_error", 1, telemetry.TagsFromPairs("kind", "recording"))
  419. }
  420. continue
  421. }
  422. st.sessions[sig.ID] = s
  423. sess = s
  424. } else {
  425. s := st.openListenSession(sig, now)
  426. st.sessions[sig.ID] = s
  427. sess = s
  428. }
  429. // Attach any pending listeners
  430. st.attachPendingListeners(sess)
  431. if st.telemetry != nil {
  432. st.telemetry.IncCounter("streamer.session.open", 1, telemetry.TagsFromPairs("session_id", sess.sessionID, "signal_id", fmt.Sprintf("%d", sig.ID)))
  433. st.telemetry.Event("session_open", "info", "stream session opened", telemetry.TagsFromPairs("session_id", sess.sessionID, "signal_id", fmt.Sprintf("%d", sig.ID)), map[string]any{
  434. "listen_only": sess.listenOnly,
  435. "demod": sess.demodName,
  436. })
  437. }
  438. }
  439. // Update metadata
  440. sess.lastFeed = now
  441. sess.centerHz = sig.CenterHz
  442. sess.bwHz = sig.BWHz
  443. if sig.SNRDb > sess.snrDb {
  444. sess.snrDb = sig.SNRDb
  445. }
  446. if sig.PeakDb > sess.peakDb {
  447. sess.peakDb = sig.PeakDb
  448. }
  449. if sig.Class != nil {
  450. sess.class = sig.Class
  451. }
  452. // Demod with persistent state
  453. logging.Debug("trace", "demod_start", "trace", msg.traceID, "signal", sess.signalID, "snip_len", len(item.snippet), "snip_rate", item.snipRate)
  454. audioStart := time.Now()
  455. audio, audioRate := sess.processSnippet(item.snippet, item.snipRate, st.telemetry)
  456. if st.telemetry != nil {
  457. st.telemetry.Observe("streamer.process_snippet_ms", float64(time.Since(audioStart).Microseconds())/1000.0, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  458. }
  459. logging.Debug("trace", "demod_done", "trace", msg.traceID, "signal", sess.signalID, "audio_len", len(audio), "audio_rate", audioRate)
  460. if len(audio) == 0 {
  461. logging.Warn("gap", "audio_empty", "signal", sess.signalID, "snip_len", len(item.snippet), "snip_rate", item.snipRate)
  462. if st.telemetry != nil {
  463. st.telemetry.IncCounter("streamer.audio.empty", 1, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID)))
  464. }
  465. }
  466. if len(audio) > 0 {
  467. if sess.wavSamples == 0 && audioRate > 0 {
  468. sess.sampleRate = audioRate
  469. }
  470. // Encode PCM once into scratch buffer, reuse for both WAV and fanout
  471. pcmLen := len(audio) * 2
  472. pcm := sess.growPCM(pcmLen)
  473. for k, s := range audio {
  474. v := int16(clip(s * 32767))
  475. binary.LittleEndian.PutUint16(pcm[k*2:], uint16(v))
  476. }
  477. if !sess.listenOnly && sess.wavBuf != nil {
  478. n, err := sess.wavBuf.Write(pcm)
  479. if err != nil {
  480. log.Printf("STREAM: write error signal=%d: %v", sess.signalID, err)
  481. } else {
  482. sess.wavSamples += int64(n / 2)
  483. }
  484. }
  485. // Gap logging for live-audio sessions + transient click detector
  486. if len(sess.audioSubs) > 0 {
  487. if !sess.lastAudioTs.IsZero() {
  488. gap := time.Since(sess.lastAudioTs)
  489. if gap > 150*time.Millisecond {
  490. logging.Warn("gap", "audio_gap", "signal", sess.signalID, "gap_ms", gap.Milliseconds())
  491. if st.telemetry != nil {
  492. st.telemetry.IncCounter("streamer.audio.gap.count", 1, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID)))
  493. st.telemetry.Observe("streamer.audio.gap_ms", float64(gap.Milliseconds()), telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID)))
  494. }
  495. }
  496. }
  497. // Transient click detector: finds short impulses (1-3 samples)
  498. // that deviate sharply from the local signal trend.
  499. // A click looks like: ...smooth... SPIKE ...smooth...
  500. // Normal FM audio has large deltas too, but they follow
  501. // a continuous curve. A click has high |d2/dt2| (acceleration).
  502. //
  503. // Method: second-derivative detector. For each sample triplet
  504. // (a, b, c), compute |2b - a - c| which is the discrete
  505. // second derivative magnitude. High values = transient spike.
  506. // Threshold: 0.15 (tuned to reject normal FM content <15kHz).
  507. if logging.EnabledCategory("boundary") && len(audio) > 0 {
  508. stride := sess.channels
  509. if stride < 1 {
  510. stride = 1
  511. }
  512. nFrames := len(audio) / stride
  513. // Boundary transient: use last 2 samples of prev frame + first sample of this frame
  514. if sess.lastAudioSet && nFrames >= 1 {
  515. // second derivative across boundary: |2*last - prevLast - first|
  516. first := float64(audio[0])
  517. d2 := math.Abs(2*float64(sess.lastAudioL) - sess.prevAudioL - first)
  518. if d2 > 0.15 {
  519. logging.Warn("boundary", "boundary_click", "signal", sess.signalID, "d2", d2)
  520. if st.telemetry != nil {
  521. st.telemetry.IncCounter("audio.boundary_click.count", 1, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  522. st.telemetry.Observe("audio.boundary_click.d2", d2, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID)))
  523. }
  524. }
  525. }
  526. // Intra-frame transient scan (L channel only for performance)
  527. nClicks := 0
  528. maxD2 := float64(0)
  529. maxD2Pos := 0
  530. for k := 1; k < nFrames-1; k++ {
  531. a := float64(audio[(k-1)*stride])
  532. b := float64(audio[k*stride])
  533. c := float64(audio[(k+1)*stride])
  534. d2 := math.Abs(2*b - a - c)
  535. if d2 > maxD2 {
  536. maxD2 = d2
  537. maxD2Pos = k
  538. }
  539. if d2 > 0.15 {
  540. nClicks++
  541. }
  542. }
  543. if nClicks > 0 {
  544. logging.Warn("boundary", "intra_click", "signal", sess.signalID, "clicks", nClicks, "maxD2", maxD2, "pos", maxD2Pos, "len", nFrames)
  545. if st.telemetry != nil {
  546. st.telemetry.IncCounter("audio.intra_click.count", float64(nClicks), telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  547. st.telemetry.Observe("audio.intra_click.max_d2", maxD2, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID)))
  548. }
  549. }
  550. // Store last two samples for next frame's boundary check
  551. if nFrames >= 2 {
  552. sess.prevAudioL = float64(audio[(nFrames-2)*stride])
  553. sess.lastAudioL = audio[(nFrames-1)*stride]
  554. if stride > 1 {
  555. sess.lastAudioR = audio[(nFrames-1)*stride+1]
  556. }
  557. } else if nFrames == 1 {
  558. sess.prevAudioL = float64(sess.lastAudioL)
  559. sess.lastAudioL = audio[0]
  560. if stride > 1 && len(audio) >= 2 {
  561. sess.lastAudioR = audio[1]
  562. }
  563. }
  564. sess.lastAudioSet = true
  565. }
  566. sess.lastAudioTs = time.Now()
  567. }
  568. st.fanoutPCM(sess, pcm, pcmLen)
  569. }
  570. // Segment split (recording sessions only)
  571. if !sess.listenOnly && st.policy.MaxDuration > 0 && now.Sub(sess.startTime) >= st.policy.MaxDuration {
  572. segIdx := sess.segmentIdx + 1
  573. oldSubs := sess.audioSubs
  574. oldState := sess.captureDSPState()
  575. sess.audioSubs = nil
  576. closeSession(sess, &st.policy)
  577. s, err := st.openRecordingSession(sig, now)
  578. if err != nil {
  579. delete(st.sessions, sig.ID)
  580. continue
  581. }
  582. s.segmentIdx = segIdx
  583. s.audioSubs = oldSubs
  584. s.restoreDSPState(oldState)
  585. st.sessions[sig.ID] = s
  586. if st.telemetry != nil {
  587. st.telemetry.IncCounter("streamer.session.reopen", 1, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sig.ID)))
  588. st.telemetry.Event("session_reopen", "info", "stream session rotated by max duration", telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sig.ID)), map[string]any{
  589. "old_session": sess.sessionID,
  590. "new_session": s.sessionID,
  591. })
  592. }
  593. }
  594. }
  595. // Close sessions for disappeared signals (with grace period)
  596. for id, sess := range st.sessions {
  597. if seen[id] {
  598. continue
  599. }
  600. gracePeriod := 3 * time.Second
  601. if sess.listenOnly {
  602. gracePeriod = 5 * time.Second
  603. }
  604. if now.Sub(sess.lastFeed) > gracePeriod {
  605. for _, sub := range sess.audioSubs {
  606. close(sub.ch)
  607. }
  608. sess.audioSubs = nil
  609. if !sess.listenOnly {
  610. closeSession(sess, &st.policy)
  611. }
  612. if st.telemetry != nil {
  613. st.telemetry.IncCounter("streamer.session.close", 1, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", id), "session_id", sess.sessionID))
  614. st.telemetry.Event("session_close", "info", "stream session closed", telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", id), "session_id", sess.sessionID), map[string]any{
  615. "reason": "signal_missing",
  616. "listen_only": sess.listenOnly,
  617. })
  618. }
  619. delete(st.sessions, id)
  620. }
  621. }
  622. }
  623. func (st *Streamer) signalHasListenerLocked(sig *detector.Signal) bool {
  624. if sess, ok := st.sessions[sig.ID]; ok && len(sess.audioSubs) > 0 {
  625. if st.policy.DebugLiveAudio {
  626. log.Printf("LIVEAUDIO MATCH: signal id=%d matched existing session listener center=%.3fMHz", sig.ID, sig.CenterHz/1e6)
  627. }
  628. return true
  629. }
  630. for subID, pl := range st.pendingListens {
  631. delta := math.Abs(sig.CenterHz - pl.freq)
  632. if delta < 200000 {
  633. if st.policy.DebugLiveAudio {
  634. log.Printf("LIVEAUDIO MATCH: signal id=%d matched pending subscriber=%d center=%.3fMHz req=%.3fMHz delta=%.0fHz", sig.ID, subID, sig.CenterHz/1e6, pl.freq/1e6, delta)
  635. }
  636. return true
  637. }
  638. }
  639. return false
  640. }
  641. func (st *Streamer) attachPendingListeners(sess *streamSession) {
  642. for subID, pl := range st.pendingListens {
  643. requestedMode := normalizeRequestedMode(pl.mode)
  644. if requestedMode != "" && sess.demodName != requestedMode {
  645. continue
  646. }
  647. if math.Abs(sess.centerHz-pl.freq) < 200000 {
  648. sess.audioSubs = append(sess.audioSubs, audioSub{id: subID, ch: pl.ch})
  649. delete(st.pendingListens, subID)
  650. // Send updated audio_info now that we know the real session params.
  651. // Prefix with 0x00 tag byte so ws/audio handler sends as TextMessage.
  652. infoJSON, _ := json.Marshal(sess.audioInfo())
  653. tagged := make([]byte, 1+len(infoJSON))
  654. tagged[0] = 0x00 // tag: audio_info
  655. copy(tagged[1:], infoJSON)
  656. select {
  657. case pl.ch <- tagged:
  658. default:
  659. }
  660. if audioDumpEnabled {
  661. now := time.Now()
  662. sess.debugDumpStart = now.Add(debugDumpDelay)
  663. sess.debugDumpUntil = sess.debugDumpStart.Add(debugDumpDuration)
  664. sess.debugDumpBase = filepath.Join("debug", fmt.Sprintf("signal-%d-window-%s", sess.signalID, now.Format("20060102-150405")))
  665. sess.demodDump = nil
  666. sess.finalDump = nil
  667. }
  668. log.Printf("STREAM: attached pending listener %d to signal %d (%.1fMHz %s ch=%d)",
  669. subID, sess.signalID, sess.centerHz/1e6, sess.demodName, sess.channels)
  670. if audioDumpEnabled {
  671. log.Printf("STREAM: debug dump armed signal=%d start=%s until=%s", sess.signalID, sess.debugDumpStart.Format(time.RFC3339), sess.debugDumpUntil.Format(time.RFC3339))
  672. }
  673. }
  674. }
  675. }
  676. // CloseAll finalises all sessions and stops the worker goroutine.
  677. func (st *Streamer) RuntimeInfoBySignalID() map[int64]RuntimeSignalInfo {
  678. st.mu.Lock()
  679. defer st.mu.Unlock()
  680. out := make(map[int64]RuntimeSignalInfo, len(st.sessions))
  681. for _, sess := range st.sessions {
  682. out[sess.signalID] = RuntimeSignalInfo{
  683. DemodName: sess.demodName,
  684. PlaybackMode: sess.playbackMode,
  685. StereoState: sess.stereoState,
  686. Channels: sess.channels,
  687. SampleRate: sess.sampleRate,
  688. }
  689. }
  690. return out
  691. }
  692. func (st *Streamer) CloseAll() {
  693. close(st.feedCh)
  694. <-st.done
  695. st.mu.Lock()
  696. defer st.mu.Unlock()
  697. for id, sess := range st.sessions {
  698. for _, sub := range sess.audioSubs {
  699. close(sub.ch)
  700. }
  701. sess.audioSubs = nil
  702. if !sess.listenOnly {
  703. closeSession(sess, &st.policy)
  704. }
  705. if st.telemetry != nil {
  706. st.telemetry.IncCounter("streamer.session.close", 1, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", id), "session_id", sess.sessionID))
  707. }
  708. delete(st.sessions, id)
  709. }
  710. for _, pl := range st.pendingListens {
  711. close(pl.ch)
  712. }
  713. st.pendingListens = nil
  714. if st.telemetry != nil {
  715. st.telemetry.Event("streamer_close_all", "info", "all stream sessions closed", nil, nil)
  716. }
  717. }
  718. // ActiveSessions returns the number of open streaming sessions.
  719. func (st *Streamer) ActiveSessions() int {
  720. st.mu.Lock()
  721. defer st.mu.Unlock()
  722. return len(st.sessions)
  723. }
  724. // SubscribeAudio registers a live-listen subscriber for a given frequency.
  725. //
  726. // LL-2: Returns AudioInfo with correct channels and sample rate.
  727. // LL-3: Returns error only on hard failures (nil streamer etc).
  728. //
  729. // If a matching session exists, attaches immediately. Otherwise, the
  730. // subscriber is held as "pending" and will be attached when a matching
  731. // signal appears in the next DSP frame.
  732. func (st *Streamer) SubscribeAudio(freq float64, bw float64, mode string) (int64, <-chan []byte, AudioInfo, error) {
  733. ch := make(chan []byte, 64)
  734. st.mu.Lock()
  735. defer st.mu.Unlock()
  736. st.nextSub++
  737. subID := st.nextSub
  738. requestedMode := normalizeRequestedMode(mode)
  739. // Try to find a matching session
  740. var bestSess *streamSession
  741. bestDist := math.MaxFloat64
  742. for _, sess := range st.sessions {
  743. if requestedMode != "" && sess.demodName != requestedMode {
  744. continue
  745. }
  746. d := math.Abs(sess.centerHz - freq)
  747. if d < bestDist {
  748. bestDist = d
  749. bestSess = sess
  750. }
  751. }
  752. if bestSess != nil && bestDist < 200000 {
  753. bestSess.audioSubs = append(bestSess.audioSubs, audioSub{id: subID, ch: ch})
  754. if audioDumpEnabled {
  755. now := time.Now()
  756. bestSess.debugDumpStart = now.Add(debugDumpDelay)
  757. bestSess.debugDumpUntil = bestSess.debugDumpStart.Add(debugDumpDuration)
  758. bestSess.debugDumpBase = filepath.Join("debug", fmt.Sprintf("signal-%d-window-%s", bestSess.signalID, now.Format("20060102-150405")))
  759. bestSess.demodDump = nil
  760. bestSess.finalDump = nil
  761. }
  762. info := bestSess.audioInfo()
  763. log.Printf("STREAM: subscriber %d attached to signal %d (%.1fMHz %s)",
  764. subID, bestSess.signalID, bestSess.centerHz/1e6, bestSess.demodName)
  765. if audioDumpEnabled {
  766. log.Printf("STREAM: debug dump armed signal=%d start=%s until=%s", bestSess.signalID, bestSess.debugDumpStart.Format(time.RFC3339), bestSess.debugDumpUntil.Format(time.RFC3339))
  767. }
  768. if st.telemetry != nil {
  769. st.telemetry.IncCounter("streamer.listener.attach", 1, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", bestSess.signalID), "session_id", bestSess.sessionID))
  770. }
  771. return subID, ch, info, nil
  772. }
  773. // No matching session yet — add as pending listener
  774. st.pendingListens[subID] = &pendingListen{
  775. freq: freq,
  776. bw: bw,
  777. mode: mode,
  778. ch: ch,
  779. }
  780. info := defaultAudioInfoForMode(mode)
  781. log.Printf("STREAM: subscriber %d pending (freq=%.1fMHz)", subID, freq/1e6)
  782. log.Printf("LIVEAUDIO MATCH: subscriber=%d pending req=%.3fMHz bw=%.0f mode=%s", subID, freq/1e6, bw, mode)
  783. if st.telemetry != nil {
  784. st.telemetry.IncCounter("streamer.listener.pending", 1, nil)
  785. st.telemetry.SetGauge("streamer.pending_listeners", float64(len(st.pendingListens)), nil)
  786. }
  787. return subID, ch, info, nil
  788. }
  789. // UnsubscribeAudio removes a live-listen subscriber.
  790. func (st *Streamer) UnsubscribeAudio(subID int64) {
  791. st.mu.Lock()
  792. defer st.mu.Unlock()
  793. if pl, ok := st.pendingListens[subID]; ok {
  794. close(pl.ch)
  795. delete(st.pendingListens, subID)
  796. if st.telemetry != nil {
  797. st.telemetry.IncCounter("streamer.listener.unsubscribe", 1, telemetry.TagsFromPairs("kind", "pending"))
  798. st.telemetry.SetGauge("streamer.pending_listeners", float64(len(st.pendingListens)), nil)
  799. }
  800. return
  801. }
  802. for _, sess := range st.sessions {
  803. for i, sub := range sess.audioSubs {
  804. if sub.id == subID {
  805. close(sub.ch)
  806. sess.audioSubs = append(sess.audioSubs[:i], sess.audioSubs[i+1:]...)
  807. if st.telemetry != nil {
  808. st.telemetry.IncCounter("streamer.listener.unsubscribe", 1, telemetry.TagsFromPairs("kind", "active", "session_id", sess.sessionID))
  809. }
  810. return
  811. }
  812. }
  813. }
  814. }
  815. // ---------------------------------------------------------------------------
  816. // Session: stateful extraction + demod
  817. // ---------------------------------------------------------------------------
  818. // processSnippet takes a pre-extracted IQ snippet and demodulates it with
  819. // persistent state. Uses stateful FIR + polyphase resampler for exact 48kHz
  820. // output with zero transient artifacts.
  821. func (sess *streamSession) processSnippet(snippet []complex64, snipRate int, coll *telemetry.Collector) ([]float32, int) {
  822. if len(snippet) == 0 || snipRate <= 0 {
  823. return nil, 0
  824. }
  825. if coll != nil {
  826. coll.SetGauge("iq.stage.snippet.length", float64(len(snippet)), telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  827. }
  828. isWFMStereo := sess.demodName == "WFM_STEREO"
  829. isWFM := sess.demodName == "WFM" || isWFMStereo
  830. demodName := sess.demodName
  831. if isWFMStereo {
  832. demodName = "WFM"
  833. }
  834. d := demod.Get(demodName)
  835. if d == nil {
  836. d = demod.Get("NFM")
  837. }
  838. if d == nil {
  839. return nil, 0
  840. }
  841. // The extra 1-sample discriminator overlap prepend was removed after it was
  842. // shown to shift the downstream decimation phase and create heavy click
  843. // artifacts in steady-state streaming/recording. The upstream extraction path
  844. // and the stateful FIR/decimation stages already provide continuity.
  845. fullSnip := snippet
  846. overlapApplied := false
  847. prevTailValid := false
  848. if logging.EnabledCategory("prefir") && len(fullSnip) > 0 {
  849. probeN := 64
  850. if len(fullSnip) < probeN {
  851. probeN = len(fullSnip)
  852. }
  853. minPreMag := math.MaxFloat64
  854. minPreIdx := 0
  855. maxPreStep := 0.0
  856. maxPreStepIdx := 0
  857. for i := 0; i < probeN; i++ {
  858. v := fullSnip[i]
  859. mag := math.Hypot(float64(real(v)), float64(imag(v)))
  860. if mag < minPreMag {
  861. minPreMag = mag
  862. minPreIdx = i
  863. }
  864. if i > 0 {
  865. p := fullSnip[i-1]
  866. num := float64(real(p))*float64(imag(v)) - float64(imag(p))*float64(real(v))
  867. den := float64(real(p))*float64(real(v)) + float64(imag(p))*float64(imag(v))
  868. step := math.Abs(math.Atan2(num, den))
  869. if step > maxPreStep {
  870. maxPreStep = step
  871. maxPreStepIdx = i - 1
  872. }
  873. }
  874. }
  875. logging.Debug("prefir", "pre_fir_head_probe", "signal", sess.signalID, "probe_len", probeN, "min_mag", minPreMag, "min_idx", minPreIdx, "max_step", maxPreStep, "max_step_idx", maxPreStepIdx, "snip_len", len(fullSnip))
  876. if minPreMag < 0.18 {
  877. logging.Warn("prefir", "pre_fir_head_dip", "signal", sess.signalID, "probe_len", probeN, "min_mag", minPreMag, "min_idx", minPreIdx, "max_step", maxPreStep, "max_step_idx", maxPreStepIdx)
  878. }
  879. if maxPreStep > 1.5 {
  880. logging.Warn("prefir", "pre_fir_head_step", "signal", sess.signalID, "probe_len", probeN, "max_step", maxPreStep, "max_step_idx", maxPreStepIdx, "min_mag", minPreMag, "min_idx", minPreIdx)
  881. }
  882. }
  883. // --- Stateful anti-alias FIR + decimation to demod rate ---
  884. demodRate := d.OutputSampleRate()
  885. decim1 := int(math.Round(float64(snipRate) / float64(demodRate)))
  886. if decim1 < 1 {
  887. decim1 = 1
  888. }
  889. // WFM override: force decim1=2 (256kHz) instead of round(512k/192k)=3 (170kHz).
  890. // At decim1=3, Nyquist is 85kHz which clips FM broadcast ±75kHz deviation.
  891. // At decim1=2, Nyquist is 128kHz → full FM deviation + stereo pilot + guard band.
  892. // Bonus: 256000→48000 resampler ratio is L=3/M=16 (96 taps, 1kB) instead of
  893. // the pathological L=24000/M=85333 (768k taps, 6MB) from 170666→48000.
  894. if isWFM && decim1 > 2 && snipRate/2 >= 200000 {
  895. decim1 = 2
  896. }
  897. actualDemodRate := snipRate / decim1
  898. logging.Debug("demod", "rates", "snipRate", snipRate, "decim1", decim1, "actual", actualDemodRate)
  899. var dec []complex64
  900. if decim1 > 1 {
  901. // FIR cutoff: for WFM, use 90kHz (above ±75kHz FM deviation + guard).
  902. // For NFM/other: use standard Nyquist*0.8 cutoff.
  903. cutoff := float64(actualDemodRate) / 2.0 * 0.8
  904. if isWFM {
  905. cutoff = 90000
  906. }
  907. // Lazy-init or reinit stateful FIR if parameters changed
  908. if sess.preDemodDecimator == nil || sess.preDemodRate != snipRate || sess.preDemodCutoff != cutoff || sess.preDemodDecim != decim1 {
  909. taps := dsp.LowpassFIR(cutoff, snipRate, 101)
  910. sess.preDemodFIR = dsp.NewStatefulFIRComplex(taps)
  911. sess.preDemodDecimator = dsp.NewStatefulDecimatingFIRComplex(taps, decim1)
  912. sess.preDemodRate = snipRate
  913. sess.preDemodCutoff = cutoff
  914. sess.preDemodDecim = decim1
  915. sess.preDemodDecimPhase = 0
  916. if coll != nil {
  917. coll.IncCounter("dsp.pre_demod.init", 1, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  918. coll.Event("prefir_reinit", "info", "pre-demod decimator reinitialized", telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID), map[string]any{
  919. "snip_rate": snipRate,
  920. "cutoff_hz": cutoff,
  921. "decim": decim1,
  922. })
  923. }
  924. }
  925. decimPhaseBefore := sess.preDemodDecimPhase
  926. filtered := sess.preDemodFIR.ProcessInto(fullSnip, sess.growIQ(len(fullSnip)))
  927. dec = sess.preDemodDecimator.Process(fullSnip)
  928. sess.preDemodDecimPhase = sess.preDemodDecimator.Phase()
  929. if coll != nil {
  930. coll.Observe("dsp.pre_demod.decimation_factor", float64(decim1), telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  931. coll.SetGauge("iq.stage.pre_demod.length", float64(len(dec)), telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  932. }
  933. logging.Debug("boundary", "snippet_path", "signal", sess.signalID, "overlap_applied", overlapApplied, "snip_len", len(snippet), "full_len", len(fullSnip), "filtered_len", len(filtered), "dec_len", len(dec), "decim1", decim1, "phase_before", decimPhaseBefore, "phase_after", sess.preDemodDecimPhase)
  934. } else {
  935. logging.Debug("boundary", "snippet_path", "signal", sess.signalID, "overlap_applied", overlapApplied, "snip_len", len(snippet), "full_len", len(fullSnip), "filtered_len", len(fullSnip), "dec_len", len(fullSnip), "decim1", decim1, "phase_before", 0, "phase_after", 0)
  936. dec = fullSnip
  937. }
  938. if decHeadTrimSamples > 0 && decHeadTrimSamples < len(dec) {
  939. logging.Warn("boundary", "dec_head_trim_applied", "signal", sess.signalID, "trim", decHeadTrimSamples, "before_len", len(dec))
  940. dec = dec[decHeadTrimSamples:]
  941. if coll != nil {
  942. coll.IncCounter("dsp.pre_demod.head_trim", 1, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID)))
  943. }
  944. }
  945. if logging.EnabledCategory("boundary") && len(dec) > 0 {
  946. first := dec[0]
  947. if sess.lastDecIQSet {
  948. d2Re := math.Abs(2*float64(real(sess.lastDecIQ)) - float64(real(sess.prevDecIQ)) - float64(real(first)))
  949. d2Im := math.Abs(2*float64(imag(sess.lastDecIQ)) - float64(imag(sess.prevDecIQ)) - float64(imag(first)))
  950. d2Mag := math.Hypot(d2Re, d2Im)
  951. if d2Mag > 0.15 {
  952. logging.Warn("boundary", "dec_iq_boundary", "signal", sess.signalID, "d2", d2Mag)
  953. if coll != nil {
  954. coll.IncCounter("iq.dec.boundary.count", 1, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  955. coll.Observe("iq.dec.boundary.d2", d2Mag, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID)))
  956. }
  957. }
  958. }
  959. headN := 16
  960. if len(dec) < headN {
  961. headN = len(dec)
  962. }
  963. tailN := 16
  964. if len(dec) < tailN {
  965. tailN = len(dec)
  966. }
  967. var headSum, tailSum, minMag, maxMag float64
  968. minMag = math.MaxFloat64
  969. for i, v := range dec {
  970. mag := math.Hypot(float64(real(v)), float64(imag(v)))
  971. if mag < minMag {
  972. minMag = mag
  973. }
  974. if mag > maxMag {
  975. maxMag = mag
  976. }
  977. if i < headN {
  978. headSum += mag
  979. }
  980. }
  981. for i := len(dec) - tailN; i < len(dec); i++ {
  982. if i >= 0 {
  983. v := dec[i]
  984. tailSum += math.Hypot(float64(real(v)), float64(imag(v)))
  985. }
  986. }
  987. headAvg := 0.0
  988. if headN > 0 {
  989. headAvg = headSum / float64(headN)
  990. }
  991. tailAvg := 0.0
  992. if tailN > 0 {
  993. tailAvg = tailSum / float64(tailN)
  994. }
  995. logging.Debug("boundary", "dec_iq_meter", "signal", sess.signalID, "len", len(dec), "head_avg", headAvg, "tail_avg", tailAvg, "min_mag", minMag, "max_mag", maxMag)
  996. if tailAvg > 0 {
  997. ratio := headAvg / tailAvg
  998. if ratio < 0.75 || ratio > 1.25 {
  999. logging.Warn("boundary", "dec_iq_head_tail_skew", "signal", sess.signalID, "head_avg", headAvg, "tail_avg", tailAvg, "ratio", ratio)
  1000. }
  1001. if coll != nil {
  1002. coll.Observe("iq.dec.head_tail_ratio", ratio, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  1003. }
  1004. }
  1005. probeN := 64
  1006. if len(dec) < probeN {
  1007. probeN = len(dec)
  1008. }
  1009. minHeadMag := math.MaxFloat64
  1010. minHeadIdx := 0
  1011. maxHeadStep := 0.0
  1012. maxHeadStepIdx := 0
  1013. for i := 0; i < probeN; i++ {
  1014. v := dec[i]
  1015. mag := math.Hypot(float64(real(v)), float64(imag(v)))
  1016. if mag < minHeadMag {
  1017. minHeadMag = mag
  1018. minHeadIdx = i
  1019. }
  1020. if i > 0 {
  1021. p := dec[i-1]
  1022. num := float64(real(p))*float64(imag(v)) - float64(imag(p))*float64(real(v))
  1023. den := float64(real(p))*float64(real(v)) + float64(imag(p))*float64(imag(v))
  1024. step := math.Abs(math.Atan2(num, den))
  1025. if step > maxHeadStep {
  1026. maxHeadStep = step
  1027. maxHeadStepIdx = i - 1
  1028. }
  1029. }
  1030. }
  1031. logging.Debug("boundary", "dec_iq_head_probe", "signal", sess.signalID, "probe_len", probeN, "min_mag", minHeadMag, "min_idx", minHeadIdx, "max_step", maxHeadStep, "max_step_idx", maxHeadStepIdx)
  1032. if minHeadMag < 0.18 {
  1033. logging.Warn("boundary", "dec_iq_head_dip", "signal", sess.signalID, "probe_len", probeN, "min_mag", minHeadMag, "min_idx", minHeadIdx, "max_step", maxHeadStep, "max_step_idx", maxHeadStepIdx)
  1034. }
  1035. if maxHeadStep > 1.5 {
  1036. logging.Warn("boundary", "dec_iq_head_step", "signal", sess.signalID, "probe_len", probeN, "max_step", maxHeadStep, "max_step_idx", maxHeadStepIdx, "min_mag", minHeadMag, "min_idx", minHeadIdx)
  1037. }
  1038. if coll != nil {
  1039. coll.Observe("iq.dec.magnitude.min", minMag, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  1040. coll.Observe("iq.dec.magnitude.max", maxMag, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  1041. coll.Observe("iq.dec.phase_step.max", maxHeadStep, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  1042. }
  1043. if len(dec) >= 2 {
  1044. sess.prevDecIQ = dec[len(dec)-2]
  1045. sess.lastDecIQ = dec[len(dec)-1]
  1046. } else {
  1047. sess.prevDecIQ = sess.lastDecIQ
  1048. sess.lastDecIQ = dec[0]
  1049. }
  1050. sess.lastDecIQSet = true
  1051. }
  1052. // --- FM/AM/etc Demod ---
  1053. audio := d.Demod(dec, actualDemodRate)
  1054. if len(audio) == 0 {
  1055. return nil, 0
  1056. }
  1057. if coll != nil {
  1058. coll.SetGauge("audio.stage.demod.length", float64(len(audio)), telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  1059. }
  1060. if logging.EnabledCategory("boundary") {
  1061. stride := d.Channels()
  1062. if stride < 1 {
  1063. stride = 1
  1064. }
  1065. nFrames := len(audio) / stride
  1066. if nFrames > 0 {
  1067. first := float64(audio[0])
  1068. if sess.lastDemodSet {
  1069. d2 := math.Abs(2*float64(sess.lastDemodL) - sess.prevDemodL - first)
  1070. if d2 > 0.15 {
  1071. logging.Warn("boundary", "demod_boundary", "signal", sess.signalID, "d2", d2)
  1072. if coll != nil {
  1073. coll.IncCounter("audio.demod_boundary.count", 1, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  1074. coll.Observe("audio.demod_boundary.d2", d2, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID)))
  1075. }
  1076. }
  1077. }
  1078. if nFrames >= 2 {
  1079. sess.prevDemodL = float64(audio[(nFrames-2)*stride])
  1080. sess.lastDemodL = audio[(nFrames-1)*stride]
  1081. } else {
  1082. sess.prevDemodL = float64(sess.lastDemodL)
  1083. sess.lastDemodL = audio[0]
  1084. }
  1085. sess.lastDemodSet = true
  1086. }
  1087. }
  1088. logging.Debug("boundary", "audio_path", "signal", sess.signalID, "demod", demodName, "actual_rate", actualDemodRate, "audio_len", len(audio), "channels", d.Channels(), "overlap_applied", overlapApplied, "prev_tail_valid", prevTailValid)
  1089. shouldDump := !sess.debugDumpStart.IsZero() && !sess.debugDumpUntil.IsZero()
  1090. if shouldDump {
  1091. now := time.Now()
  1092. shouldDump = !now.Before(sess.debugDumpStart) && now.Before(sess.debugDumpUntil)
  1093. }
  1094. if shouldDump {
  1095. sess.demodDump = append(sess.demodDump, audio...)
  1096. }
  1097. // --- Stateful stereo decode with conservative lock/hysteresis ---
  1098. channels := 1
  1099. if isWFMStereo {
  1100. sess.playbackMode = "WFM_STEREO"
  1101. channels = 2 // keep transport format stable for live WFM_STEREO sessions
  1102. stereoAudio, locked := sess.stereoDecodeStateful(audio, actualDemodRate)
  1103. if locked {
  1104. sess.stereoOnCount++
  1105. sess.stereoOffCount = 0
  1106. if sess.stereoOnCount >= 4 {
  1107. sess.stereoEnabled = true
  1108. }
  1109. } else {
  1110. sess.stereoOnCount = 0
  1111. sess.stereoOffCount++
  1112. if sess.stereoOffCount >= 10 {
  1113. sess.stereoEnabled = false
  1114. }
  1115. }
  1116. prevPlayback := sess.playbackMode
  1117. prevStereo := sess.stereoState
  1118. if sess.stereoEnabled && len(stereoAudio) > 0 {
  1119. sess.stereoState = "locked"
  1120. audio = stereoAudio
  1121. } else {
  1122. sess.stereoState = "mono-fallback"
  1123. dual := make([]float32, len(audio)*2)
  1124. for i, s := range audio {
  1125. dual[i*2] = s
  1126. dual[i*2+1] = s
  1127. }
  1128. audio = dual
  1129. }
  1130. if (prevPlayback != sess.playbackMode || prevStereo != sess.stereoState) && len(sess.audioSubs) > 0 {
  1131. sendAudioInfo(sess.audioSubs, sess.audioInfo())
  1132. }
  1133. }
  1134. // --- Polyphase resample to exact 48kHz ---
  1135. if actualDemodRate != streamAudioRate {
  1136. if channels > 1 {
  1137. if sess.stereoResampler == nil || sess.stereoResamplerRate != actualDemodRate {
  1138. logging.Info("resample", "reset", "mode", "stereo", "rate", actualDemodRate)
  1139. sess.stereoResampler = dsp.NewStereoResampler(actualDemodRate, streamAudioRate, resamplerTaps)
  1140. sess.stereoResamplerRate = actualDemodRate
  1141. if coll != nil {
  1142. coll.Event("resampler_reset", "info", "stereo resampler reset", telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID), map[string]any{
  1143. "mode": "stereo",
  1144. "rate": actualDemodRate,
  1145. })
  1146. }
  1147. }
  1148. audio = sess.stereoResampler.Process(audio)
  1149. } else {
  1150. if sess.monoResampler == nil || sess.monoResamplerRate != actualDemodRate {
  1151. logging.Info("resample", "reset", "mode", "mono", "rate", actualDemodRate)
  1152. sess.monoResampler = dsp.NewResampler(actualDemodRate, streamAudioRate, resamplerTaps)
  1153. sess.monoResamplerRate = actualDemodRate
  1154. if coll != nil {
  1155. coll.Event("resampler_reset", "info", "mono resampler reset", telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID), map[string]any{
  1156. "mode": "mono",
  1157. "rate": actualDemodRate,
  1158. })
  1159. }
  1160. }
  1161. audio = sess.monoResampler.Process(audio)
  1162. }
  1163. }
  1164. if coll != nil {
  1165. coll.SetGauge("audio.stage.output.length", float64(len(audio)), telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  1166. }
  1167. // --- De-emphasis (configurable: 50µs Europe, 75µs US/Japan, 0=disabled) ---
  1168. if isWFM && sess.deemphasisUs > 0 && streamAudioRate > 0 {
  1169. tau := sess.deemphasisUs * 1e-6
  1170. alpha := math.Exp(-1.0 / (float64(streamAudioRate) * tau))
  1171. if channels > 1 {
  1172. nFrames := len(audio) / channels
  1173. yL, yR := sess.deemphL, sess.deemphR
  1174. for i := 0; i < nFrames; i++ {
  1175. yL = alpha*yL + (1-alpha)*float64(audio[i*2])
  1176. audio[i*2] = float32(yL)
  1177. yR = alpha*yR + (1-alpha)*float64(audio[i*2+1])
  1178. audio[i*2+1] = float32(yR)
  1179. }
  1180. sess.deemphL, sess.deemphR = yL, yR
  1181. } else {
  1182. y := sess.deemphL
  1183. for i := range audio {
  1184. y = alpha*y + (1-alpha)*float64(audio[i])
  1185. audio[i] = float32(y)
  1186. }
  1187. sess.deemphL = y
  1188. }
  1189. }
  1190. if isWFM {
  1191. for i := range audio {
  1192. audio[i] *= 0.35
  1193. }
  1194. }
  1195. if shouldDump {
  1196. sess.finalDump = append(sess.finalDump, audio...)
  1197. } else if !sess.debugDumpUntil.IsZero() && time.Now().After(sess.debugDumpUntil) && sess.debugDumpBase != "" {
  1198. _ = os.MkdirAll(filepath.Dir(sess.debugDumpBase), 0o755)
  1199. if len(sess.demodDump) > 0 {
  1200. _ = writeWAVFile(sess.debugDumpBase+"-demod.wav", sess.demodDump, actualDemodRate, d.Channels())
  1201. }
  1202. if len(sess.finalDump) > 0 {
  1203. _ = writeWAVFile(sess.debugDumpBase+"-final.wav", sess.finalDump, streamAudioRate, channels)
  1204. }
  1205. logging.Warn("boundary", "debug_audio_dump_window", "signal", sess.signalID, "base", sess.debugDumpBase)
  1206. sess.debugDumpBase = ""
  1207. sess.demodDump = nil
  1208. sess.finalDump = nil
  1209. sess.debugDumpStart = time.Time{}
  1210. sess.debugDumpUntil = time.Time{}
  1211. }
  1212. return audio, streamAudioRate
  1213. }
  1214. // pllCoefficients returns the proportional (alpha) and integral (beta) gains
  1215. // for a Type-II PLL using the specified loop bandwidth and damping factor.
  1216. // loopBW is in Hz, sampleRate in samples/sec.
  1217. func pllCoefficients(loopBW, damping float64, sampleRate int) (float64, float64) {
  1218. if sampleRate <= 0 || loopBW <= 0 {
  1219. return 0, 0
  1220. }
  1221. bl := loopBW / float64(sampleRate)
  1222. theta := bl / (damping + 0.25/damping)
  1223. d := 1 + 2*damping*theta + theta*theta
  1224. alpha := (4 * damping * theta) / d
  1225. beta := (4 * theta * theta) / d
  1226. return alpha, beta
  1227. }
  1228. // stereoDecodeStateful: pilot-locked 38kHz oscillator for L-R extraction.
  1229. // Uses persistent FIR filter state across frames for click-free stereo.
  1230. // Reuses session scratch buffers to minimize allocations.
  1231. func (sess *streamSession) stereoDecodeStateful(mono []float32, sampleRate int) ([]float32, bool) {
  1232. if len(mono) == 0 || sampleRate <= 0 {
  1233. return nil, false
  1234. }
  1235. n := len(mono)
  1236. // Rebuild rate-dependent stereo filters when sampleRate changes
  1237. if sess.stereoLPF == nil || sess.stereoFilterRate != sampleRate {
  1238. lp := dsp.LowpassFIR(15000, sampleRate, 101)
  1239. sess.stereoLPF = dsp.NewStatefulFIRReal(lp)
  1240. sess.stereoBPHi = dsp.NewStatefulFIRReal(dsp.LowpassFIR(53000, sampleRate, 101))
  1241. sess.stereoBPLo = dsp.NewStatefulFIRReal(dsp.LowpassFIR(23000, sampleRate, 101))
  1242. sess.stereoLRLPF = dsp.NewStatefulFIRReal(lp)
  1243. // Narrow pilot bandpass via LPF(21k)-LPF(17k).
  1244. sess.pilotLPFHi = dsp.NewStatefulFIRReal(dsp.LowpassFIR(21000, sampleRate, 101))
  1245. sess.pilotLPFLo = dsp.NewStatefulFIRReal(dsp.LowpassFIR(17000, sampleRate, 101))
  1246. sess.stereoFilterRate = sampleRate
  1247. // Initialize PLL for 19kHz pilot tracking.
  1248. sess.pilotPhase = 0
  1249. sess.pilotFreq = 2 * math.Pi * 19000 / float64(sampleRate)
  1250. sess.pilotAlpha, sess.pilotBeta = pllCoefficients(50, 0.707, sampleRate)
  1251. sess.pilotErrAvg = 0
  1252. sess.pilotI = 0
  1253. sess.pilotQ = 0
  1254. sess.pilotLPAlpha = 1 - math.Exp(-2*math.Pi*200/float64(sampleRate))
  1255. }
  1256. // Reuse scratch for intermediates: lpr, bpfLR, lr, work1, work2.
  1257. scratch := sess.growAudio(n * 5)
  1258. lpr := scratch[:n]
  1259. bpfLR := scratch[n : 2*n]
  1260. lr := scratch[2*n : 3*n]
  1261. work1 := scratch[3*n : 4*n]
  1262. work2 := scratch[4*n : 5*n]
  1263. sess.stereoLPF.ProcessInto(mono, lpr)
  1264. // 23-53kHz bandpass for L-R DSB-SC.
  1265. sess.stereoBPHi.ProcessInto(mono, work1)
  1266. sess.stereoBPLo.ProcessInto(mono, work2)
  1267. for i := 0; i < n; i++ {
  1268. bpfLR[i] = work1[i] - work2[i]
  1269. }
  1270. // 19kHz pilot bandpass for PLL.
  1271. sess.pilotLPFHi.ProcessInto(mono, work1)
  1272. sess.pilotLPFLo.ProcessInto(mono, work2)
  1273. for i := 0; i < n; i++ {
  1274. work1[i] = work1[i] - work2[i]
  1275. }
  1276. pilot := work1
  1277. phase := sess.pilotPhase
  1278. freq := sess.pilotFreq
  1279. alpha := sess.pilotAlpha
  1280. beta := sess.pilotBeta
  1281. iState := sess.pilotI
  1282. qState := sess.pilotQ
  1283. lpAlpha := sess.pilotLPAlpha
  1284. minFreq := 2 * math.Pi * 17000 / float64(sampleRate)
  1285. maxFreq := 2 * math.Pi * 21000 / float64(sampleRate)
  1286. var pilotPower float64
  1287. var totalPower float64
  1288. var errSum float64
  1289. for i := 0; i < n; i++ {
  1290. p := float64(pilot[i])
  1291. sinP, cosP := math.Sincos(phase)
  1292. iMix := p * cosP
  1293. qMix := p * -sinP
  1294. iState += lpAlpha * (iMix - iState)
  1295. qState += lpAlpha * (qMix - qState)
  1296. err := math.Atan2(qState, iState)
  1297. freq += beta * err
  1298. if freq < minFreq {
  1299. freq = minFreq
  1300. } else if freq > maxFreq {
  1301. freq = maxFreq
  1302. }
  1303. phase += freq + alpha*err
  1304. if phase > 2*math.Pi {
  1305. phase -= 2 * math.Pi
  1306. } else if phase < 0 {
  1307. phase += 2 * math.Pi
  1308. }
  1309. totalPower += float64(mono[i]) * float64(mono[i])
  1310. pilotPower += p * p
  1311. errSum += math.Abs(err)
  1312. lr[i] = bpfLR[i] * float32(2*math.Sin(2*phase))
  1313. }
  1314. sess.pilotPhase = phase
  1315. sess.pilotFreq = freq
  1316. sess.pilotI = iState
  1317. sess.pilotQ = qState
  1318. blockErr := errSum / float64(n)
  1319. sess.pilotErrAvg = 0.9*sess.pilotErrAvg + 0.1*blockErr
  1320. lr = sess.stereoLRLPF.ProcessInto(lr, lr)
  1321. pilotRatio := 0.0
  1322. if totalPower > 0 {
  1323. pilotRatio = pilotPower / totalPower
  1324. }
  1325. freqHz := sess.pilotFreq * float64(sampleRate) / (2 * math.Pi)
  1326. // Lock heuristics: pilot power fraction and PLL phase error stability.
  1327. // Pilot power is a small but stable fraction of composite energy; require
  1328. // a modest floor plus PLL settling to avoid flapping in noise.
  1329. locked := pilotRatio > 0.003 && math.Abs(freqHz-19000) < 250 && sess.pilotErrAvg < 0.35
  1330. out := make([]float32, n*2)
  1331. for i := 0; i < n; i++ {
  1332. out[i*2] = 0.5 * (lpr[i] + lr[i])
  1333. out[i*2+1] = 0.5 * (lpr[i] - lr[i])
  1334. }
  1335. return out, locked
  1336. }
  1337. // dspStateSnapshot captures persistent DSP state for segment splits.
  1338. type dspStateSnapshot struct {
  1339. overlapIQ []complex64
  1340. deemphL float64
  1341. deemphR float64
  1342. pilotPhase float64
  1343. pilotFreq float64
  1344. pilotAlpha float64
  1345. pilotBeta float64
  1346. pilotErrAvg float64
  1347. pilotI float64
  1348. pilotQ float64
  1349. pilotLPAlpha float64
  1350. monoResampler *dsp.Resampler
  1351. monoResamplerRate int
  1352. stereoResampler *dsp.StereoResampler
  1353. stereoResamplerRate int
  1354. stereoLPF *dsp.StatefulFIRReal
  1355. stereoFilterRate int
  1356. stereoBPHi *dsp.StatefulFIRReal
  1357. stereoBPLo *dsp.StatefulFIRReal
  1358. stereoLRLPF *dsp.StatefulFIRReal
  1359. stereoAALPF *dsp.StatefulFIRReal
  1360. pilotLPFHi *dsp.StatefulFIRReal
  1361. pilotLPFLo *dsp.StatefulFIRReal
  1362. preDemodFIR *dsp.StatefulFIRComplex
  1363. preDemodDecimator *dsp.StatefulDecimatingFIRComplex
  1364. preDemodDecim int
  1365. preDemodRate int
  1366. preDemodCutoff float64
  1367. preDemodDecimPhase int
  1368. }
  1369. func (sess *streamSession) captureDSPState() dspStateSnapshot {
  1370. return dspStateSnapshot{
  1371. overlapIQ: sess.overlapIQ,
  1372. deemphL: sess.deemphL,
  1373. deemphR: sess.deemphR,
  1374. pilotPhase: sess.pilotPhase,
  1375. pilotFreq: sess.pilotFreq,
  1376. pilotAlpha: sess.pilotAlpha,
  1377. pilotBeta: sess.pilotBeta,
  1378. pilotErrAvg: sess.pilotErrAvg,
  1379. pilotI: sess.pilotI,
  1380. pilotQ: sess.pilotQ,
  1381. pilotLPAlpha: sess.pilotLPAlpha,
  1382. monoResampler: sess.monoResampler,
  1383. monoResamplerRate: sess.monoResamplerRate,
  1384. stereoResampler: sess.stereoResampler,
  1385. stereoResamplerRate: sess.stereoResamplerRate,
  1386. stereoLPF: sess.stereoLPF,
  1387. stereoFilterRate: sess.stereoFilterRate,
  1388. stereoBPHi: sess.stereoBPHi,
  1389. stereoBPLo: sess.stereoBPLo,
  1390. stereoLRLPF: sess.stereoLRLPF,
  1391. stereoAALPF: sess.stereoAALPF,
  1392. pilotLPFHi: sess.pilotLPFHi,
  1393. pilotLPFLo: sess.pilotLPFLo,
  1394. preDemodFIR: sess.preDemodFIR,
  1395. preDemodDecimator: sess.preDemodDecimator,
  1396. preDemodDecim: sess.preDemodDecim,
  1397. preDemodRate: sess.preDemodRate,
  1398. preDemodCutoff: sess.preDemodCutoff,
  1399. preDemodDecimPhase: sess.preDemodDecimPhase,
  1400. }
  1401. }
  1402. func (sess *streamSession) restoreDSPState(s dspStateSnapshot) {
  1403. sess.overlapIQ = s.overlapIQ
  1404. sess.deemphL = s.deemphL
  1405. sess.deemphR = s.deemphR
  1406. sess.pilotPhase = s.pilotPhase
  1407. sess.pilotFreq = s.pilotFreq
  1408. sess.pilotAlpha = s.pilotAlpha
  1409. sess.pilotBeta = s.pilotBeta
  1410. sess.pilotErrAvg = s.pilotErrAvg
  1411. sess.pilotI = s.pilotI
  1412. sess.pilotQ = s.pilotQ
  1413. sess.pilotLPAlpha = s.pilotLPAlpha
  1414. sess.monoResampler = s.monoResampler
  1415. sess.monoResamplerRate = s.monoResamplerRate
  1416. sess.stereoResampler = s.stereoResampler
  1417. sess.stereoResamplerRate = s.stereoResamplerRate
  1418. sess.stereoLPF = s.stereoLPF
  1419. sess.stereoFilterRate = s.stereoFilterRate
  1420. sess.stereoBPHi = s.stereoBPHi
  1421. sess.stereoBPLo = s.stereoBPLo
  1422. sess.stereoLRLPF = s.stereoLRLPF
  1423. sess.stereoAALPF = s.stereoAALPF
  1424. sess.pilotLPFHi = s.pilotLPFHi
  1425. sess.pilotLPFLo = s.pilotLPFLo
  1426. sess.preDemodFIR = s.preDemodFIR
  1427. sess.preDemodDecimator = s.preDemodDecimator
  1428. sess.preDemodDecim = s.preDemodDecim
  1429. sess.preDemodRate = s.preDemodRate
  1430. sess.preDemodCutoff = s.preDemodCutoff
  1431. sess.preDemodDecimPhase = s.preDemodDecimPhase
  1432. }
  1433. // ---------------------------------------------------------------------------
  1434. // Session management helpers
  1435. // ---------------------------------------------------------------------------
  1436. func (st *Streamer) openRecordingSession(sig *detector.Signal, now time.Time) (*streamSession, error) {
  1437. outputDir := st.policy.OutputDir
  1438. if outputDir == "" {
  1439. outputDir = "data/recordings"
  1440. }
  1441. demodName, channels := resolveDemod(sig)
  1442. dirName := fmt.Sprintf("%s_%.0fHz_stream%d",
  1443. now.Format("2006-01-02T15-04-05"), sig.CenterHz, sig.ID)
  1444. dir := filepath.Join(outputDir, dirName)
  1445. if err := os.MkdirAll(dir, 0o755); err != nil {
  1446. return nil, err
  1447. }
  1448. wavPath := filepath.Join(dir, "audio.wav")
  1449. f, err := os.Create(wavPath)
  1450. if err != nil {
  1451. return nil, err
  1452. }
  1453. if err := writeStreamWAVHeader(f, streamAudioRate, channels); err != nil {
  1454. f.Close()
  1455. return nil, err
  1456. }
  1457. playbackMode, stereoState := initialPlaybackState(demodName)
  1458. sess := &streamSession{
  1459. sessionID: fmt.Sprintf("%d-%d-r", sig.ID, now.UnixMilli()),
  1460. signalID: sig.ID,
  1461. centerHz: sig.CenterHz,
  1462. bwHz: sig.BWHz,
  1463. snrDb: sig.SNRDb,
  1464. peakDb: sig.PeakDb,
  1465. class: sig.Class,
  1466. startTime: now,
  1467. lastFeed: now,
  1468. dir: dir,
  1469. wavFile: f,
  1470. wavBuf: bufio.NewWriterSize(f, 64*1024),
  1471. sampleRate: streamAudioRate,
  1472. channels: channels,
  1473. demodName: demodName,
  1474. playbackMode: playbackMode,
  1475. stereoState: stereoState,
  1476. deemphasisUs: st.policy.DeemphasisUs,
  1477. }
  1478. log.Printf("STREAM: opened recording signal=%d %.1fMHz %s dir=%s",
  1479. sig.ID, sig.CenterHz/1e6, demodName, dirName)
  1480. return sess, nil
  1481. }
  1482. func (st *Streamer) openListenSession(sig *detector.Signal, now time.Time) *streamSession {
  1483. demodName, channels := resolveDemod(sig)
  1484. for _, pl := range st.pendingListens {
  1485. if math.Abs(sig.CenterHz-pl.freq) < 200000 {
  1486. if requested := normalizeRequestedMode(pl.mode); requested != "" {
  1487. demodName = requested
  1488. if demodName == "WFM_STEREO" {
  1489. channels = 2
  1490. } else if d := demod.Get(demodName); d != nil {
  1491. channels = d.Channels()
  1492. } else {
  1493. channels = 1
  1494. }
  1495. break
  1496. }
  1497. }
  1498. }
  1499. playbackMode, stereoState := initialPlaybackState(demodName)
  1500. sess := &streamSession{
  1501. sessionID: fmt.Sprintf("%d-%d-l", sig.ID, now.UnixMilli()),
  1502. signalID: sig.ID,
  1503. centerHz: sig.CenterHz,
  1504. bwHz: sig.BWHz,
  1505. snrDb: sig.SNRDb,
  1506. peakDb: sig.PeakDb,
  1507. class: sig.Class,
  1508. startTime: now,
  1509. lastFeed: now,
  1510. listenOnly: true,
  1511. sampleRate: streamAudioRate,
  1512. channels: channels,
  1513. demodName: demodName,
  1514. playbackMode: playbackMode,
  1515. stereoState: stereoState,
  1516. deemphasisUs: st.policy.DeemphasisUs,
  1517. }
  1518. log.Printf("STREAM: opened listen-only signal=%d %.1fMHz %s",
  1519. sig.ID, sig.CenterHz/1e6, demodName)
  1520. return sess
  1521. }
  1522. func resolveDemod(sig *detector.Signal) (string, int) {
  1523. demodName := "NFM"
  1524. if sig.Class != nil {
  1525. if n := mapClassToDemod(sig.Class.ModType); n != "" {
  1526. demodName = n
  1527. }
  1528. }
  1529. channels := 1
  1530. if demodName == "WFM_STEREO" {
  1531. channels = 2
  1532. } else if d := demod.Get(demodName); d != nil {
  1533. channels = d.Channels()
  1534. }
  1535. return demodName, channels
  1536. }
  1537. func initialPlaybackState(demodName string) (string, string) {
  1538. playbackMode := demodName
  1539. stereoState := "mono"
  1540. if demodName == "WFM_STEREO" {
  1541. stereoState = "searching"
  1542. }
  1543. return playbackMode, stereoState
  1544. }
  1545. func (sess *streamSession) audioInfo() AudioInfo {
  1546. return AudioInfo{
  1547. SampleRate: sess.sampleRate,
  1548. Channels: sess.channels,
  1549. Format: "s16le",
  1550. DemodName: sess.demodName,
  1551. PlaybackMode: sess.playbackMode,
  1552. StereoState: sess.stereoState,
  1553. }
  1554. }
  1555. func sendAudioInfo(subs []audioSub, info AudioInfo) {
  1556. infoJSON, _ := json.Marshal(info)
  1557. tagged := make([]byte, 1+len(infoJSON))
  1558. tagged[0] = 0x00 // tag: audio_info
  1559. copy(tagged[1:], infoJSON)
  1560. for _, sub := range subs {
  1561. select {
  1562. case sub.ch <- tagged:
  1563. default:
  1564. }
  1565. }
  1566. }
  1567. func defaultAudioInfoForMode(mode string) AudioInfo {
  1568. demodName := "NFM"
  1569. if requested := normalizeRequestedMode(mode); requested != "" {
  1570. demodName = requested
  1571. }
  1572. channels := 1
  1573. if demodName == "WFM_STEREO" {
  1574. channels = 2
  1575. } else if d := demod.Get(demodName); d != nil {
  1576. channels = d.Channels()
  1577. }
  1578. playbackMode, stereoState := initialPlaybackState(demodName)
  1579. return AudioInfo{
  1580. SampleRate: streamAudioRate,
  1581. Channels: channels,
  1582. Format: "s16le",
  1583. DemodName: demodName,
  1584. PlaybackMode: playbackMode,
  1585. StereoState: stereoState,
  1586. }
  1587. }
  1588. func normalizeRequestedMode(mode string) string {
  1589. switch strings.ToUpper(strings.TrimSpace(mode)) {
  1590. case "", "AUTO":
  1591. return ""
  1592. case "WFM", "WFM_STEREO", "NFM", "AM", "USB", "LSB", "CW":
  1593. return strings.ToUpper(strings.TrimSpace(mode))
  1594. default:
  1595. return ""
  1596. }
  1597. }
  1598. // growIQ returns a complex64 slice of at least n elements, reusing sess.scratchIQ.
  1599. func (sess *streamSession) growIQ(n int) []complex64 {
  1600. if cap(sess.scratchIQ) >= n {
  1601. return sess.scratchIQ[:n]
  1602. }
  1603. sess.scratchIQ = make([]complex64, n, n*5/4)
  1604. return sess.scratchIQ
  1605. }
  1606. // growAudio returns a float32 slice of at least n elements, reusing sess.scratchAudio.
  1607. func (sess *streamSession) growAudio(n int) []float32 {
  1608. if cap(sess.scratchAudio) >= n {
  1609. return sess.scratchAudio[:n]
  1610. }
  1611. sess.scratchAudio = make([]float32, n, n*5/4)
  1612. return sess.scratchAudio
  1613. }
  1614. // growPCM returns a byte slice of at least n bytes, reusing sess.scratchPCM.
  1615. func (sess *streamSession) growPCM(n int) []byte {
  1616. if cap(sess.scratchPCM) >= n {
  1617. return sess.scratchPCM[:n]
  1618. }
  1619. sess.scratchPCM = make([]byte, n, n*5/4)
  1620. return sess.scratchPCM
  1621. }
  1622. func convertToListenOnly(sess *streamSession) {
  1623. if sess.wavBuf != nil {
  1624. _ = sess.wavBuf.Flush()
  1625. }
  1626. if sess.wavFile != nil {
  1627. fixStreamWAVHeader(sess.wavFile, sess.wavSamples, sess.sampleRate, sess.channels)
  1628. sess.wavFile.Close()
  1629. }
  1630. sess.wavFile = nil
  1631. sess.wavBuf = nil
  1632. sess.listenOnly = true
  1633. log.Printf("STREAM: converted signal=%d to listen-only", sess.signalID)
  1634. }
  1635. func closeSession(sess *streamSession, policy *Policy) {
  1636. if sess.listenOnly {
  1637. return
  1638. }
  1639. if sess.wavBuf != nil {
  1640. _ = sess.wavBuf.Flush()
  1641. }
  1642. if sess.wavFile != nil {
  1643. fixStreamWAVHeader(sess.wavFile, sess.wavSamples, sess.sampleRate, sess.channels)
  1644. sess.wavFile.Close()
  1645. sess.wavFile = nil
  1646. sess.wavBuf = nil
  1647. }
  1648. dur := sess.lastFeed.Sub(sess.startTime)
  1649. files := map[string]any{
  1650. "audio": "audio.wav",
  1651. "audio_sample_rate": sess.sampleRate,
  1652. "audio_channels": sess.channels,
  1653. "audio_demod": sess.demodName,
  1654. "recording_mode": "streaming",
  1655. }
  1656. meta := Meta{
  1657. EventID: sess.signalID,
  1658. Start: sess.startTime,
  1659. End: sess.lastFeed,
  1660. CenterHz: sess.centerHz,
  1661. BandwidthHz: sess.bwHz,
  1662. SampleRate: sess.sampleRate,
  1663. SNRDb: sess.snrDb,
  1664. PeakDb: sess.peakDb,
  1665. Class: sess.class,
  1666. DurationMs: dur.Milliseconds(),
  1667. Files: files,
  1668. }
  1669. b, err := json.MarshalIndent(meta, "", " ")
  1670. if err == nil {
  1671. _ = os.WriteFile(filepath.Join(sess.dir, "meta.json"), b, 0o644)
  1672. }
  1673. if policy != nil {
  1674. enforceQuota(policy.OutputDir, policy.MaxDiskMB)
  1675. }
  1676. }
  1677. func (st *Streamer) fanoutPCM(sess *streamSession, pcm []byte, pcmLen int) {
  1678. if len(sess.audioSubs) == 0 {
  1679. return
  1680. }
  1681. // Tag + copy for all subscribers: 0x01 prefix = PCM audio
  1682. tagged := make([]byte, 1+pcmLen)
  1683. tagged[0] = 0x01
  1684. copy(tagged[1:], pcm[:pcmLen])
  1685. alive := sess.audioSubs[:0]
  1686. for _, sub := range sess.audioSubs {
  1687. select {
  1688. case sub.ch <- tagged:
  1689. default:
  1690. st.droppedPCM++
  1691. logging.Warn("drop", "pcm_drop", "count", st.droppedPCM)
  1692. if st.telemetry != nil {
  1693. st.telemetry.IncCounter("streamer.pcm.drop", 1, telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  1694. }
  1695. }
  1696. alive = append(alive, sub)
  1697. }
  1698. sess.audioSubs = alive
  1699. if st.telemetry != nil {
  1700. st.telemetry.SetGauge("streamer.subscribers.count", float64(len(alive)), telemetry.TagsFromPairs("signal_id", fmt.Sprintf("%d", sess.signalID), "session_id", sess.sessionID))
  1701. }
  1702. }
  1703. func (st *Streamer) classAllowed(cls *classifier.Classification) bool {
  1704. if len(st.policy.ClassFilter) == 0 {
  1705. return true
  1706. }
  1707. if cls == nil {
  1708. return false
  1709. }
  1710. for _, f := range st.policy.ClassFilter {
  1711. if strings.EqualFold(f, string(cls.ModType)) {
  1712. return true
  1713. }
  1714. }
  1715. return false
  1716. }
  1717. // ErrNoSession is returned when no matching signal session exists.
  1718. var ErrNoSession = errors.New("no active or pending session for this frequency")
  1719. // ---------------------------------------------------------------------------
  1720. // WAV header helpers
  1721. // ---------------------------------------------------------------------------
  1722. func writeWAVFile(path string, audio []float32, sampleRate int, channels int) error {
  1723. f, err := os.Create(path)
  1724. if err != nil {
  1725. return err
  1726. }
  1727. defer f.Close()
  1728. return writeWAVTo(f, audio, sampleRate, channels)
  1729. }
  1730. func writeStreamWAVHeader(f *os.File, sampleRate int, channels int) error {
  1731. if channels <= 0 {
  1732. channels = 1
  1733. }
  1734. hdr := make([]byte, 44)
  1735. copy(hdr[0:4], "RIFF")
  1736. binary.LittleEndian.PutUint32(hdr[4:8], 36)
  1737. copy(hdr[8:12], "WAVE")
  1738. copy(hdr[12:16], "fmt ")
  1739. binary.LittleEndian.PutUint32(hdr[16:20], 16)
  1740. binary.LittleEndian.PutUint16(hdr[20:22], 1)
  1741. binary.LittleEndian.PutUint16(hdr[22:24], uint16(channels))
  1742. binary.LittleEndian.PutUint32(hdr[24:28], uint32(sampleRate))
  1743. binary.LittleEndian.PutUint32(hdr[28:32], uint32(sampleRate*channels*2))
  1744. binary.LittleEndian.PutUint16(hdr[32:34], uint16(channels*2))
  1745. binary.LittleEndian.PutUint16(hdr[34:36], 16)
  1746. copy(hdr[36:40], "data")
  1747. binary.LittleEndian.PutUint32(hdr[40:44], 0)
  1748. _, err := f.Write(hdr)
  1749. return err
  1750. }
  1751. func fixStreamWAVHeader(f *os.File, totalSamples int64, sampleRate int, channels int) {
  1752. dataSize := uint32(totalSamples * 2)
  1753. var buf [4]byte
  1754. binary.LittleEndian.PutUint32(buf[:], 36+dataSize)
  1755. if _, err := f.Seek(4, 0); err != nil {
  1756. return
  1757. }
  1758. _, _ = f.Write(buf[:])
  1759. binary.LittleEndian.PutUint32(buf[:], uint32(sampleRate))
  1760. if _, err := f.Seek(24, 0); err != nil {
  1761. return
  1762. }
  1763. _, _ = f.Write(buf[:])
  1764. binary.LittleEndian.PutUint32(buf[:], uint32(sampleRate*channels*2))
  1765. if _, err := f.Seek(28, 0); err != nil {
  1766. return
  1767. }
  1768. _, _ = f.Write(buf[:])
  1769. binary.LittleEndian.PutUint32(buf[:], dataSize)
  1770. if _, err := f.Seek(40, 0); err != nil {
  1771. return
  1772. }
  1773. _, _ = f.Write(buf[:])
  1774. }
  1775. // ResetStreams forces all active streaming sessions to discard their FIR states and decimation phases.
  1776. // This is used when the upstream DSP drops samples, creating a hard break in phase continuity.
  1777. func (st *Streamer) ResetStreams() {
  1778. st.mu.Lock()
  1779. defer st.mu.Unlock()
  1780. if st.telemetry != nil {
  1781. st.telemetry.IncCounter("streamer.reset.count", 1, nil)
  1782. st.telemetry.Event("stream_reset", "warn", "stream DSP state reset", nil, map[string]any{"sessions": len(st.sessions)})
  1783. }
  1784. for _, sess := range st.sessions {
  1785. sess.preDemodFIR = nil
  1786. sess.preDemodDecimator = nil
  1787. sess.preDemodDecimPhase = 0
  1788. sess.stereoResampler = nil
  1789. sess.monoResampler = nil
  1790. }
  1791. }