Wideband autonomous SDR analysis engine forked from sdr-visual-suite
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

1267 Zeilen
35KB

  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. "strings"
  13. "sync"
  14. "time"
  15. "sdr-wideband-suite/internal/classifier"
  16. "sdr-wideband-suite/internal/demod"
  17. "sdr-wideband-suite/internal/detector"
  18. "sdr-wideband-suite/internal/dsp"
  19. )
  20. // ---------------------------------------------------------------------------
  21. // streamSession — one open demod session for one signal
  22. // ---------------------------------------------------------------------------
  23. type streamSession struct {
  24. signalID int64
  25. centerHz float64
  26. bwHz float64
  27. snrDb float64
  28. peakDb float64
  29. class *classifier.Classification
  30. startTime time.Time
  31. lastFeed time.Time
  32. // listenOnly sessions have no WAV file and no disk I/O.
  33. // They exist solely to feed audio to live-listen subscribers.
  34. listenOnly bool
  35. // Recording state (nil/zero for listen-only sessions)
  36. dir string
  37. wavFile *os.File
  38. wavBuf *bufio.Writer
  39. wavSamples int64
  40. segmentIdx int
  41. sampleRate int // actual output audio sample rate (always streamAudioRate)
  42. channels int
  43. demodName string
  44. // --- Persistent DSP state for click-free streaming ---
  45. // Overlap-save: tail of previous extracted IQ snippet.
  46. overlapIQ []complex64
  47. // De-emphasis IIR state (persists across frames)
  48. deemphL float64
  49. deemphR float64
  50. // Stereo lock state for live WFM streaming
  51. stereoEnabled bool
  52. stereoOnCount int
  53. stereoOffCount int
  54. // Pilot-locked stereo PLL state (19kHz pilot)
  55. pilotPhase float64
  56. pilotFreq float64
  57. pilotAlpha float64
  58. pilotBeta float64
  59. pilotErrAvg float64
  60. pilotI float64
  61. pilotQ float64
  62. pilotLPAlpha float64
  63. // Polyphase resampler (replaces integer-decimate hack)
  64. monoResampler *dsp.Resampler
  65. monoResamplerRate int
  66. stereoResampler *dsp.StereoResampler
  67. stereoResamplerRate int
  68. // AQ-4: Stateful FIR filters for click-free stereo decode
  69. stereoFilterRate int
  70. stereoLPF *dsp.StatefulFIRReal // 15kHz lowpass for L+R
  71. stereoBPHi *dsp.StatefulFIRReal // 53kHz LP for bandpass high
  72. stereoBPLo *dsp.StatefulFIRReal // 23kHz LP for bandpass low
  73. stereoLRLPF *dsp.StatefulFIRReal // 15kHz LP for demodulated L-R
  74. stereoAALPF *dsp.StatefulFIRReal // Anti-alias LP for pre-decim (mono path)
  75. pilotLPFHi *dsp.StatefulFIRReal // ~21kHz LP for pilot bandpass high
  76. pilotLPFLo *dsp.StatefulFIRReal // ~17kHz LP for pilot bandpass low
  77. // Stateful pre-demod anti-alias FIR (eliminates cold-start transients
  78. // and avoids per-frame FIR recomputation)
  79. preDemodFIR *dsp.StatefulFIRComplex
  80. preDemodDecim int // cached decimation factor
  81. preDemodRate int // cached snipRate this FIR was built for
  82. preDemodCutoff float64 // cached cutoff
  83. // AQ-2: De-emphasis config (µs, 0 = disabled)
  84. deemphasisUs float64
  85. // Scratch buffers — reused across frames to avoid GC pressure.
  86. // Grown as needed, never shrunk.
  87. scratchIQ []complex64 // for pre-demod FIR output + decimate input
  88. scratchAudio []float32 // for stereo decode intermediates
  89. scratchPCM []byte // for PCM encoding
  90. // live-listen subscribers
  91. audioSubs []audioSub
  92. }
  93. type audioSub struct {
  94. id int64
  95. ch chan []byte
  96. }
  97. // AudioInfo describes the audio format of a live-listen subscription.
  98. // Sent to the WebSocket client as the first message.
  99. type AudioInfo struct {
  100. SampleRate int `json:"sample_rate"`
  101. Channels int `json:"channels"`
  102. Format string `json:"format"` // always "s16le"
  103. DemodName string `json:"demod"`
  104. }
  105. const (
  106. streamAudioRate = 48000
  107. resamplerTaps = 32 // taps per polyphase arm — good quality
  108. )
  109. // ---------------------------------------------------------------------------
  110. // Streamer — manages all active streaming sessions
  111. // ---------------------------------------------------------------------------
  112. type streamFeedItem struct {
  113. signal detector.Signal
  114. snippet []complex64
  115. snipRate int
  116. }
  117. type streamFeedMsg struct {
  118. items []streamFeedItem
  119. }
  120. type Streamer struct {
  121. mu sync.Mutex
  122. sessions map[int64]*streamSession
  123. policy Policy
  124. centerHz float64
  125. nextSub int64
  126. feedCh chan streamFeedMsg
  127. done chan struct{}
  128. // pendingListens are subscribers waiting for a matching session.
  129. pendingListens map[int64]*pendingListen
  130. }
  131. type pendingListen struct {
  132. freq float64
  133. bw float64
  134. mode string
  135. ch chan []byte
  136. }
  137. func newStreamer(policy Policy, centerHz float64) *Streamer {
  138. st := &Streamer{
  139. sessions: make(map[int64]*streamSession),
  140. policy: policy,
  141. centerHz: centerHz,
  142. feedCh: make(chan streamFeedMsg, 2),
  143. done: make(chan struct{}),
  144. pendingListens: make(map[int64]*pendingListen),
  145. }
  146. go st.worker()
  147. return st
  148. }
  149. func (st *Streamer) worker() {
  150. for msg := range st.feedCh {
  151. st.processFeed(msg)
  152. }
  153. close(st.done)
  154. }
  155. func (st *Streamer) updatePolicy(policy Policy, centerHz float64) {
  156. st.mu.Lock()
  157. defer st.mu.Unlock()
  158. wasEnabled := st.policy.Enabled
  159. st.policy = policy
  160. st.centerHz = centerHz
  161. // If recording was just disabled, close recording sessions
  162. // but keep listen-only sessions alive.
  163. if wasEnabled && !policy.Enabled {
  164. for id, sess := range st.sessions {
  165. if sess.listenOnly {
  166. continue
  167. }
  168. if len(sess.audioSubs) > 0 {
  169. // Convert to listen-only: close WAV but keep session
  170. convertToListenOnly(sess)
  171. } else {
  172. closeSession(sess, &st.policy)
  173. delete(st.sessions, id)
  174. }
  175. }
  176. }
  177. }
  178. // HasListeners returns true if any sessions have audio subscribers
  179. // or there are pending listen requests. Used by the DSP loop to
  180. // decide whether to feed snippets even when recording is disabled.
  181. func (st *Streamer) HasListeners() bool {
  182. st.mu.Lock()
  183. defer st.mu.Unlock()
  184. return st.hasListenersLocked()
  185. }
  186. func (st *Streamer) hasListenersLocked() bool {
  187. if len(st.pendingListens) > 0 {
  188. return true
  189. }
  190. for _, sess := range st.sessions {
  191. if len(sess.audioSubs) > 0 {
  192. return true
  193. }
  194. }
  195. return false
  196. }
  197. // FeedSnippets is called from the DSP loop with pre-extracted IQ snippets.
  198. // Feeds are accepted if:
  199. // - Recording is enabled (policy.Enabled && RecordAudio/RecordIQ), OR
  200. // - Any live-listen subscribers exist (listen-only mode)
  201. //
  202. // IMPORTANT: The caller (Manager.FeedSnippets) already copies the snippet
  203. // data, so items can be passed directly without another copy.
  204. func (st *Streamer) FeedSnippets(items []streamFeedItem) {
  205. st.mu.Lock()
  206. recEnabled := st.policy.Enabled && (st.policy.RecordAudio || st.policy.RecordIQ)
  207. hasListeners := st.hasListenersLocked()
  208. pending := len(st.pendingListens)
  209. st.mu.Unlock()
  210. log.Printf("LIVEAUDIO STREAM: feedSnippets items=%d recEnabled=%v hasListeners=%v pending=%d", len(items), recEnabled, hasListeners, pending)
  211. if (!recEnabled && !hasListeners) || len(items) == 0 {
  212. return
  213. }
  214. select {
  215. case st.feedCh <- streamFeedMsg{items: items}:
  216. default:
  217. }
  218. }
  219. // processFeed runs in the worker goroutine.
  220. func (st *Streamer) processFeed(msg streamFeedMsg) {
  221. st.mu.Lock()
  222. defer st.mu.Unlock()
  223. recEnabled := st.policy.Enabled && (st.policy.RecordAudio || st.policy.RecordIQ)
  224. hasListeners := st.hasListenersLocked()
  225. if !recEnabled && !hasListeners {
  226. return
  227. }
  228. now := time.Now()
  229. seen := make(map[int64]bool, len(msg.items))
  230. for i := range msg.items {
  231. item := &msg.items[i]
  232. sig := &item.signal
  233. seen[sig.ID] = true
  234. if sig.ID == 0 || sig.Class == nil {
  235. continue
  236. }
  237. if len(item.snippet) == 0 || item.snipRate <= 0 {
  238. continue
  239. }
  240. // Decide whether this signal needs a session
  241. needsRecording := recEnabled && sig.SNRDb >= st.policy.MinSNRDb && st.classAllowed(sig.Class)
  242. needsListen := st.signalHasListenerLocked(sig)
  243. className := "<nil>"
  244. demodName := ""
  245. if sig.Class != nil {
  246. className = string(sig.Class.ModType)
  247. demodName, _ = resolveDemod(sig)
  248. }
  249. 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)
  250. if !needsRecording && !needsListen {
  251. continue
  252. }
  253. sess, exists := st.sessions[sig.ID]
  254. requestedMode := ""
  255. for _, pl := range st.pendingListens {
  256. if math.Abs(sig.CenterHz-pl.freq) < 200000 {
  257. if m := normalizeRequestedMode(pl.mode); m != "" {
  258. requestedMode = m
  259. break
  260. }
  261. }
  262. }
  263. if exists && sess.listenOnly && requestedMode != "" && sess.demodName != requestedMode {
  264. for _, sub := range sess.audioSubs {
  265. st.pendingListens[sub.id] = &pendingListen{freq: sig.CenterHz, bw: sig.BWHz, mode: requestedMode, ch: sub.ch}
  266. }
  267. delete(st.sessions, sig.ID)
  268. sess = nil
  269. exists = false
  270. }
  271. if !exists {
  272. if needsRecording {
  273. s, err := st.openRecordingSession(sig, now)
  274. if err != nil {
  275. log.Printf("STREAM: open failed signal=%d %.1fMHz: %v",
  276. sig.ID, sig.CenterHz/1e6, err)
  277. continue
  278. }
  279. st.sessions[sig.ID] = s
  280. sess = s
  281. } else {
  282. s := st.openListenSession(sig, now)
  283. st.sessions[sig.ID] = s
  284. sess = s
  285. }
  286. // Attach any pending listeners
  287. st.attachPendingListeners(sess)
  288. }
  289. // Update metadata
  290. sess.lastFeed = now
  291. sess.centerHz = sig.CenterHz
  292. sess.bwHz = sig.BWHz
  293. if sig.SNRDb > sess.snrDb {
  294. sess.snrDb = sig.SNRDb
  295. }
  296. if sig.PeakDb > sess.peakDb {
  297. sess.peakDb = sig.PeakDb
  298. }
  299. if sig.Class != nil {
  300. sess.class = sig.Class
  301. }
  302. // Demod with persistent state
  303. audio, audioRate := sess.processSnippet(item.snippet, item.snipRate)
  304. if len(audio) > 0 {
  305. if sess.wavSamples == 0 && audioRate > 0 {
  306. sess.sampleRate = audioRate
  307. }
  308. // Encode PCM once into scratch buffer, reuse for both WAV and fanout
  309. pcmLen := len(audio) * 2
  310. pcm := sess.growPCM(pcmLen)
  311. for k, s := range audio {
  312. v := int16(clip(s * 32767))
  313. binary.LittleEndian.PutUint16(pcm[k*2:], uint16(v))
  314. }
  315. if !sess.listenOnly && sess.wavBuf != nil {
  316. n, err := sess.wavBuf.Write(pcm)
  317. if err != nil {
  318. log.Printf("STREAM: write error signal=%d: %v", sess.signalID, err)
  319. } else {
  320. sess.wavSamples += int64(n / 2)
  321. }
  322. }
  323. st.fanoutPCM(sess, pcm, pcmLen)
  324. }
  325. // Segment split (recording sessions only)
  326. if !sess.listenOnly && st.policy.MaxDuration > 0 && now.Sub(sess.startTime) >= st.policy.MaxDuration {
  327. segIdx := sess.segmentIdx + 1
  328. oldSubs := sess.audioSubs
  329. oldState := sess.captureDSPState()
  330. sess.audioSubs = nil
  331. closeSession(sess, &st.policy)
  332. s, err := st.openRecordingSession(sig, now)
  333. if err != nil {
  334. delete(st.sessions, sig.ID)
  335. continue
  336. }
  337. s.segmentIdx = segIdx
  338. s.audioSubs = oldSubs
  339. s.restoreDSPState(oldState)
  340. st.sessions[sig.ID] = s
  341. }
  342. }
  343. // Close sessions for disappeared signals (with grace period)
  344. for id, sess := range st.sessions {
  345. if seen[id] {
  346. continue
  347. }
  348. gracePeriod := 3 * time.Second
  349. if sess.listenOnly {
  350. gracePeriod = 5 * time.Second
  351. }
  352. if now.Sub(sess.lastFeed) > gracePeriod {
  353. for _, sub := range sess.audioSubs {
  354. close(sub.ch)
  355. }
  356. sess.audioSubs = nil
  357. if !sess.listenOnly {
  358. closeSession(sess, &st.policy)
  359. }
  360. delete(st.sessions, id)
  361. }
  362. }
  363. }
  364. func (st *Streamer) signalHasListenerLocked(sig *detector.Signal) bool {
  365. if sess, ok := st.sessions[sig.ID]; ok && len(sess.audioSubs) > 0 {
  366. log.Printf("LIVEAUDIO MATCH: signal id=%d matched existing session listener center=%.3fMHz", sig.ID, sig.CenterHz/1e6)
  367. return true
  368. }
  369. for subID, pl := range st.pendingListens {
  370. delta := math.Abs(sig.CenterHz - pl.freq)
  371. if delta < 200000 {
  372. 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)
  373. return true
  374. }
  375. }
  376. return false
  377. }
  378. func (st *Streamer) attachPendingListeners(sess *streamSession) {
  379. for subID, pl := range st.pendingListens {
  380. requestedMode := normalizeRequestedMode(pl.mode)
  381. if requestedMode != "" && sess.demodName != requestedMode {
  382. continue
  383. }
  384. if math.Abs(sess.centerHz-pl.freq) < 200000 {
  385. sess.audioSubs = append(sess.audioSubs, audioSub{id: subID, ch: pl.ch})
  386. delete(st.pendingListens, subID)
  387. // Send updated audio_info now that we know the real session params.
  388. // Prefix with 0x00 tag byte so ws/audio handler sends as TextMessage.
  389. infoJSON, _ := json.Marshal(AudioInfo{
  390. SampleRate: sess.sampleRate,
  391. Channels: sess.channels,
  392. Format: "s16le",
  393. DemodName: sess.demodName,
  394. })
  395. tagged := make([]byte, 1+len(infoJSON))
  396. tagged[0] = 0x00 // tag: audio_info
  397. copy(tagged[1:], infoJSON)
  398. select {
  399. case pl.ch <- tagged:
  400. default:
  401. }
  402. log.Printf("STREAM: attached pending listener %d to signal %d (%.1fMHz %s ch=%d)",
  403. subID, sess.signalID, sess.centerHz/1e6, sess.demodName, sess.channels)
  404. }
  405. }
  406. }
  407. // CloseAll finalises all sessions and stops the worker goroutine.
  408. func (st *Streamer) CloseAll() {
  409. close(st.feedCh)
  410. <-st.done
  411. st.mu.Lock()
  412. defer st.mu.Unlock()
  413. for id, sess := range st.sessions {
  414. for _, sub := range sess.audioSubs {
  415. close(sub.ch)
  416. }
  417. sess.audioSubs = nil
  418. if !sess.listenOnly {
  419. closeSession(sess, &st.policy)
  420. }
  421. delete(st.sessions, id)
  422. }
  423. for _, pl := range st.pendingListens {
  424. close(pl.ch)
  425. }
  426. st.pendingListens = nil
  427. }
  428. // ActiveSessions returns the number of open streaming sessions.
  429. func (st *Streamer) ActiveSessions() int {
  430. st.mu.Lock()
  431. defer st.mu.Unlock()
  432. return len(st.sessions)
  433. }
  434. // SubscribeAudio registers a live-listen subscriber for a given frequency.
  435. //
  436. // LL-2: Returns AudioInfo with correct channels and sample rate.
  437. // LL-3: Returns error only on hard failures (nil streamer etc).
  438. //
  439. // If a matching session exists, attaches immediately. Otherwise, the
  440. // subscriber is held as "pending" and will be attached when a matching
  441. // signal appears in the next DSP frame.
  442. func (st *Streamer) SubscribeAudio(freq float64, bw float64, mode string) (int64, <-chan []byte, AudioInfo, error) {
  443. ch := make(chan []byte, 64)
  444. st.mu.Lock()
  445. defer st.mu.Unlock()
  446. st.nextSub++
  447. subID := st.nextSub
  448. requestedMode := normalizeRequestedMode(mode)
  449. // Try to find a matching session
  450. var bestSess *streamSession
  451. bestDist := math.MaxFloat64
  452. for _, sess := range st.sessions {
  453. if requestedMode != "" && sess.demodName != requestedMode {
  454. continue
  455. }
  456. d := math.Abs(sess.centerHz - freq)
  457. if d < bestDist {
  458. bestDist = d
  459. bestSess = sess
  460. }
  461. }
  462. if bestSess != nil && bestDist < 200000 {
  463. bestSess.audioSubs = append(bestSess.audioSubs, audioSub{id: subID, ch: ch})
  464. info := AudioInfo{
  465. SampleRate: bestSess.sampleRate,
  466. Channels: bestSess.channels,
  467. Format: "s16le",
  468. DemodName: bestSess.demodName,
  469. }
  470. log.Printf("STREAM: subscriber %d attached to signal %d (%.1fMHz %s)",
  471. subID, bestSess.signalID, bestSess.centerHz/1e6, bestSess.demodName)
  472. return subID, ch, info, nil
  473. }
  474. // No matching session yet — add as pending listener
  475. st.pendingListens[subID] = &pendingListen{
  476. freq: freq,
  477. bw: bw,
  478. mode: mode,
  479. ch: ch,
  480. }
  481. info := AudioInfo{
  482. SampleRate: streamAudioRate,
  483. Channels: 1,
  484. Format: "s16le",
  485. DemodName: "NFM",
  486. }
  487. log.Printf("STREAM: subscriber %d pending (freq=%.1fMHz)", subID, freq/1e6)
  488. log.Printf("LIVEAUDIO MATCH: subscriber=%d pending req=%.3fMHz bw=%.0f mode=%s", subID, freq/1e6, bw, mode)
  489. return subID, ch, info, nil
  490. }
  491. // UnsubscribeAudio removes a live-listen subscriber.
  492. func (st *Streamer) UnsubscribeAudio(subID int64) {
  493. st.mu.Lock()
  494. defer st.mu.Unlock()
  495. if pl, ok := st.pendingListens[subID]; ok {
  496. close(pl.ch)
  497. delete(st.pendingListens, subID)
  498. return
  499. }
  500. for _, sess := range st.sessions {
  501. for i, sub := range sess.audioSubs {
  502. if sub.id == subID {
  503. close(sub.ch)
  504. sess.audioSubs = append(sess.audioSubs[:i], sess.audioSubs[i+1:]...)
  505. return
  506. }
  507. }
  508. }
  509. }
  510. // ---------------------------------------------------------------------------
  511. // Session: stateful extraction + demod
  512. // ---------------------------------------------------------------------------
  513. // processSnippet takes a pre-extracted IQ snippet and demodulates it with
  514. // persistent state. Uses stateful FIR + polyphase resampler for exact 48kHz
  515. // output with zero transient artifacts.
  516. func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([]float32, int) {
  517. if len(snippet) == 0 || snipRate <= 0 {
  518. return nil, 0
  519. }
  520. isWFMStereo := sess.demodName == "WFM_STEREO"
  521. isWFM := sess.demodName == "WFM" || isWFMStereo
  522. demodName := sess.demodName
  523. if isWFMStereo {
  524. demodName = "WFM"
  525. }
  526. d := demod.Get(demodName)
  527. if d == nil {
  528. d = demod.Get("NFM")
  529. }
  530. if d == nil {
  531. return nil, 0
  532. }
  533. // --- FM discriminator overlap: prepend 1 sample from previous frame ---
  534. // The FM discriminator needs iq[i-1] to compute the first output.
  535. // All FIR filtering is now stateful, so no additional overlap is needed.
  536. var fullSnip []complex64
  537. trimSamples := 0
  538. if len(sess.overlapIQ) == 1 {
  539. fullSnip = make([]complex64, 1+len(snippet))
  540. fullSnip[0] = sess.overlapIQ[0]
  541. copy(fullSnip[1:], snippet)
  542. trimSamples = 1
  543. } else {
  544. fullSnip = snippet
  545. }
  546. // Save last sample for next frame's FM discriminator
  547. if len(snippet) > 0 {
  548. sess.overlapIQ = []complex64{snippet[len(snippet)-1]}
  549. }
  550. // --- Stateful anti-alias FIR + decimation to demod rate ---
  551. demodRate := d.OutputSampleRate()
  552. decim1 := int(math.Round(float64(snipRate) / float64(demodRate)))
  553. if decim1 < 1 {
  554. decim1 = 1
  555. }
  556. actualDemodRate := snipRate / decim1
  557. var dec []complex64
  558. if decim1 > 1 {
  559. cutoff := float64(actualDemodRate) / 2.0 * 0.8
  560. // Lazy-init or reinit stateful FIR if parameters changed
  561. if sess.preDemodFIR == nil || sess.preDemodRate != snipRate || sess.preDemodCutoff != cutoff {
  562. taps := dsp.LowpassFIR(cutoff, snipRate, 101)
  563. sess.preDemodFIR = dsp.NewStatefulFIRComplex(taps)
  564. sess.preDemodRate = snipRate
  565. sess.preDemodCutoff = cutoff
  566. sess.preDemodDecim = decim1
  567. }
  568. filtered := sess.preDemodFIR.ProcessInto(fullSnip, sess.growIQ(len(fullSnip)))
  569. dec = dsp.Decimate(filtered, decim1)
  570. } else {
  571. dec = fullSnip
  572. }
  573. // --- FM Demod ---
  574. audio := d.Demod(dec, actualDemodRate)
  575. if len(audio) == 0 {
  576. return nil, 0
  577. }
  578. // --- Trim the 1-sample FM discriminator overlap ---
  579. if trimSamples > 0 {
  580. audioTrim := trimSamples / decim1
  581. if audioTrim < 1 {
  582. audioTrim = 1 // at minimum trim 1 audio sample
  583. }
  584. if audioTrim > 0 && audioTrim < len(audio) {
  585. audio = audio[audioTrim:]
  586. }
  587. }
  588. // --- Stateful stereo decode with conservative lock/hysteresis ---
  589. channels := 1
  590. if isWFMStereo {
  591. channels = 2 // keep transport format stable for live WFM_STEREO sessions
  592. stereoAudio, locked := sess.stereoDecodeStateful(audio, actualDemodRate)
  593. if locked {
  594. sess.stereoOnCount++
  595. sess.stereoOffCount = 0
  596. if sess.stereoOnCount >= 4 {
  597. sess.stereoEnabled = true
  598. }
  599. } else {
  600. sess.stereoOnCount = 0
  601. sess.stereoOffCount++
  602. if sess.stereoOffCount >= 10 {
  603. sess.stereoEnabled = false
  604. }
  605. }
  606. if sess.stereoEnabled && len(stereoAudio) > 0 {
  607. audio = stereoAudio
  608. } else {
  609. dual := make([]float32, len(audio)*2)
  610. for i, s := range audio {
  611. dual[i*2] = s
  612. dual[i*2+1] = s
  613. }
  614. audio = dual
  615. }
  616. }
  617. // --- Polyphase resample to exact 48kHz ---
  618. if actualDemodRate != streamAudioRate {
  619. if channels > 1 {
  620. if sess.stereoResampler == nil || sess.stereoResamplerRate != actualDemodRate {
  621. sess.stereoResampler = dsp.NewStereoResampler(actualDemodRate, streamAudioRate, resamplerTaps)
  622. sess.stereoResamplerRate = actualDemodRate
  623. }
  624. audio = sess.stereoResampler.Process(audio)
  625. } else {
  626. if sess.monoResampler == nil || sess.monoResamplerRate != actualDemodRate {
  627. sess.monoResampler = dsp.NewResampler(actualDemodRate, streamAudioRate, resamplerTaps)
  628. sess.monoResamplerRate = actualDemodRate
  629. }
  630. audio = sess.monoResampler.Process(audio)
  631. }
  632. }
  633. // --- De-emphasis (configurable: 50µs Europe, 75µs US/Japan, 0=disabled) ---
  634. if isWFM && sess.deemphasisUs > 0 && streamAudioRate > 0 {
  635. tau := sess.deemphasisUs * 1e-6
  636. alpha := math.Exp(-1.0 / (float64(streamAudioRate) * tau))
  637. if channels > 1 {
  638. nFrames := len(audio) / channels
  639. yL, yR := sess.deemphL, sess.deemphR
  640. for i := 0; i < nFrames; i++ {
  641. yL = alpha*yL + (1-alpha)*float64(audio[i*2])
  642. audio[i*2] = float32(yL)
  643. yR = alpha*yR + (1-alpha)*float64(audio[i*2+1])
  644. audio[i*2+1] = float32(yR)
  645. }
  646. sess.deemphL, sess.deemphR = yL, yR
  647. } else {
  648. y := sess.deemphL
  649. for i := range audio {
  650. y = alpha*y + (1-alpha)*float64(audio[i])
  651. audio[i] = float32(y)
  652. }
  653. sess.deemphL = y
  654. }
  655. }
  656. if isWFM {
  657. for i := range audio {
  658. audio[i] *= 0.35
  659. }
  660. }
  661. return audio, streamAudioRate
  662. }
  663. // pllCoefficients returns the proportional (alpha) and integral (beta) gains
  664. // for a Type-II PLL using the specified loop bandwidth and damping factor.
  665. // loopBW is in Hz, sampleRate in samples/sec.
  666. func pllCoefficients(loopBW, damping float64, sampleRate int) (float64, float64) {
  667. if sampleRate <= 0 || loopBW <= 0 {
  668. return 0, 0
  669. }
  670. bl := loopBW / float64(sampleRate)
  671. theta := bl / (damping + 0.25/damping)
  672. d := 1 + 2*damping*theta + theta*theta
  673. alpha := (4 * damping * theta) / d
  674. beta := (4 * theta * theta) / d
  675. return alpha, beta
  676. }
  677. // stereoDecodeStateful: pilot-locked 38kHz oscillator for L-R extraction.
  678. // Uses persistent FIR filter state across frames for click-free stereo.
  679. // Reuses session scratch buffers to minimize allocations.
  680. func (sess *streamSession) stereoDecodeStateful(mono []float32, sampleRate int) ([]float32, bool) {
  681. if len(mono) == 0 || sampleRate <= 0 {
  682. return nil, false
  683. }
  684. n := len(mono)
  685. // Rebuild rate-dependent stereo filters when sampleRate changes
  686. if sess.stereoLPF == nil || sess.stereoFilterRate != sampleRate {
  687. lp := dsp.LowpassFIR(15000, sampleRate, 101)
  688. sess.stereoLPF = dsp.NewStatefulFIRReal(lp)
  689. sess.stereoBPHi = dsp.NewStatefulFIRReal(dsp.LowpassFIR(53000, sampleRate, 101))
  690. sess.stereoBPLo = dsp.NewStatefulFIRReal(dsp.LowpassFIR(23000, sampleRate, 101))
  691. sess.stereoLRLPF = dsp.NewStatefulFIRReal(lp)
  692. // Narrow pilot bandpass via LPF(21k)-LPF(17k).
  693. sess.pilotLPFHi = dsp.NewStatefulFIRReal(dsp.LowpassFIR(21000, sampleRate, 101))
  694. sess.pilotLPFLo = dsp.NewStatefulFIRReal(dsp.LowpassFIR(17000, sampleRate, 101))
  695. sess.stereoFilterRate = sampleRate
  696. // Initialize PLL for 19kHz pilot tracking.
  697. sess.pilotPhase = 0
  698. sess.pilotFreq = 2 * math.Pi * 19000 / float64(sampleRate)
  699. sess.pilotAlpha, sess.pilotBeta = pllCoefficients(50, 0.707, sampleRate)
  700. sess.pilotErrAvg = 0
  701. sess.pilotI = 0
  702. sess.pilotQ = 0
  703. sess.pilotLPAlpha = 1 - math.Exp(-2*math.Pi*200/float64(sampleRate))
  704. }
  705. // Reuse scratch for intermediates: lpr, bpfLR, lr, work1, work2.
  706. scratch := sess.growAudio(n * 5)
  707. lpr := scratch[:n]
  708. bpfLR := scratch[n : 2*n]
  709. lr := scratch[2*n : 3*n]
  710. work1 := scratch[3*n : 4*n]
  711. work2 := scratch[4*n : 5*n]
  712. sess.stereoLPF.ProcessInto(mono, lpr)
  713. // 23-53kHz bandpass for L-R DSB-SC.
  714. sess.stereoBPHi.ProcessInto(mono, work1)
  715. sess.stereoBPLo.ProcessInto(mono, work2)
  716. for i := 0; i < n; i++ {
  717. bpfLR[i] = work1[i] - work2[i]
  718. }
  719. // 19kHz pilot bandpass for PLL.
  720. sess.pilotLPFHi.ProcessInto(mono, work1)
  721. sess.pilotLPFLo.ProcessInto(mono, work2)
  722. for i := 0; i < n; i++ {
  723. work1[i] = work1[i] - work2[i]
  724. }
  725. pilot := work1
  726. phase := sess.pilotPhase
  727. freq := sess.pilotFreq
  728. alpha := sess.pilotAlpha
  729. beta := sess.pilotBeta
  730. iState := sess.pilotI
  731. qState := sess.pilotQ
  732. lpAlpha := sess.pilotLPAlpha
  733. minFreq := 2 * math.Pi * 17000 / float64(sampleRate)
  734. maxFreq := 2 * math.Pi * 21000 / float64(sampleRate)
  735. var pilotPower float64
  736. var totalPower float64
  737. var errSum float64
  738. for i := 0; i < n; i++ {
  739. p := float64(pilot[i])
  740. sinP, cosP := math.Sincos(phase)
  741. iMix := p * cosP
  742. qMix := p * -sinP
  743. iState += lpAlpha * (iMix - iState)
  744. qState += lpAlpha * (qMix - qState)
  745. err := math.Atan2(qState, iState)
  746. freq += beta * err
  747. if freq < minFreq {
  748. freq = minFreq
  749. } else if freq > maxFreq {
  750. freq = maxFreq
  751. }
  752. phase += freq + alpha*err
  753. if phase > 2*math.Pi {
  754. phase -= 2 * math.Pi
  755. } else if phase < 0 {
  756. phase += 2 * math.Pi
  757. }
  758. totalPower += float64(mono[i]) * float64(mono[i])
  759. pilotPower += p * p
  760. errSum += math.Abs(err)
  761. lr[i] = bpfLR[i] * float32(2*math.Sin(2*phase))
  762. }
  763. sess.pilotPhase = phase
  764. sess.pilotFreq = freq
  765. sess.pilotI = iState
  766. sess.pilotQ = qState
  767. blockErr := errSum / float64(n)
  768. sess.pilotErrAvg = 0.9*sess.pilotErrAvg + 0.1*blockErr
  769. lr = sess.stereoLRLPF.ProcessInto(lr, lr)
  770. pilotRatio := 0.0
  771. if totalPower > 0 {
  772. pilotRatio = pilotPower / totalPower
  773. }
  774. freqHz := sess.pilotFreq * float64(sampleRate) / (2 * math.Pi)
  775. // Lock heuristics: pilot power fraction and PLL phase error stability.
  776. // Pilot power is a small but stable fraction of composite energy; require
  777. // a modest floor plus PLL settling to avoid flapping in noise.
  778. locked := pilotRatio > 0.003 && math.Abs(freqHz-19000) < 250 && sess.pilotErrAvg < 0.35
  779. out := make([]float32, n*2)
  780. for i := 0; i < n; i++ {
  781. out[i*2] = 0.5 * (lpr[i] + lr[i])
  782. out[i*2+1] = 0.5 * (lpr[i] - lr[i])
  783. }
  784. return out, locked
  785. }
  786. // dspStateSnapshot captures persistent DSP state for segment splits.
  787. type dspStateSnapshot struct {
  788. overlapIQ []complex64
  789. deemphL float64
  790. deemphR float64
  791. pilotPhase float64
  792. pilotFreq float64
  793. pilotAlpha float64
  794. pilotBeta float64
  795. pilotErrAvg float64
  796. pilotI float64
  797. pilotQ float64
  798. pilotLPAlpha float64
  799. monoResampler *dsp.Resampler
  800. monoResamplerRate int
  801. stereoResampler *dsp.StereoResampler
  802. stereoResamplerRate int
  803. stereoLPF *dsp.StatefulFIRReal
  804. stereoFilterRate int
  805. stereoBPHi *dsp.StatefulFIRReal
  806. stereoBPLo *dsp.StatefulFIRReal
  807. stereoLRLPF *dsp.StatefulFIRReal
  808. stereoAALPF *dsp.StatefulFIRReal
  809. pilotLPFHi *dsp.StatefulFIRReal
  810. pilotLPFLo *dsp.StatefulFIRReal
  811. preDemodFIR *dsp.StatefulFIRComplex
  812. preDemodDecim int
  813. preDemodRate int
  814. preDemodCutoff float64
  815. }
  816. func (sess *streamSession) captureDSPState() dspStateSnapshot {
  817. return dspStateSnapshot{
  818. overlapIQ: sess.overlapIQ,
  819. deemphL: sess.deemphL,
  820. deemphR: sess.deemphR,
  821. pilotPhase: sess.pilotPhase,
  822. pilotFreq: sess.pilotFreq,
  823. pilotAlpha: sess.pilotAlpha,
  824. pilotBeta: sess.pilotBeta,
  825. pilotErrAvg: sess.pilotErrAvg,
  826. pilotI: sess.pilotI,
  827. pilotQ: sess.pilotQ,
  828. pilotLPAlpha: sess.pilotLPAlpha,
  829. monoResampler: sess.monoResampler,
  830. monoResamplerRate: sess.monoResamplerRate,
  831. stereoResampler: sess.stereoResampler,
  832. stereoResamplerRate: sess.stereoResamplerRate,
  833. stereoLPF: sess.stereoLPF,
  834. stereoFilterRate: sess.stereoFilterRate,
  835. stereoBPHi: sess.stereoBPHi,
  836. stereoBPLo: sess.stereoBPLo,
  837. stereoLRLPF: sess.stereoLRLPF,
  838. stereoAALPF: sess.stereoAALPF,
  839. pilotLPFHi: sess.pilotLPFHi,
  840. pilotLPFLo: sess.pilotLPFLo,
  841. preDemodFIR: sess.preDemodFIR,
  842. preDemodDecim: sess.preDemodDecim,
  843. preDemodRate: sess.preDemodRate,
  844. preDemodCutoff: sess.preDemodCutoff,
  845. }
  846. }
  847. func (sess *streamSession) restoreDSPState(s dspStateSnapshot) {
  848. sess.overlapIQ = s.overlapIQ
  849. sess.deemphL = s.deemphL
  850. sess.deemphR = s.deemphR
  851. sess.pilotPhase = s.pilotPhase
  852. sess.pilotFreq = s.pilotFreq
  853. sess.pilotAlpha = s.pilotAlpha
  854. sess.pilotBeta = s.pilotBeta
  855. sess.pilotErrAvg = s.pilotErrAvg
  856. sess.pilotI = s.pilotI
  857. sess.pilotQ = s.pilotQ
  858. sess.pilotLPAlpha = s.pilotLPAlpha
  859. sess.monoResampler = s.monoResampler
  860. sess.monoResamplerRate = s.monoResamplerRate
  861. sess.stereoResampler = s.stereoResampler
  862. sess.stereoResamplerRate = s.stereoResamplerRate
  863. sess.stereoLPF = s.stereoLPF
  864. sess.stereoFilterRate = s.stereoFilterRate
  865. sess.stereoBPHi = s.stereoBPHi
  866. sess.stereoBPLo = s.stereoBPLo
  867. sess.stereoLRLPF = s.stereoLRLPF
  868. sess.stereoAALPF = s.stereoAALPF
  869. sess.pilotLPFHi = s.pilotLPFHi
  870. sess.pilotLPFLo = s.pilotLPFLo
  871. sess.preDemodFIR = s.preDemodFIR
  872. sess.preDemodDecim = s.preDemodDecim
  873. sess.preDemodRate = s.preDemodRate
  874. sess.preDemodCutoff = s.preDemodCutoff
  875. }
  876. // ---------------------------------------------------------------------------
  877. // Session management helpers
  878. // ---------------------------------------------------------------------------
  879. func (st *Streamer) openRecordingSession(sig *detector.Signal, now time.Time) (*streamSession, error) {
  880. outputDir := st.policy.OutputDir
  881. if outputDir == "" {
  882. outputDir = "data/recordings"
  883. }
  884. demodName, channels := resolveDemod(sig)
  885. dirName := fmt.Sprintf("%s_%.0fHz_stream%d",
  886. now.Format("2006-01-02T15-04-05"), sig.CenterHz, sig.ID)
  887. dir := filepath.Join(outputDir, dirName)
  888. if err := os.MkdirAll(dir, 0o755); err != nil {
  889. return nil, err
  890. }
  891. wavPath := filepath.Join(dir, "audio.wav")
  892. f, err := os.Create(wavPath)
  893. if err != nil {
  894. return nil, err
  895. }
  896. if err := writeStreamWAVHeader(f, streamAudioRate, channels); err != nil {
  897. f.Close()
  898. return nil, err
  899. }
  900. sess := &streamSession{
  901. signalID: sig.ID,
  902. centerHz: sig.CenterHz,
  903. bwHz: sig.BWHz,
  904. snrDb: sig.SNRDb,
  905. peakDb: sig.PeakDb,
  906. class: sig.Class,
  907. startTime: now,
  908. lastFeed: now,
  909. dir: dir,
  910. wavFile: f,
  911. wavBuf: bufio.NewWriterSize(f, 64*1024),
  912. sampleRate: streamAudioRate,
  913. channels: channels,
  914. demodName: demodName,
  915. deemphasisUs: st.policy.DeemphasisUs,
  916. }
  917. log.Printf("STREAM: opened recording signal=%d %.1fMHz %s dir=%s",
  918. sig.ID, sig.CenterHz/1e6, demodName, dirName)
  919. return sess, nil
  920. }
  921. func (st *Streamer) openListenSession(sig *detector.Signal, now time.Time) *streamSession {
  922. demodName, channels := resolveDemod(sig)
  923. for _, pl := range st.pendingListens {
  924. if math.Abs(sig.CenterHz-pl.freq) < 200000 {
  925. if requested := normalizeRequestedMode(pl.mode); requested != "" {
  926. demodName = requested
  927. if demodName == "WFM_STEREO" {
  928. channels = 2
  929. } else if d := demod.Get(demodName); d != nil {
  930. channels = d.Channels()
  931. } else {
  932. channels = 1
  933. }
  934. break
  935. }
  936. }
  937. }
  938. sess := &streamSession{
  939. signalID: sig.ID,
  940. centerHz: sig.CenterHz,
  941. bwHz: sig.BWHz,
  942. snrDb: sig.SNRDb,
  943. peakDb: sig.PeakDb,
  944. class: sig.Class,
  945. startTime: now,
  946. lastFeed: now,
  947. listenOnly: true,
  948. sampleRate: streamAudioRate,
  949. channels: channels,
  950. demodName: demodName,
  951. deemphasisUs: st.policy.DeemphasisUs,
  952. }
  953. log.Printf("STREAM: opened listen-only signal=%d %.1fMHz %s",
  954. sig.ID, sig.CenterHz/1e6, demodName)
  955. return sess
  956. }
  957. func resolveDemod(sig *detector.Signal) (string, int) {
  958. demodName := "NFM"
  959. if sig.Class != nil {
  960. if n := mapClassToDemod(sig.Class.ModType); n != "" {
  961. demodName = n
  962. }
  963. }
  964. channels := 1
  965. if demodName == "WFM_STEREO" {
  966. channels = 2
  967. } else if d := demod.Get(demodName); d != nil {
  968. channels = d.Channels()
  969. }
  970. return demodName, channels
  971. }
  972. func normalizeRequestedMode(mode string) string {
  973. switch strings.ToUpper(strings.TrimSpace(mode)) {
  974. case "", "AUTO":
  975. return ""
  976. case "WFM", "WFM_STEREO", "NFM", "AM", "USB", "LSB", "CW":
  977. return strings.ToUpper(strings.TrimSpace(mode))
  978. default:
  979. return ""
  980. }
  981. }
  982. // growIQ returns a complex64 slice of at least n elements, reusing sess.scratchIQ.
  983. func (sess *streamSession) growIQ(n int) []complex64 {
  984. if cap(sess.scratchIQ) >= n {
  985. return sess.scratchIQ[:n]
  986. }
  987. sess.scratchIQ = make([]complex64, n, n*5/4)
  988. return sess.scratchIQ
  989. }
  990. // growAudio returns a float32 slice of at least n elements, reusing sess.scratchAudio.
  991. func (sess *streamSession) growAudio(n int) []float32 {
  992. if cap(sess.scratchAudio) >= n {
  993. return sess.scratchAudio[:n]
  994. }
  995. sess.scratchAudio = make([]float32, n, n*5/4)
  996. return sess.scratchAudio
  997. }
  998. // growPCM returns a byte slice of at least n bytes, reusing sess.scratchPCM.
  999. func (sess *streamSession) growPCM(n int) []byte {
  1000. if cap(sess.scratchPCM) >= n {
  1001. return sess.scratchPCM[:n]
  1002. }
  1003. sess.scratchPCM = make([]byte, n, n*5/4)
  1004. return sess.scratchPCM
  1005. }
  1006. func convertToListenOnly(sess *streamSession) {
  1007. if sess.wavBuf != nil {
  1008. _ = sess.wavBuf.Flush()
  1009. }
  1010. if sess.wavFile != nil {
  1011. fixStreamWAVHeader(sess.wavFile, sess.wavSamples, sess.sampleRate, sess.channels)
  1012. sess.wavFile.Close()
  1013. }
  1014. sess.wavFile = nil
  1015. sess.wavBuf = nil
  1016. sess.listenOnly = true
  1017. log.Printf("STREAM: converted signal=%d to listen-only", sess.signalID)
  1018. }
  1019. func closeSession(sess *streamSession, policy *Policy) {
  1020. if sess.listenOnly {
  1021. return
  1022. }
  1023. if sess.wavBuf != nil {
  1024. _ = sess.wavBuf.Flush()
  1025. }
  1026. if sess.wavFile != nil {
  1027. fixStreamWAVHeader(sess.wavFile, sess.wavSamples, sess.sampleRate, sess.channels)
  1028. sess.wavFile.Close()
  1029. sess.wavFile = nil
  1030. sess.wavBuf = nil
  1031. }
  1032. dur := sess.lastFeed.Sub(sess.startTime)
  1033. files := map[string]any{
  1034. "audio": "audio.wav",
  1035. "audio_sample_rate": sess.sampleRate,
  1036. "audio_channels": sess.channels,
  1037. "audio_demod": sess.demodName,
  1038. "recording_mode": "streaming",
  1039. }
  1040. meta := Meta{
  1041. EventID: sess.signalID,
  1042. Start: sess.startTime,
  1043. End: sess.lastFeed,
  1044. CenterHz: sess.centerHz,
  1045. BandwidthHz: sess.bwHz,
  1046. SampleRate: sess.sampleRate,
  1047. SNRDb: sess.snrDb,
  1048. PeakDb: sess.peakDb,
  1049. Class: sess.class,
  1050. DurationMs: dur.Milliseconds(),
  1051. Files: files,
  1052. }
  1053. b, err := json.MarshalIndent(meta, "", " ")
  1054. if err == nil {
  1055. _ = os.WriteFile(filepath.Join(sess.dir, "meta.json"), b, 0o644)
  1056. }
  1057. if policy != nil {
  1058. enforceQuota(policy.OutputDir, policy.MaxDiskMB)
  1059. }
  1060. }
  1061. func (st *Streamer) fanoutPCM(sess *streamSession, pcm []byte, pcmLen int) {
  1062. if len(sess.audioSubs) == 0 {
  1063. return
  1064. }
  1065. // Tag + copy for all subscribers: 0x01 prefix = PCM audio
  1066. tagged := make([]byte, 1+pcmLen)
  1067. tagged[0] = 0x01
  1068. copy(tagged[1:], pcm[:pcmLen])
  1069. alive := sess.audioSubs[:0]
  1070. for _, sub := range sess.audioSubs {
  1071. select {
  1072. case sub.ch <- tagged:
  1073. default:
  1074. }
  1075. alive = append(alive, sub)
  1076. }
  1077. sess.audioSubs = alive
  1078. }
  1079. func (st *Streamer) classAllowed(cls *classifier.Classification) bool {
  1080. if len(st.policy.ClassFilter) == 0 {
  1081. return true
  1082. }
  1083. if cls == nil {
  1084. return false
  1085. }
  1086. for _, f := range st.policy.ClassFilter {
  1087. if strings.EqualFold(f, string(cls.ModType)) {
  1088. return true
  1089. }
  1090. }
  1091. return false
  1092. }
  1093. // ErrNoSession is returned when no matching signal session exists.
  1094. var ErrNoSession = errors.New("no active or pending session for this frequency")
  1095. // ---------------------------------------------------------------------------
  1096. // WAV header helpers
  1097. // ---------------------------------------------------------------------------
  1098. func writeStreamWAVHeader(f *os.File, sampleRate int, channels int) error {
  1099. if channels <= 0 {
  1100. channels = 1
  1101. }
  1102. hdr := make([]byte, 44)
  1103. copy(hdr[0:4], "RIFF")
  1104. binary.LittleEndian.PutUint32(hdr[4:8], 36)
  1105. copy(hdr[8:12], "WAVE")
  1106. copy(hdr[12:16], "fmt ")
  1107. binary.LittleEndian.PutUint32(hdr[16:20], 16)
  1108. binary.LittleEndian.PutUint16(hdr[20:22], 1)
  1109. binary.LittleEndian.PutUint16(hdr[22:24], uint16(channels))
  1110. binary.LittleEndian.PutUint32(hdr[24:28], uint32(sampleRate))
  1111. binary.LittleEndian.PutUint32(hdr[28:32], uint32(sampleRate*channels*2))
  1112. binary.LittleEndian.PutUint16(hdr[32:34], uint16(channels*2))
  1113. binary.LittleEndian.PutUint16(hdr[34:36], 16)
  1114. copy(hdr[36:40], "data")
  1115. binary.LittleEndian.PutUint32(hdr[40:44], 0)
  1116. _, err := f.Write(hdr)
  1117. return err
  1118. }
  1119. func fixStreamWAVHeader(f *os.File, totalSamples int64, sampleRate int, channels int) {
  1120. dataSize := uint32(totalSamples * 2)
  1121. var buf [4]byte
  1122. binary.LittleEndian.PutUint32(buf[:], 36+dataSize)
  1123. if _, err := f.Seek(4, 0); err != nil {
  1124. return
  1125. }
  1126. _, _ = f.Write(buf[:])
  1127. binary.LittleEndian.PutUint32(buf[:], uint32(sampleRate))
  1128. if _, err := f.Seek(24, 0); err != nil {
  1129. return
  1130. }
  1131. _, _ = f.Write(buf[:])
  1132. binary.LittleEndian.PutUint32(buf[:], uint32(sampleRate*channels*2))
  1133. if _, err := f.Seek(28, 0); err != nil {
  1134. return
  1135. }
  1136. _, _ = f.Write(buf[:])
  1137. binary.LittleEndian.PutUint32(buf[:], dataSize)
  1138. if _, err := f.Seek(40, 0); err != nil {
  1139. return
  1140. }
  1141. _, _ = f.Write(buf[:])
  1142. }