| @@ -97,7 +97,10 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||||
| MaxDiskMB: cfg.Recorder.MaxDiskMB, | MaxDiskMB: cfg.Recorder.MaxDiskMB, | ||||
| OutputDir: cfg.Recorder.OutputDir, | OutputDir: cfg.Recorder.OutputDir, | ||||
| ClassFilter: cfg.Recorder.ClassFilter, | ClassFilter: cfg.Recorder.ClassFilter, | ||||
| RingSeconds: cfg.Recorder.RingSeconds, | |||||
| RingSeconds: cfg.Recorder.RingSeconds, | |||||
| DeemphasisUs: cfg.Recorder.DeemphasisUs, | |||||
| ExtractionTaps: cfg.Recorder.ExtractionTaps, | |||||
| ExtractionBwMult: cfg.Recorder.ExtractionBwMult, | |||||
| }, cfg.CenterHz, buildDecoderMap(cfg)) | }, cfg.CenterHz, buildDecoderMap(cfg)) | ||||
| } | } | ||||
| if upd.det != nil { | if upd.det != nil { | ||||
| @@ -180,20 +183,25 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||||
| } | } | ||||
| var spectrum []float64 | var spectrum []float64 | ||||
| if useGPU && gpuEngine != nil { | if useGPU && gpuEngine != nil { | ||||
| // GPU FFT: apply window to a COPY — allIQ must stay unmodified | |||||
| // for extractForStreaming which needs raw IQ for signal extraction. | |||||
| gpuBuf := make([]complex64, len(iq)) | |||||
| if len(window) == len(iq) { | if len(window) == len(iq) { | ||||
| for i := 0; i < len(iq); i++ { | for i := 0; i < len(iq); i++ { | ||||
| v := iq[i] | v := iq[i] | ||||
| w := float32(window[i]) | w := float32(window[i]) | ||||
| iq[i] = complex(real(v)*w, imag(v)*w) | |||||
| gpuBuf[i] = complex(real(v)*w, imag(v)*w) | |||||
| } | } | ||||
| } else { | |||||
| copy(gpuBuf, iq) | |||||
| } | } | ||||
| out, err := gpuEngine.Exec(iq) | |||||
| out, err := gpuEngine.Exec(gpuBuf) | |||||
| if err != nil { | if err != nil { | ||||
| if gpuState != nil { | if gpuState != nil { | ||||
| gpuState.set(false, err) | gpuState.set(false, err) | ||||
| } | } | ||||
| useGPU = false | useGPU = false | ||||
| spectrum = fftutil.SpectrumWithPlan(iq, nil, plan) | |||||
| spectrum = fftutil.SpectrumWithPlan(gpuBuf, nil, plan) | |||||
| } else { | } else { | ||||
| spectrum = fftutil.SpectrumFromFFT(out) | spectrum = fftutil.SpectrumFromFFT(out) | ||||
| } | } | ||||
| @@ -322,11 +330,28 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||||
| } | } | ||||
| } | } | ||||
| // Cleanup streamPhaseState for disappeared signals | |||||
| if len(streamPhaseState) > 0 { | |||||
| sigIDs := make(map[int64]bool, len(signals)) | |||||
| for _, s := range signals { | |||||
| sigIDs[s.ID] = true | |||||
| } | |||||
| for id := range streamPhaseState { | |||||
| if !sigIDs[id] { | |||||
| delete(streamPhaseState, id) | |||||
| } | |||||
| } | |||||
| } | |||||
| // GPU-extract signal snippets with phase-continuous FreqShift and | // GPU-extract signal snippets with phase-continuous FreqShift and | ||||
| // IQ overlap for FIR halo. Heavy work on GPU, only demod runs async. | // IQ overlap for FIR halo. Heavy work on GPU, only demod runs async. | ||||
| displaySignals = det.StableSignals() | displaySignals = det.StableSignals() | ||||
| if rec != nil && len(displaySignals) > 0 && len(allIQ) > 0 { | if rec != nil && len(displaySignals) > 0 && len(allIQ) > 0 { | ||||
| streamSnips, streamRates := extractForStreaming(extractMgr, allIQ, cfg.SampleRate, cfg.CenterHz, displaySignals, streamPhaseState, streamOverlap) | |||||
| aqCfg := extractionConfig{ | |||||
| firTaps: cfg.Recorder.ExtractionTaps, | |||||
| bwMult: cfg.Recorder.ExtractionBwMult, | |||||
| } | |||||
| streamSnips, streamRates := extractForStreaming(extractMgr, allIQ, cfg.SampleRate, cfg.CenterHz, displaySignals, streamPhaseState, streamOverlap, aqCfg) | |||||
| items := make([]recorder.StreamFeedItem, 0, len(displaySignals)) | items := make([]recorder.StreamFeedItem, 0, len(displaySignals)) | ||||
| for j, ds := range displaySignals { | for j, ds := range displaySignals { | ||||
| if ds.ID == 0 || ds.Class == nil { | if ds.ID == 0 || ds.Class == nil { | ||||
| @@ -214,7 +214,13 @@ type streamIQOverlap struct { | |||||
| tail []complex64 | tail []complex64 | ||||
| } | } | ||||
| const streamOverlapLen = 512 // must be >= FIR tap count (101) with margin | |||||
| // extractionConfig holds audio quality settings for signal extraction. | |||||
| type extractionConfig struct { | |||||
| firTaps int // AQ-3: FIR tap count (default 101) | |||||
| bwMult float64 // AQ-5: BW multiplier (default 1.2) | |||||
| } | |||||
| const streamOverlapLen = 512 // must be >= FIR tap count with margin | |||||
| // extractForStreaming performs GPU-accelerated extraction with: | // extractForStreaming performs GPU-accelerated extraction with: | ||||
| // - Per-signal phase-continuous FreqShift (via PhaseStart in ExtractJob) | // - Per-signal phase-continuous FreqShift (via PhaseStart in ExtractJob) | ||||
| @@ -229,6 +235,7 @@ func extractForStreaming( | |||||
| signals []detector.Signal, | signals []detector.Signal, | ||||
| phaseState map[int64]*streamExtractState, | phaseState map[int64]*streamExtractState, | ||||
| overlap *streamIQOverlap, | overlap *streamIQOverlap, | ||||
| aqCfg extractionConfig, | |||||
| ) ([][]complex64, []int) { | ) ([][]complex64, []int) { | ||||
| out := make([][]complex64, len(signals)) | out := make([][]complex64, len(signals)) | ||||
| rates := make([]int, len(signals)) | rates := make([]int, len(signals)) | ||||
| @@ -236,6 +243,12 @@ func extractForStreaming( | |||||
| return out, rates | return out, rates | ||||
| } | } | ||||
| // AQ-3: Use configured overlap length (must cover FIR taps) | |||||
| overlapNeeded := streamOverlapLen | |||||
| if aqCfg.firTaps > 0 && aqCfg.firTaps+64 > overlapNeeded { | |||||
| overlapNeeded = aqCfg.firTaps + 64 | |||||
| } | |||||
| // Prepend overlap from previous frame so FIR kernel has real halo data | // Prepend overlap from previous frame so FIR kernel has real halo data | ||||
| var gpuIQ []complex64 | var gpuIQ []complex64 | ||||
| overlapLen := len(overlap.tail) | overlapLen := len(overlap.tail) | ||||
| @@ -248,19 +261,25 @@ func extractForStreaming( | |||||
| overlapLen = 0 | overlapLen = 0 | ||||
| } | } | ||||
| // Save tail for next frame | |||||
| if len(allIQ) > streamOverlapLen { | |||||
| overlap.tail = append(overlap.tail[:0], allIQ[len(allIQ)-streamOverlapLen:]...) | |||||
| // Save tail for next frame (sized to cover configured FIR taps) | |||||
| if len(allIQ) > overlapNeeded { | |||||
| overlap.tail = append(overlap.tail[:0], allIQ[len(allIQ)-overlapNeeded:]...) | |||||
| } else { | } else { | ||||
| overlap.tail = append(overlap.tail[:0], allIQ...) | overlap.tail = append(overlap.tail[:0], allIQ...) | ||||
| } | } | ||||
| decimTarget := 200000 | decimTarget := 200000 | ||||
| // AQ-5: BW multiplier for extraction (wider = better S/N for weak signals) | |||||
| bwMult := aqCfg.bwMult | |||||
| if bwMult <= 0 { | |||||
| bwMult = 1.0 | |||||
| } | |||||
| // Build jobs with per-signal phase | // Build jobs with per-signal phase | ||||
| jobs := make([]gpudemod.ExtractJob, len(signals)) | jobs := make([]gpudemod.ExtractJob, len(signals)) | ||||
| for i, sig := range signals { | for i, sig := range signals { | ||||
| bw := sig.BWHz | |||||
| bw := sig.BWHz * bwMult // AQ-5: widen extraction BW | |||||
| sigMHz := sig.CenterHz / 1e6 | sigMHz := sig.CenterHz / 1e6 | ||||
| isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || | isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || | ||||
| (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) | (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) | ||||
| @@ -352,7 +371,11 @@ func extractForStreaming( | |||||
| if cutoff > float64(sampleRate)/2-1 { | if cutoff > float64(sampleRate)/2-1 { | ||||
| cutoff = float64(sampleRate)/2 - 1 | cutoff = float64(sampleRate)/2 - 1 | ||||
| } | } | ||||
| taps := dsp.LowpassFIR(cutoff, sampleRate, 101) | |||||
| firTaps := 101 | |||||
| if aqCfg.firTaps > 0 { | |||||
| firTaps = aqCfg.firTaps | |||||
| } | |||||
| taps := dsp.LowpassFIR(cutoff, sampleRate, firTaps) | |||||
| filtered := dsp.ApplyFIR(shifted, taps) | filtered := dsp.ApplyFIR(shifted, taps) | ||||
| decim := sampleRate / decimTarget | decim := sampleRate / decimTarget | ||||
| if decim < 1 { | if decim < 1 { | ||||
| @@ -1,8 +1,10 @@ | |||||
| package main | package main | ||||
| import ( | import ( | ||||
| "encoding/binary" | |||||
| "encoding/json" | "encoding/json" | ||||
| "log" | "log" | ||||
| "math" | |||||
| "time" | "time" | ||||
| "sdr-visual-suite/internal/detector" | "sdr-visual-suite/internal/detector" | ||||
| @@ -57,11 +59,15 @@ func (h *hub) remove(c *client) { | |||||
| } | } | ||||
| func (h *hub) broadcast(frame SpectrumFrame) { | func (h *hub) broadcast(frame SpectrumFrame) { | ||||
| b, err := json.Marshal(frame) | |||||
| if err != nil { | |||||
| log.Printf("marshal frame: %v", err) | |||||
| return | |||||
| // Pre-encode JSON for legacy clients (only if needed) | |||||
| var jsonBytes []byte | |||||
| // Pre-encode binary for binary clients at various decimation levels | |||||
| // We cache per unique maxBins value to avoid re-encoding | |||||
| type binCacheEntry struct { | |||||
| bins int | |||||
| data []byte | |||||
| } | } | ||||
| var binCache []binCacheEntry | |||||
| h.mu.Lock() | h.mu.Lock() | ||||
| clients := make([]*client, 0, len(h.clients)) | clients := make([]*client, 0, len(h.clients)) | ||||
| @@ -71,15 +77,165 @@ func (h *hub) broadcast(frame SpectrumFrame) { | |||||
| h.mu.Unlock() | h.mu.Unlock() | ||||
| for _, c := range clients { | for _, c := range clients { | ||||
| select { | |||||
| case c.send <- b: | |||||
| default: | |||||
| h.remove(c) | |||||
| // Frame rate limiting | |||||
| if c.targetFps > 0 && c.frameSkip > 1 { | |||||
| c.frameN++ | |||||
| if c.frameN%c.frameSkip != 0 { | |||||
| continue | |||||
| } | |||||
| } | |||||
| if c.binary { | |||||
| // Find or create cached binary encoding for this bin count | |||||
| bins := c.maxBins | |||||
| if bins <= 0 || bins >= len(frame.Spectrum) { | |||||
| bins = len(frame.Spectrum) | |||||
| } | |||||
| var encoded []byte | |||||
| for _, entry := range binCache { | |||||
| if entry.bins == bins { | |||||
| encoded = entry.data | |||||
| break | |||||
| } | |||||
| } | |||||
| if encoded == nil { | |||||
| encoded = encodeBinaryFrame(frame, bins) | |||||
| binCache = append(binCache, binCacheEntry{bins: bins, data: encoded}) | |||||
| } | |||||
| select { | |||||
| case c.send <- encoded: | |||||
| default: | |||||
| h.remove(c) | |||||
| } | |||||
| } else { | |||||
| // JSON path (legacy) | |||||
| if jsonBytes == nil { | |||||
| var err error | |||||
| jsonBytes, err = json.Marshal(frame) | |||||
| if err != nil { | |||||
| log.Printf("marshal frame: %v", err) | |||||
| return | |||||
| } | |||||
| } | |||||
| select { | |||||
| case c.send <- jsonBytes: | |||||
| default: | |||||
| h.remove(c) | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| h.frameCnt++ | h.frameCnt++ | ||||
| if time.Since(h.lastLogTs) > 2*time.Second { | if time.Since(h.lastLogTs) > 2*time.Second { | ||||
| h.lastLogTs = time.Now() | h.lastLogTs = time.Now() | ||||
| log.Printf("broadcast frames=%d clients=%d", h.frameCnt, len(clients)) | log.Printf("broadcast frames=%d clients=%d", h.frameCnt, len(clients)) | ||||
| } | } | ||||
| } | } | ||||
| // --------------------------------------------------------------------------- | |||||
| // Binary spectrum protocol v4 | |||||
| // --------------------------------------------------------------------------- | |||||
| // | |||||
| // Hybrid approach: spectrum data as compact binary, signals + debug as JSON. | |||||
| // | |||||
| // Layout (32-byte header): | |||||
| // [0:1] magic: 0x53 0x50 ("SP") | |||||
| // [2:3] version: uint16 LE = 4 | |||||
| // [4:11] timestamp: int64 LE (Unix millis) | |||||
| // [12:19] center_hz: float64 LE | |||||
| // [20:23] bin_count: uint32 LE (supports FFT up to 4 billion) | |||||
| // [24:27] sample_rate_hz: uint32 LE (Hz, max ~4.29 GHz) | |||||
| // [28:31] json_offset: uint32 LE (byte offset where JSON starts) | |||||
| // | |||||
| // [32 .. 32+bins*2-1] spectrum: int16 LE, dB × 100 | |||||
| // [json_offset ..] JSON: {"signals":[...],"debug":{...}} | |||||
| const binaryHeaderSize = 32 | |||||
| func encodeBinaryFrame(frame SpectrumFrame, targetBins int) []byte { | |||||
| spectrum := frame.Spectrum | |||||
| srcBins := len(spectrum) | |||||
| if targetBins <= 0 || targetBins > srcBins { | |||||
| targetBins = srcBins | |||||
| } | |||||
| var decimated []float64 | |||||
| if targetBins < srcBins && targetBins > 0 { | |||||
| decimated = decimateSpectrum(spectrum, targetBins) | |||||
| } else { | |||||
| decimated = spectrum | |||||
| targetBins = srcBins | |||||
| } | |||||
| // JSON-encode signals + debug (full fidelity) | |||||
| jsonPart, _ := json.Marshal(struct { | |||||
| Signals []detector.Signal `json:"signals"` | |||||
| Debug *SpectrumDebug `json:"debug,omitempty"` | |||||
| }{ | |||||
| Signals: frame.Signals, | |||||
| Debug: frame.Debug, | |||||
| }) | |||||
| specBytes := targetBins * 2 | |||||
| jsonOffset := uint32(binaryHeaderSize + specBytes) | |||||
| totalSize := int(jsonOffset) + len(jsonPart) | |||||
| buf := make([]byte, totalSize) | |||||
| // Header | |||||
| buf[0] = 0x53 // 'S' | |||||
| buf[1] = 0x50 // 'P' | |||||
| binary.LittleEndian.PutUint16(buf[2:4], 4) // version 4 | |||||
| binary.LittleEndian.PutUint64(buf[4:12], uint64(frame.Timestamp)) | |||||
| binary.LittleEndian.PutUint64(buf[12:20], math.Float64bits(frame.CenterHz)) | |||||
| binary.LittleEndian.PutUint32(buf[20:24], uint32(targetBins)) | |||||
| binary.LittleEndian.PutUint32(buf[24:28], uint32(frame.SampleHz)) | |||||
| binary.LittleEndian.PutUint32(buf[28:32], jsonOffset) | |||||
| // Spectrum (int16, dB × 100) | |||||
| off := binaryHeaderSize | |||||
| for i := 0; i < targetBins; i++ { | |||||
| v := decimated[i] * 100 | |||||
| if v > 32767 { | |||||
| v = 32767 | |||||
| } else if v < -32767 { | |||||
| v = -32767 | |||||
| } | |||||
| binary.LittleEndian.PutUint16(buf[off:off+2], uint16(int16(v))) | |||||
| off += 2 | |||||
| } | |||||
| // JSON signals + debug | |||||
| copy(buf[jsonOffset:], jsonPart) | |||||
| return buf | |||||
| } | |||||
| // decimateSpectrum reduces bins via peak-hold within each group. | |||||
| func decimateSpectrum(spectrum []float64, targetBins int) []float64 { | |||||
| src := len(spectrum) | |||||
| out := make([]float64, targetBins) | |||||
| ratio := float64(src) / float64(targetBins) | |||||
| for i := 0; i < targetBins; i++ { | |||||
| lo := int(float64(i) * ratio) | |||||
| hi := int(float64(i+1) * ratio) | |||||
| if hi > src { | |||||
| hi = src | |||||
| } | |||||
| if lo >= hi { | |||||
| if lo < src { | |||||
| out[i] = spectrum[lo] | |||||
| } | |||||
| continue | |||||
| } | |||||
| peak := spectrum[lo] | |||||
| for j := lo + 1; j < hi; j++ { | |||||
| if spectrum[j] > peak { | |||||
| peak = spectrum[j] | |||||
| } | |||||
| } | |||||
| out[i] = peak | |||||
| } | |||||
| return out | |||||
| } | |||||
| @@ -8,6 +8,7 @@ import ( | |||||
| "os" | "os" | ||||
| "os/signal" | "os/signal" | ||||
| "path/filepath" | "path/filepath" | ||||
| "runtime/debug" | |||||
| "sync" | "sync" | ||||
| "syscall" | "syscall" | ||||
| "time" | "time" | ||||
| @@ -24,6 +25,14 @@ import ( | |||||
| ) | ) | ||||
| func main() { | func main() { | ||||
| // Reduce GC target to limit peak memory. Default GOGC=100 lets heap | |||||
| // grow to 2× live set before collecting. GOGC=50 triggers GC at 1.5×, | |||||
| // halving the memory swings at a small CPU cost. | |||||
| debug.SetGCPercent(50) | |||||
| // Soft memory limit — GC will be more aggressive near this limit. | |||||
| // 1 GB is generous for 5 WFM-stereo signals + FFT + recordings. | |||||
| debug.SetMemoryLimit(1024 * 1024 * 1024) | |||||
| var cfgPath string | var cfgPath string | ||||
| var mockFlag bool | var mockFlag bool | ||||
| flag.StringVar(&cfgPath, "config", "config.yaml", "path to config YAML") | flag.StringVar(&cfgPath, "config", "config.yaml", "path to config YAML") | ||||
| @@ -100,7 +109,10 @@ func main() { | |||||
| MaxDiskMB: cfg.Recorder.MaxDiskMB, | MaxDiskMB: cfg.Recorder.MaxDiskMB, | ||||
| OutputDir: cfg.Recorder.OutputDir, | OutputDir: cfg.Recorder.OutputDir, | ||||
| ClassFilter: cfg.Recorder.ClassFilter, | ClassFilter: cfg.Recorder.ClassFilter, | ||||
| RingSeconds: cfg.Recorder.RingSeconds, | |||||
| RingSeconds: cfg.Recorder.RingSeconds, | |||||
| DeemphasisUs: cfg.Recorder.DeemphasisUs, | |||||
| ExtractionTaps: cfg.Recorder.ExtractionTaps, | |||||
| ExtractionBwMult: cfg.Recorder.ExtractionBwMult, | |||||
| }, cfg.CenterHz, decodeMap) | }, cfg.CenterHz, decodeMap) | ||||
| defer recMgr.Close() | defer recMgr.Close() | ||||
| @@ -33,6 +33,13 @@ type client struct { | |||||
| send chan []byte | send chan []byte | ||||
| done chan struct{} | done chan struct{} | ||||
| closeOnce sync.Once | closeOnce sync.Once | ||||
| // Per-client settings (set via initial config message) | |||||
| binary bool // send binary spectrum frames instead of JSON | |||||
| maxBins int // target bin count (0 = full resolution) | |||||
| targetFps int // target frame rate (0 = full rate) | |||||
| frameSkip int // skip counter: send every N-th frame | |||||
| frameN int // current frame counter | |||||
| } | } | ||||
| type hub struct { | type hub struct { | ||||
| @@ -27,7 +27,25 @@ func registerWSHandlers(mux *http.ServeMux, h *hub, recMgr *recorder.Manager) { | |||||
| log.Printf("ws upgrade failed: %v (origin: %s)", err, r.Header.Get("Origin")) | log.Printf("ws upgrade failed: %v (origin: %s)", err, r.Header.Get("Origin")) | ||||
| return | return | ||||
| } | } | ||||
| c := &client{conn: conn, send: make(chan []byte, 32), done: make(chan struct{})} | |||||
| // Parse query params for remote clients: ?binary=1&bins=2048&fps=5 | |||||
| q := r.URL.Query() | |||||
| c := &client{conn: conn, send: make(chan []byte, 64), done: make(chan struct{})} | |||||
| if q.Get("binary") == "1" || q.Get("binary") == "true" { | |||||
| c.binary = true | |||||
| } | |||||
| if v, err := strconv.Atoi(q.Get("bins")); err == nil && v > 0 { | |||||
| c.maxBins = v | |||||
| } | |||||
| if v, err := strconv.Atoi(q.Get("fps")); err == nil && v > 0 { | |||||
| c.targetFps = v | |||||
| // frameSkip: if server runs at ~15fps and client wants 5fps → skip 3 | |||||
| c.frameSkip = 15 / v | |||||
| if c.frameSkip < 1 { | |||||
| c.frameSkip = 1 | |||||
| } | |||||
| } | |||||
| h.add(c) | h.add(c) | ||||
| defer func() { | defer func() { | ||||
| h.remove(c) | h.remove(c) | ||||
| @@ -47,24 +65,56 @@ func registerWSHandlers(mux *http.ServeMux, h *hub, recMgr *recorder.Manager) { | |||||
| if !ok { | if !ok { | ||||
| return | return | ||||
| } | } | ||||
| _ = conn.SetWriteDeadline(time.Now().Add(200 * time.Millisecond)) | |||||
| if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { | |||||
| // Binary frames can be large (130KB+) — need more time | |||||
| deadline := 500 * time.Millisecond | |||||
| if !c.binary { | |||||
| deadline = 200 * time.Millisecond | |||||
| } | |||||
| _ = conn.SetWriteDeadline(time.Now().Add(deadline)) | |||||
| msgType := websocket.TextMessage | |||||
| if c.binary { | |||||
| msgType = websocket.BinaryMessage | |||||
| } | |||||
| if err := conn.WriteMessage(msgType, msg); err != nil { | |||||
| return | return | ||||
| } | } | ||||
| case <-ping.C: | case <-ping.C: | ||||
| _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) | _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) | ||||
| if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { | if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { | ||||
| log.Printf("ws ping error: %v", err) | |||||
| return | return | ||||
| } | } | ||||
| case <-c.done: | |||||
| return | |||||
| } | } | ||||
| } | } | ||||
| }() | }() | ||||
| // Read loop: handle config messages from client + keep-alive | |||||
| for { | for { | ||||
| _, _, err := conn.ReadMessage() | |||||
| _, msg, err := conn.ReadMessage() | |||||
| if err != nil { | if err != nil { | ||||
| return | return | ||||
| } | } | ||||
| // Try to parse as client config update | |||||
| var cfg struct { | |||||
| Binary *bool `json:"binary,omitempty"` | |||||
| Bins *int `json:"bins,omitempty"` | |||||
| FPS *int `json:"fps,omitempty"` | |||||
| } | |||||
| if json.Unmarshal(msg, &cfg) == nil { | |||||
| if cfg.Binary != nil { | |||||
| c.binary = *cfg.Binary | |||||
| } | |||||
| if cfg.Bins != nil && *cfg.Bins > 0 { | |||||
| c.maxBins = *cfg.Bins | |||||
| } | |||||
| if cfg.FPS != nil && *cfg.FPS > 0 { | |||||
| c.targetFps = *cfg.FPS | |||||
| c.frameSkip = 15 / *cfg.FPS | |||||
| if c.frameSkip < 1 { | |||||
| c.frameSkip = 1 | |||||
| } | |||||
| } | |||||
| } | |||||
| } | } | ||||
| }) | }) | ||||
| @@ -90,9 +140,12 @@ func registerWSHandlers(mux *http.ServeMux, h *hub, recMgr *recorder.Manager) { | |||||
| return | return | ||||
| } | } | ||||
| subID, ch := streamer.SubscribeAudio(freq, bw, mode) | |||||
| if ch == nil { | |||||
| http.Error(w, "no active stream for this frequency", http.StatusNotFound) | |||||
| // LL-3: Subscribe BEFORE upgrading WebSocket. | |||||
| // SubscribeAudio now returns AudioInfo and never immediately closes | |||||
| // the channel — it queues pending listeners instead. | |||||
| subID, ch, audioInfo, err := streamer.SubscribeAudio(freq, bw, mode) | |||||
| if err != nil { | |||||
| http.Error(w, err.Error(), http.StatusServiceUnavailable) | |||||
| return | return | ||||
| } | } | ||||
| @@ -109,12 +162,13 @@ func registerWSHandlers(mux *http.ServeMux, h *hub, recMgr *recorder.Manager) { | |||||
| log.Printf("ws/audio: client connected freq=%.1fMHz mode=%s", freq/1e6, mode) | log.Printf("ws/audio: client connected freq=%.1fMHz mode=%s", freq/1e6, mode) | ||||
| // Send audio stream info as first text message | |||||
| // LL-2: Send actual audio info (channels, sample rate from session) | |||||
| info := map[string]any{ | info := map[string]any{ | ||||
| "type": "audio_info", | "type": "audio_info", | ||||
| "sample_rate": 48000, | |||||
| "channels": 1, | |||||
| "format": "s16le", | |||||
| "sample_rate": audioInfo.SampleRate, | |||||
| "channels": audioInfo.Channels, | |||||
| "format": audioInfo.Format, | |||||
| "demod": audioInfo.DemodName, | |||||
| "freq": freq, | "freq": freq, | ||||
| "mode": mode, | "mode": mode, | ||||
| } | } | ||||
| @@ -139,13 +193,25 @@ func registerWSHandlers(mux *http.ServeMux, h *hub, recMgr *recorder.Manager) { | |||||
| for { | for { | ||||
| select { | select { | ||||
| case pcm, ok := <-ch: | |||||
| case data, ok := <-ch: | |||||
| if !ok { | if !ok { | ||||
| log.Printf("ws/audio: stream ended freq=%.1fMHz", freq/1e6) | log.Printf("ws/audio: stream ended freq=%.1fMHz", freq/1e6) | ||||
| return | return | ||||
| } | } | ||||
| if len(data) == 0 { | |||||
| continue | |||||
| } | |||||
| _ = conn.SetWriteDeadline(time.Now().Add(500 * time.Millisecond)) | _ = conn.SetWriteDeadline(time.Now().Add(500 * time.Millisecond)) | ||||
| if err := conn.WriteMessage(websocket.BinaryMessage, pcm); err != nil { | |||||
| // Tag protocol: first byte is message type | |||||
| // 0x00 = AudioInfo JSON (send as TextMessage, strip tag) | |||||
| // 0x01 = PCM audio (send as BinaryMessage, strip tag) | |||||
| tag := data[0] | |||||
| payload := data[1:] | |||||
| msgType := websocket.BinaryMessage | |||||
| if tag == 0x00 { | |||||
| msgType = websocket.TextMessage | |||||
| } | |||||
| if err := conn.WriteMessage(msgType, payload); err != nil { | |||||
| log.Printf("ws/audio: write error: %v", err) | log.Printf("ws/audio: write error: %v", err) | ||||
| return | return | ||||
| } | } | ||||
| @@ -54,6 +54,11 @@ type RecorderConfig struct { | |||||
| OutputDir string `yaml:"output_dir" json:"output_dir"` | OutputDir string `yaml:"output_dir" json:"output_dir"` | ||||
| ClassFilter []string `yaml:"class_filter" json:"class_filter"` | ClassFilter []string `yaml:"class_filter" json:"class_filter"` | ||||
| RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` | RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` | ||||
| // Audio quality settings (AQ-2, AQ-3, AQ-5) | |||||
| DeemphasisUs float64 `yaml:"deemphasis_us" json:"deemphasis_us"` // De-emphasis time constant in µs. 50=Europe, 75=US/Japan, 0=disabled. Default: 50 | |||||
| ExtractionTaps int `yaml:"extraction_fir_taps" json:"extraction_fir_taps"` // FIR tap count for extraction filter. Default: 101, max 301 | |||||
| ExtractionBwMult float64 `yaml:"extraction_bw_mult" json:"extraction_bw_mult"` // BW multiplier for extraction. Default: 1.2 (20% wider than detected) | |||||
| } | } | ||||
| type DecoderConfig struct { | type DecoderConfig struct { | ||||
| @@ -136,7 +141,10 @@ func Default() Config { | |||||
| AutoDecode: false, | AutoDecode: false, | ||||
| MaxDiskMB: 0, | MaxDiskMB: 0, | ||||
| OutputDir: "data/recordings", | OutputDir: "data/recordings", | ||||
| RingSeconds: 8, | |||||
| RingSeconds: 8, | |||||
| DeemphasisUs: 50, | |||||
| ExtractionTaps: 101, | |||||
| ExtractionBwMult: 1.2, | |||||
| }, | }, | ||||
| Decoder: DecoderConfig{}, | Decoder: DecoderConfig{}, | ||||
| WebAddr: ":8080", | WebAddr: ":8080", | ||||
| @@ -271,6 +279,21 @@ func applyDefaults(cfg Config) Config { | |||||
| if cfg.Recorder.RingSeconds <= 0 { | if cfg.Recorder.RingSeconds <= 0 { | ||||
| cfg.Recorder.RingSeconds = 8 | cfg.Recorder.RingSeconds = 8 | ||||
| } | } | ||||
| if cfg.Recorder.DeemphasisUs == 0 { | |||||
| cfg.Recorder.DeemphasisUs = 50 | |||||
| } | |||||
| if cfg.Recorder.ExtractionTaps <= 0 { | |||||
| cfg.Recorder.ExtractionTaps = 101 | |||||
| } | |||||
| if cfg.Recorder.ExtractionTaps > 301 { | |||||
| cfg.Recorder.ExtractionTaps = 301 | |||||
| } | |||||
| if cfg.Recorder.ExtractionTaps%2 == 0 { | |||||
| cfg.Recorder.ExtractionTaps++ // must be odd | |||||
| } | |||||
| if cfg.Recorder.ExtractionBwMult <= 0 { | |||||
| cfg.Recorder.ExtractionBwMult = 1.2 | |||||
| } | |||||
| return cfg | return cfg | ||||
| } | } | ||||
| @@ -138,20 +138,6 @@ func RDSBasebandDecimated(iq []complex64, sampleRate int) RDSBasebandResult { | |||||
| return RDSBasebandResult{Samples: out, SampleRate: res.SampleRate} | return RDSBasebandResult{Samples: out, SampleRate: res.SampleRate} | ||||
| } | } | ||||
| func deemphasis(x []float32, sampleRate int, tau float64) []float32 { | |||||
| if len(x) == 0 || sampleRate <= 0 { | |||||
| return x | |||||
| } | |||||
| alpha := math.Exp(-1.0 / (float64(sampleRate) * tau)) | |||||
| out := make([]float32, len(x)) | |||||
| var y float64 | |||||
| for i, v := range x { | |||||
| y = alpha*y + (1-alpha)*float64(v) | |||||
| out[i] = float32(y) | |||||
| } | |||||
| return out | |||||
| } | |||||
| func init() { | func init() { | ||||
| Register(NFM{}) | Register(NFM{}) | ||||
| Register(WFM{}) | Register(WFM{}) | ||||
| @@ -0,0 +1,112 @@ | |||||
| package dsp | |||||
| // StatefulFIRReal is a real-valued FIR filter that preserves its delay line | |||||
| // between calls to Process(). This eliminates click/pop artifacts at frame | |||||
| // boundaries in streaming audio pipelines. | |||||
| type StatefulFIRReal struct { | |||||
| taps []float64 | |||||
| delay []float64 | |||||
| pos int // write position in circular delay buffer | |||||
| } | |||||
| // NewStatefulFIRReal creates a stateful FIR filter with the given taps. | |||||
| func NewStatefulFIRReal(taps []float64) *StatefulFIRReal { | |||||
| t := make([]float64, len(taps)) | |||||
| copy(t, taps) | |||||
| return &StatefulFIRReal{ | |||||
| taps: t, | |||||
| delay: make([]float64, len(taps)), | |||||
| } | |||||
| } | |||||
| // Process filters the input through the FIR with persistent state. | |||||
| // Allocates a new output slice. For zero-alloc hot paths, use ProcessInto. | |||||
| func (f *StatefulFIRReal) Process(x []float32) []float32 { | |||||
| out := make([]float32, len(x)) | |||||
| f.ProcessInto(x, out) | |||||
| return out | |||||
| } | |||||
| // ProcessInto filters into a pre-allocated output buffer. | |||||
| func (f *StatefulFIRReal) ProcessInto(x []float32, out []float32) []float32 { | |||||
| if len(x) == 0 || len(f.taps) == 0 { | |||||
| return out[:0] | |||||
| } | |||||
| n := len(f.taps) | |||||
| for i := 0; i < len(x); i++ { | |||||
| copy(f.delay[1:], f.delay[:n-1]) | |||||
| f.delay[0] = float64(x[i]) | |||||
| var acc float64 | |||||
| for k := 0; k < n; k++ { | |||||
| acc += f.delay[k] * f.taps[k] | |||||
| } | |||||
| out[i] = float32(acc) | |||||
| } | |||||
| return out[:len(x)] | |||||
| } | |||||
| // Reset clears the delay line. | |||||
| func (f *StatefulFIRReal) Reset() { | |||||
| for i := range f.delay { | |||||
| f.delay[i] = 0 | |||||
| } | |||||
| } | |||||
| // StatefulFIRComplex is a complex-valued FIR filter with persistent state. | |||||
| type StatefulFIRComplex struct { | |||||
| taps []float64 | |||||
| delayR []float64 | |||||
| delayI []float64 | |||||
| } | |||||
| // NewStatefulFIRComplex creates a stateful complex FIR filter. | |||||
| func NewStatefulFIRComplex(taps []float64) *StatefulFIRComplex { | |||||
| t := make([]float64, len(taps)) | |||||
| copy(t, taps) | |||||
| return &StatefulFIRComplex{ | |||||
| taps: t, | |||||
| delayR: make([]float64, len(taps)), | |||||
| delayI: make([]float64, len(taps)), | |||||
| } | |||||
| } | |||||
| // Process filters complex IQ through the FIR with persistent state. | |||||
| // Allocates a new output slice. For zero-alloc hot paths, use ProcessInto. | |||||
| func (f *StatefulFIRComplex) Process(iq []complex64) []complex64 { | |||||
| out := make([]complex64, len(iq)) | |||||
| f.ProcessInto(iq, out) | |||||
| return out | |||||
| } | |||||
| // ProcessInto filters complex IQ into a pre-allocated output buffer. | |||||
| // out must be at least len(iq) long. Returns the used portion of out. | |||||
| func (f *StatefulFIRComplex) ProcessInto(iq []complex64, out []complex64) []complex64 { | |||||
| if len(iq) == 0 || len(f.taps) == 0 { | |||||
| return out[:0] | |||||
| } | |||||
| n := len(f.taps) | |||||
| for i := 0; i < len(iq); i++ { | |||||
| copy(f.delayR[1:], f.delayR[:n-1]) | |||||
| copy(f.delayI[1:], f.delayI[:n-1]) | |||||
| f.delayR[0] = float64(real(iq[i])) | |||||
| f.delayI[0] = float64(imag(iq[i])) | |||||
| var accR, accI float64 | |||||
| for k := 0; k < n; k++ { | |||||
| w := f.taps[k] | |||||
| accR += f.delayR[k] * w | |||||
| accI += f.delayI[k] * w | |||||
| } | |||||
| out[i] = complex(float32(accR), float32(accI)) | |||||
| } | |||||
| return out[:len(iq)] | |||||
| } | |||||
| // Reset clears the delay line. | |||||
| func (f *StatefulFIRComplex) Reset() { | |||||
| for i := range f.delayR { | |||||
| f.delayR[i] = 0 | |||||
| f.delayI[i] = 0 | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,294 @@ | |||||
| package dsp | |||||
| import "math" | |||||
| // --------------------------------------------------------------------------- | |||||
| // Rational Polyphase Resampler | |||||
| // --------------------------------------------------------------------------- | |||||
| // | |||||
| // Converts sample rate by a rational factor L/M (upsample by L, then | |||||
| // downsample by M) using a polyphase FIR implementation. The polyphase | |||||
| // decomposition avoids computing intermediate upsampled samples that | |||||
| // would be discarded, making it efficient even for large L/M. | |||||
| // | |||||
| // The resampler is stateful: it preserves its internal delay line and | |||||
| // phase index between calls to Process(), enabling click-free streaming | |||||
| // across frame boundaries. | |||||
| // | |||||
| // Usage: | |||||
| // | |||||
| // r := dsp.NewResampler(51200, 48000, 64) // 64 taps per phase | |||||
| // for each frame { | |||||
| // out := r.Process(audio) // or r.ProcessStereo(interleaved) | |||||
| // } | |||||
| // | |||||
| // --------------------------------------------------------------------------- | |||||
| // Resampler performs rational polyphase sample rate conversion. | |||||
| type Resampler struct { | |||||
| l int // upsample factor | |||||
| m int // downsample factor | |||||
| tapsPerPh int // taps per polyphase arm | |||||
| polyBank [][]float64 // polyBank[phase][tap] | |||||
| delay []float64 // delay line, length = tapsPerPh | |||||
| // outTime is the position (in upsampled-rate units) of the next output | |||||
| // sample, relative to the next input sample to be consumed. It is | |||||
| // always in [0, L). Between calls it persists so that the fractional | |||||
| // position is perfectly continuous. | |||||
| outTime int | |||||
| } | |||||
| // NewResampler creates a polyphase resampler converting from inRate to | |||||
| // outRate. tapsPerPhase controls the filter quality (16 = basic, 32 = | |||||
| // good, 64 = high quality). The total prototype filter length is | |||||
| // L * tapsPerPhase. | |||||
| func NewResampler(inRate, outRate, tapsPerPhase int) *Resampler { | |||||
| if inRate <= 0 || outRate <= 0 { | |||||
| inRate, outRate = 1, 1 | |||||
| } | |||||
| if tapsPerPhase < 4 { | |||||
| tapsPerPhase = 4 | |||||
| } | |||||
| g := gcd(inRate, outRate) | |||||
| l := outRate / g // upsample factor | |||||
| m := inRate / g // downsample factor | |||||
| // Prototype lowpass: cutoff at min(1/L, 1/M) * Nyquist of the | |||||
| // upsampled rate, with some margin for the transition band. | |||||
| protoLen := l * tapsPerPhase | |||||
| if protoLen%2 == 0 { | |||||
| protoLen++ // ensure odd length for symmetric filter | |||||
| } | |||||
| // Normalized cutoff: passband edge relative to upsampled rate | |||||
| fc := 0.45 / float64(max(l, m)) // 0.45 instead of 0.5 for transition margin | |||||
| proto := windowedSinc(protoLen, fc, float64(l)) | |||||
| // Decompose prototype into L polyphase arms | |||||
| actualTapsPerPh := (protoLen + l - 1) / l | |||||
| bank := make([][]float64, l) | |||||
| for p := 0; p < l; p++ { | |||||
| arm := make([]float64, actualTapsPerPh) | |||||
| for t := 0; t < actualTapsPerPh; t++ { | |||||
| idx := p + t*l | |||||
| if idx < protoLen { | |||||
| arm[t] = proto[idx] | |||||
| } | |||||
| } | |||||
| bank[p] = arm | |||||
| } | |||||
| return &Resampler{ | |||||
| l: l, | |||||
| m: m, | |||||
| tapsPerPh: actualTapsPerPh, | |||||
| polyBank: bank, | |||||
| delay: make([]float64, actualTapsPerPh), | |||||
| outTime: 0, | |||||
| } | |||||
| } | |||||
| // Process resamples a mono float32 buffer and returns the resampled output. | |||||
| // State is preserved between calls for seamless streaming. | |||||
| // | |||||
| // The key insight: we conceptually interleave L-1 zeros between each input | |||||
| // sample (upsampled rate = L * Fs_in), then pick every M-th sample from | |||||
| // the filtered result (output rate = L/M * Fs_in). | |||||
| // | |||||
| // outTime tracks the sub-sample position of the next output within the | |||||
| // current input sample's L phases. When outTime wraps past L, we consume | |||||
| // the next input sample. This single counter gives exact, chunk-independent | |||||
| // output. | |||||
| func (r *Resampler) Process(in []float32) []float32 { | |||||
| if len(in) == 0 { | |||||
| return nil | |||||
| } | |||||
| if r.l == r.m { | |||||
| out := make([]float32, len(in)) | |||||
| copy(out, in) | |||||
| return out | |||||
| } | |||||
| L := r.l | |||||
| M := r.m | |||||
| taps := r.tapsPerPh | |||||
| estOut := int(float64(len(in))*float64(L)/float64(M)) + 4 | |||||
| out := make([]float32, 0, estOut) | |||||
| inPos := 0 | |||||
| t := r.outTime | |||||
| for inPos < len(in) { | |||||
| // Consume input samples until outTime < L | |||||
| for t >= L { | |||||
| t -= L | |||||
| if inPos >= len(in) { | |||||
| r.outTime = t | |||||
| return out | |||||
| } | |||||
| copy(r.delay[1:], r.delay[:taps-1]) | |||||
| r.delay[0] = float64(in[inPos]) | |||||
| inPos++ | |||||
| } | |||||
| // Produce output at phase = t | |||||
| arm := r.polyBank[t] | |||||
| var acc float64 | |||||
| for k := 0; k < taps; k++ { | |||||
| acc += r.delay[k] * arm[k] | |||||
| } | |||||
| out = append(out, float32(acc)) | |||||
| // Advance to next output position | |||||
| t += M | |||||
| } | |||||
| r.outTime = t | |||||
| return out | |||||
| } | |||||
| // Reset clears the delay line and phase state. | |||||
| func (r *Resampler) Reset() { | |||||
| for i := range r.delay { | |||||
| r.delay[i] = 0 | |||||
| } | |||||
| r.outTime = 0 | |||||
| } | |||||
| // OutputRate returns the effective output sample rate given an input rate. | |||||
| func (r *Resampler) OutputRate(inRate int) int { | |||||
| return inRate * r.l / r.m | |||||
| } | |||||
| // Ratio returns L and M. | |||||
| func (r *Resampler) Ratio() (int, int) { | |||||
| return r.l, r.m | |||||
| } | |||||
| // --------------------------------------------------------------------------- | |||||
| // StereoResampler — two synchronised mono resamplers | |||||
| // --------------------------------------------------------------------------- | |||||
| // StereoResampler wraps two Resampler instances sharing the same L/M ratio | |||||
| // for click-free stereo resampling with independent delay lines. | |||||
| type StereoResampler struct { | |||||
| left *Resampler | |||||
| right *Resampler | |||||
| } | |||||
| // NewStereoResampler creates a pair of synchronised resamplers. | |||||
| func NewStereoResampler(inRate, outRate, tapsPerPhase int) *StereoResampler { | |||||
| return &StereoResampler{ | |||||
| left: NewResampler(inRate, outRate, tapsPerPhase), | |||||
| right: NewResampler(inRate, outRate, tapsPerPhase), | |||||
| } | |||||
| } | |||||
| // Process takes interleaved stereo [L0,R0,L1,R1,...] and returns | |||||
| // resampled interleaved stereo. | |||||
| func (sr *StereoResampler) Process(in []float32) []float32 { | |||||
| nFrames := len(in) / 2 | |||||
| if nFrames == 0 { | |||||
| return nil | |||||
| } | |||||
| left := make([]float32, nFrames) | |||||
| right := make([]float32, nFrames) | |||||
| for i := 0; i < nFrames; i++ { | |||||
| left[i] = in[i*2] | |||||
| if i*2+1 < len(in) { | |||||
| right[i] = in[i*2+1] | |||||
| } | |||||
| } | |||||
| outL := sr.left.Process(left) | |||||
| outR := sr.right.Process(right) | |||||
| // Interleave — use shorter length if they differ by 1 sample | |||||
| n := len(outL) | |||||
| if len(outR) < n { | |||||
| n = len(outR) | |||||
| } | |||||
| out := make([]float32, n*2) | |||||
| for i := 0; i < n; i++ { | |||||
| out[i*2] = outL[i] | |||||
| out[i*2+1] = outR[i] | |||||
| } | |||||
| return out | |||||
| } | |||||
| // Reset clears both delay lines. | |||||
| func (sr *StereoResampler) Reset() { | |||||
| sr.left.Reset() | |||||
| sr.right.Reset() | |||||
| } | |||||
| // OutputRate returns the resampled output rate. | |||||
| func (sr *StereoResampler) OutputRate(inRate int) int { | |||||
| return sr.left.OutputRate(inRate) | |||||
| } | |||||
| // --------------------------------------------------------------------------- | |||||
| // Helpers | |||||
| // --------------------------------------------------------------------------- | |||||
| func gcd(a, b int) int { | |||||
| for b != 0 { | |||||
| a, b = b, a%b | |||||
| } | |||||
| if a < 0 { | |||||
| return -a | |||||
| } | |||||
| return a | |||||
| } | |||||
| func max(a, b int) int { | |||||
| if a > b { | |||||
| return a | |||||
| } | |||||
| return b | |||||
| } | |||||
| // windowedSinc generates a windowed-sinc prototype lowpass filter. | |||||
| // fc is the normalised cutoff (0..0.5 of the upsampled rate). | |||||
| // gain is the scaling factor (= L for polyphase interpolation). | |||||
| func windowedSinc(length int, fc float64, gain float64) []float64 { | |||||
| out := make([]float64, length) | |||||
| mid := float64(length-1) / 2.0 | |||||
| for n := 0; n < length; n++ { | |||||
| x := float64(n) - mid | |||||
| // Sinc | |||||
| var s float64 | |||||
| if math.Abs(x) < 1e-12 { | |||||
| s = 2 * math.Pi * fc | |||||
| } else { | |||||
| s = math.Sin(2*math.Pi*fc*x) / x | |||||
| } | |||||
| // Kaiser window (beta=6 gives ~-60dB sidelobe, good for audio) | |||||
| w := kaiserWindow(n, length, 6.0) | |||||
| out[n] = s * w * gain | |||||
| } | |||||
| return out | |||||
| } | |||||
| // kaiserWindow computes the Kaiser window value for sample n of N total. | |||||
| func kaiserWindow(n, N int, beta float64) float64 { | |||||
| mid := float64(N-1) / 2.0 | |||||
| x := (float64(n) - mid) / mid | |||||
| return bessel0(beta*math.Sqrt(1-x*x)) / bessel0(beta) | |||||
| } | |||||
| // bessel0 is the zeroth-order modified Bessel function of the first kind. | |||||
| func bessel0(x float64) float64 { | |||||
| // Series expansion — converges rapidly for typical beta values | |||||
| sum := 1.0 | |||||
| term := 1.0 | |||||
| for k := 1; k < 30; k++ { | |||||
| term *= (x / (2 * float64(k))) * (x / (2 * float64(k))) | |||||
| sum += term | |||||
| if term < 1e-12*sum { | |||||
| break | |||||
| } | |||||
| } | |||||
| return sum | |||||
| } | |||||
| @@ -0,0 +1,248 @@ | |||||
| package dsp | |||||
| import ( | |||||
| "math" | |||||
| "testing" | |||||
| ) | |||||
| func TestGCD(t *testing.T) { | |||||
| tests := []struct { | |||||
| a, b, want int | |||||
| }{ | |||||
| {48000, 51200, 3200}, | |||||
| {48000, 44100, 300}, | |||||
| {48000, 48000, 48000}, | |||||
| {48000, 96000, 48000}, | |||||
| {48000, 200000, 8000}, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| got := gcd(tt.a, tt.b) | |||||
| if got != tt.want { | |||||
| t.Errorf("gcd(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want) | |||||
| } | |||||
| } | |||||
| } | |||||
| func TestResamplerRatio(t *testing.T) { | |||||
| tests := []struct { | |||||
| inRate, outRate int | |||||
| wantL, wantM int | |||||
| }{ | |||||
| {51200, 48000, 15, 16}, // SDR typical | |||||
| {44100, 48000, 160, 147}, | |||||
| {48000, 48000, 1, 1}, // identity | |||||
| {96000, 48000, 1, 2}, // simple downsample | |||||
| } | |||||
| for _, tt := range tests { | |||||
| r := NewResampler(tt.inRate, tt.outRate, 32) | |||||
| l, m := r.Ratio() | |||||
| if l != tt.wantL || m != tt.wantM { | |||||
| t.Errorf("NewResampler(%d, %d): ratio = %d/%d, want %d/%d", | |||||
| tt.inRate, tt.outRate, l, m, tt.wantL, tt.wantM) | |||||
| } | |||||
| } | |||||
| } | |||||
| func TestResamplerIdentity(t *testing.T) { | |||||
| r := NewResampler(48000, 48000, 32) | |||||
| in := make([]float32, 1000) | |||||
| for i := range in { | |||||
| in[i] = float32(math.Sin(2 * math.Pi * 440 * float64(i) / 48000)) | |||||
| } | |||||
| out := r.Process(in) | |||||
| if len(out) != len(in) { | |||||
| t.Fatalf("identity resampler: len(out) = %d, want %d", len(out), len(in)) | |||||
| } | |||||
| for i := range in { | |||||
| if math.Abs(float64(out[i]-in[i])) > 1e-4 { | |||||
| t.Errorf("sample %d: got %f, want %f", i, out[i], in[i]) | |||||
| break | |||||
| } | |||||
| } | |||||
| } | |||||
| func TestResamplerOutputLength(t *testing.T) { | |||||
| tests := []struct { | |||||
| inRate, outRate, inLen int | |||||
| }{ | |||||
| {51200, 48000, 5120}, | |||||
| {51200, 48000, 10240}, | |||||
| {44100, 48000, 4410}, | |||||
| {96000, 48000, 9600}, | |||||
| {200000, 48000, 20000}, | |||||
| } | |||||
| for _, tt := range tests { | |||||
| r := NewResampler(tt.inRate, tt.outRate, 32) | |||||
| in := make([]float32, tt.inLen) | |||||
| for i := range in { | |||||
| in[i] = float32(math.Sin(2 * math.Pi * 1000 * float64(i) / float64(tt.inRate))) | |||||
| } | |||||
| out := r.Process(in) | |||||
| expected := float64(tt.inLen) * float64(tt.outRate) / float64(tt.inRate) | |||||
| // Allow ±2 samples tolerance for filter delay + edge effects | |||||
| if math.Abs(float64(len(out))-expected) > 3 { | |||||
| t.Errorf("Resampler(%d→%d) %d samples: got %d output, expected ~%.0f", | |||||
| tt.inRate, tt.outRate, tt.inLen, len(out), expected) | |||||
| } | |||||
| } | |||||
| } | |||||
| func TestResamplerStreamContinuity(t *testing.T) { | |||||
| // Verify that processing in chunks gives essentially the same result | |||||
| // as one block (state preservation works for seamless streaming). | |||||
| // | |||||
| // With non-M-aligned chunks the output count may differ by ±1 per | |||||
| // chunk due to sub-phase boundary effects. This is harmless for | |||||
| // audio streaming. We verify: | |||||
| // 1. M-aligned chunks give bit-exact results | |||||
| // 2. Arbitrary chunks give correct audio (small value error near boundaries) | |||||
| inRate := 51200 | |||||
| outRate := 48000 | |||||
| freq := 1000.0 | |||||
| totalSamples := inRate | |||||
| signal := make([]float32, totalSamples) | |||||
| for i := range signal { | |||||
| signal[i] = float32(math.Sin(2 * math.Pi * freq * float64(i) / float64(inRate))) | |||||
| } | |||||
| // --- Test 1: M-aligned chunks must be bit-exact --- | |||||
| g := gcd(inRate, outRate) | |||||
| M := inRate / g // 16 | |||||
| chunkAligned := M * 200 // 3200, divides evenly | |||||
| r1 := NewResampler(inRate, outRate, 32) | |||||
| oneBlock := r1.Process(signal) | |||||
| r2 := NewResampler(inRate, outRate, 32) | |||||
| var aligned []float32 | |||||
| for i := 0; i < len(signal); i += chunkAligned { | |||||
| end := i + chunkAligned | |||||
| if end > len(signal) { | |||||
| end = len(signal) | |||||
| } | |||||
| aligned = append(aligned, r2.Process(signal[i:end])...) | |||||
| } | |||||
| if len(oneBlock) != len(aligned) { | |||||
| t.Fatalf("M-aligned: length mismatch one=%d aligned=%d", len(oneBlock), len(aligned)) | |||||
| } | |||||
| for i := range oneBlock { | |||||
| if oneBlock[i] != aligned[i] { | |||||
| t.Fatalf("M-aligned: sample %d differs: %f vs %f", i, oneBlock[i], aligned[i]) | |||||
| } | |||||
| } | |||||
| // --- Test 2: Arbitrary chunks — audio must be within ±1 sample count --- | |||||
| r3 := NewResampler(inRate, outRate, 32) | |||||
| chunkArbitrary := inRate / 15 // ~3413, not M-aligned | |||||
| var arb []float32 | |||||
| for i := 0; i < len(signal); i += chunkArbitrary { | |||||
| end := i + chunkArbitrary | |||||
| if end > len(signal) { | |||||
| end = len(signal) | |||||
| } | |||||
| arb = append(arb, r3.Process(signal[i:end])...) | |||||
| } | |||||
| // Length should be close (within ~number of chunks) | |||||
| nChunks := (len(signal) + chunkArbitrary - 1) / chunkArbitrary | |||||
| if abs(len(arb)-len(oneBlock)) > nChunks { | |||||
| t.Errorf("arbitrary chunks: length %d vs %d (diff %d, max allowed %d)", | |||||
| len(arb), len(oneBlock), len(arb)-len(oneBlock), nChunks) | |||||
| } | |||||
| // Values should match where they overlap (skip boundaries) | |||||
| minLen := len(oneBlock) | |||||
| if len(arb) < minLen { | |||||
| minLen = len(arb) | |||||
| } | |||||
| maxDiff := 0.0 | |||||
| for i := 64; i < minLen-64; i++ { | |||||
| diff := math.Abs(float64(oneBlock[i] - arb[i])) | |||||
| if diff > maxDiff { | |||||
| maxDiff = diff | |||||
| } | |||||
| } | |||||
| // Interior samples that haven't drifted should be very close | |||||
| t.Logf("arbitrary chunks: maxDiff=%e len_one=%d len_arb=%d", maxDiff, len(oneBlock), len(arb)) | |||||
| } | |||||
| func abs(x int) int { | |||||
| if x < 0 { | |||||
| return -x | |||||
| } | |||||
| return x | |||||
| } | |||||
| func TestResamplerTonePreservation(t *testing.T) { | |||||
| // Resample a 1kHz tone and verify the frequency is preserved | |||||
| inRate := 51200 | |||||
| outRate := 48000 | |||||
| freq := 1000.0 | |||||
| in := make([]float32, inRate) // 1 second | |||||
| for i := range in { | |||||
| in[i] = float32(math.Sin(2 * math.Pi * freq * float64(i) / float64(inRate))) | |||||
| } | |||||
| r := NewResampler(inRate, outRate, 32) | |||||
| out := r.Process(in) | |||||
| // Measure frequency by zero crossings in the output (skip first 100 samples for filter settle) | |||||
| crossings := 0 | |||||
| for i := 101; i < len(out); i++ { | |||||
| if (out[i-1] <= 0 && out[i] > 0) || (out[i-1] >= 0 && out[i] < 0) { | |||||
| crossings++ | |||||
| } | |||||
| } | |||||
| // Each full cycle has 2 zero crossings | |||||
| measuredFreq := float64(crossings) / 2.0 * float64(outRate) / float64(len(out)-101) | |||||
| if math.Abs(measuredFreq-freq) > 10 { // within 10 Hz | |||||
| t.Errorf("tone preservation: measured %.1f Hz, want %.1f Hz", measuredFreq, freq) | |||||
| } | |||||
| } | |||||
| func TestStereoResampler(t *testing.T) { | |||||
| inRate := 51200 | |||||
| outRate := 48000 | |||||
| // Generate stereo: 440Hz left, 880Hz right | |||||
| nFrames := inRate / 2 // 0.5 seconds | |||||
| in := make([]float32, nFrames*2) | |||||
| for i := 0; i < nFrames; i++ { | |||||
| in[i*2] = float32(math.Sin(2 * math.Pi * 440 * float64(i) / float64(inRate))) | |||||
| in[i*2+1] = float32(math.Sin(2 * math.Pi * 880 * float64(i) / float64(inRate))) | |||||
| } | |||||
| sr := NewStereoResampler(inRate, outRate, 32) | |||||
| out := sr.Process(in) | |||||
| expectedFrames := float64(nFrames) * float64(outRate) / float64(inRate) | |||||
| if math.Abs(float64(len(out)/2)-expectedFrames) > 3 { | |||||
| t.Errorf("stereo output: %d frames, expected ~%.0f", len(out)/2, expectedFrames) | |||||
| } | |||||
| // Verify it's properly interleaved (left and right should have different content) | |||||
| if len(out) >= 200 { | |||||
| leftSum := 0.0 | |||||
| rightSum := 0.0 | |||||
| for i := 50; i < 100; i++ { | |||||
| leftSum += math.Abs(float64(out[i*2])) | |||||
| rightSum += math.Abs(float64(out[i*2+1])) | |||||
| } | |||||
| if leftSum < 0.1 || rightSum < 0.1 { | |||||
| t.Errorf("stereo channels appear silent: leftEnergy=%.3f rightEnergy=%.3f", leftSum, rightSum) | |||||
| } | |||||
| } | |||||
| } | |||||
| func BenchmarkResampler51200to48000(b *testing.B) { | |||||
| in := make([]float32, 51200/15) // one DSP frame at 51200 Hz / 15fps | |||||
| for i := range in { | |||||
| in[i] = float32(math.Sin(2 * math.Pi * 1000 * float64(i) / 51200)) | |||||
| } | |||||
| r := NewResampler(51200, 48000, 32) | |||||
| b.ResetTimer() | |||||
| for i := 0; i < b.N; i++ { | |||||
| r.Process(in) | |||||
| } | |||||
| } | |||||
| @@ -28,6 +28,11 @@ type Policy struct { | |||||
| OutputDir string `yaml:"output_dir" json:"output_dir"` | OutputDir string `yaml:"output_dir" json:"output_dir"` | ||||
| ClassFilter []string `yaml:"class_filter" json:"class_filter"` | ClassFilter []string `yaml:"class_filter" json:"class_filter"` | ||||
| RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` | RingSeconds int `yaml:"ring_seconds" json:"ring_seconds"` | ||||
| // Audio quality (AQ-2, AQ-3, AQ-5) | |||||
| DeemphasisUs float64 `yaml:"deemphasis_us" json:"deemphasis_us"` | |||||
| ExtractionTaps int `yaml:"extraction_fir_taps" json:"extraction_fir_taps"` | |||||
| ExtractionBwMult float64 `yaml:"extraction_bw_mult" json:"extraction_bw_mult"` | |||||
| } | } | ||||
| type Manager struct { | type Manager struct { | ||||
| @@ -358,3 +363,11 @@ func (m *Manager) ActiveStreams() int { | |||||
| } | } | ||||
| return m.streamer.ActiveSessions() | return m.streamer.ActiveSessions() | ||||
| } | } | ||||
| // HasListeners returns true if any live-listen subscribers are active or pending. | |||||
| func (m *Manager) HasListeners() bool { | |||||
| if m == nil || m.streamer == nil { | |||||
| return false | |||||
| } | |||||
| return m.streamer.HasListeners() | |||||
| } | |||||
| @@ -117,7 +117,149 @@ const listenModeSelect = qs('listenMode'); | |||||
| let latest = null; | let latest = null; | ||||
| let currentConfig = null; | let currentConfig = null; | ||||
| let liveAudio = null; | let liveAudio = null; | ||||
| let liveListenWS = null; // WebSocket-based live listen | |||||
| let stats = { buffer_samples: 0, dropped: 0, resets: 0, last_sample_ago_ms: -1 }; | let stats = { buffer_samples: 0, dropped: 0, resets: 0, last_sample_ago_ms: -1 }; | ||||
| // --------------------------------------------------------------------------- | |||||
| // LiveListenWS — WebSocket-based gapless audio streaming via /ws/audio | |||||
| // --------------------------------------------------------------------------- | |||||
| class LiveListenWS { | |||||
| constructor(freq, bw, mode) { | |||||
| this.freq = freq; | |||||
| this.bw = bw; | |||||
| this.mode = mode; | |||||
| this.ws = null; | |||||
| this.audioCtx = null; | |||||
| this.sampleRate = 48000; | |||||
| this.channels = 1; | |||||
| this.playing = false; | |||||
| this.queue = []; // buffered PCM chunks | |||||
| this.nextTime = 0; // next scheduled playback time | |||||
| this.started = false; | |||||
| this._onStop = null; | |||||
| } | |||||
| start() { | |||||
| const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; | |||||
| const url = `${proto}//${location.host}/ws/audio?freq=${this.freq}&bw=${this.bw}&mode=${this.mode || ''}`; | |||||
| this.ws = new WebSocket(url); | |||||
| this.ws.binaryType = 'arraybuffer'; | |||||
| this.playing = true; | |||||
| this.ws.onmessage = (ev) => { | |||||
| if (typeof ev.data === 'string') { | |||||
| // audio_info JSON message (initial or updated when session attached) | |||||
| try { | |||||
| const info = JSON.parse(ev.data); | |||||
| if (info.sample_rate || info.channels) { | |||||
| const newRate = info.sample_rate || 48000; | |||||
| const newCh = info.channels || 1; | |||||
| // If channels or rate changed, reinit AudioContext | |||||
| if (newRate !== this.sampleRate || newCh !== this.channels) { | |||||
| this.sampleRate = newRate; | |||||
| this.channels = newCh; | |||||
| if (this.audioCtx) { | |||||
| this.audioCtx.close().catch(() => {}); | |||||
| this.audioCtx = null; | |||||
| } | |||||
| this.started = false; | |||||
| this.nextTime = 0; | |||||
| } | |||||
| this._initAudio(); | |||||
| } | |||||
| } catch (e) { /* ignore */ } | |||||
| return; | |||||
| } | |||||
| // Binary PCM data (s16le) | |||||
| if (!this.audioCtx || !this.playing) return; | |||||
| this._playChunk(ev.data); | |||||
| }; | |||||
| this.ws.onclose = () => { | |||||
| this.playing = false; | |||||
| if (this._onStop) this._onStop(); | |||||
| }; | |||||
| this.ws.onerror = () => { | |||||
| this.playing = false; | |||||
| if (this._onStop) this._onStop(); | |||||
| }; | |||||
| // If no audio_info arrives within 500ms, init with defaults | |||||
| setTimeout(() => { | |||||
| if (!this.audioCtx && this.playing) this._initAudio(); | |||||
| }, 500); | |||||
| } | |||||
| stop() { | |||||
| this.playing = false; | |||||
| if (this.ws) { | |||||
| this.ws.close(); | |||||
| this.ws = null; | |||||
| } | |||||
| if (this.audioCtx) { | |||||
| this.audioCtx.close().catch(() => {}); | |||||
| this.audioCtx = null; | |||||
| } | |||||
| this.queue = []; | |||||
| this.nextTime = 0; | |||||
| this.started = false; | |||||
| } | |||||
| onStop(fn) { this._onStop = fn; } | |||||
| _initAudio() { | |||||
| if (this.audioCtx) return; | |||||
| this.audioCtx = new (window.AudioContext || window.webkitAudioContext)({ | |||||
| sampleRate: this.sampleRate | |||||
| }); | |||||
| this.nextTime = 0; | |||||
| this.started = false; | |||||
| } | |||||
| _playChunk(buf) { | |||||
| const ctx = this.audioCtx; | |||||
| if (!ctx) return; | |||||
| const samples = new Int16Array(buf); | |||||
| const nFrames = Math.floor(samples.length / this.channels); | |||||
| if (nFrames === 0) return; | |||||
| const audioBuffer = ctx.createBuffer(this.channels, nFrames, this.sampleRate); | |||||
| for (let ch = 0; ch < this.channels; ch++) { | |||||
| const channelData = audioBuffer.getChannelData(ch); | |||||
| for (let i = 0; i < nFrames; i++) { | |||||
| channelData[i] = samples[i * this.channels + ch] / 32768; | |||||
| } | |||||
| } | |||||
| const source = ctx.createBufferSource(); | |||||
| source.buffer = audioBuffer; | |||||
| source.connect(ctx.destination); | |||||
| // Schedule gapless playback with drift correction. | |||||
| // We target a small jitter buffer (~100ms ahead of real time). | |||||
| // If nextTime falls behind currentTime, we resync with a small | |||||
| // buffer to avoid audible gaps. | |||||
| const now = ctx.currentTime; | |||||
| const targetLatency = 0.1; // 100ms jitter buffer | |||||
| if (!this.started || this.nextTime < now) { | |||||
| // First chunk or buffer underrun — resync | |||||
| this.nextTime = now + targetLatency; | |||||
| this.started = true; | |||||
| } | |||||
| // If we've drifted too far ahead (>500ms of buffered audio), | |||||
| // drop this chunk to reduce latency. This prevents the buffer | |||||
| // from growing unbounded when the server sends faster than realtime. | |||||
| if (this.nextTime > now + 0.5) { | |||||
| return; // drop — too much buffered | |||||
| } | |||||
| source.start(this.nextTime); | |||||
| this.nextTime += audioBuffer.duration; | |||||
| } | |||||
| } | |||||
| let gpuInfo = { available: false, active: false, error: '' }; | let gpuInfo = { available: false, active: false, error: '' }; | ||||
| let zoom = 1; | let zoom = 1; | ||||
| @@ -1331,12 +1473,46 @@ function tuneToFrequency(centerHz) { | |||||
| function connect() { | function connect() { | ||||
| clearTimeout(wsReconnectTimer); | clearTimeout(wsReconnectTimer); | ||||
| const proto = location.protocol === 'https:' ? 'wss' : 'ws'; | const proto = location.protocol === 'https:' ? 'wss' : 'ws'; | ||||
| const ws = new WebSocket(`${proto}://${location.host}/ws`); | |||||
| // Remote optimization: detect non-localhost and opt into binary + decimation | |||||
| const hn = location.hostname; | |||||
| const isLocal = ['localhost', '127.0.0.1', '::1'].includes(hn) | |||||
| || hn.startsWith('192.168.') | |||||
| || hn.startsWith('10.') | |||||
| || /^172\.(1[6-9]|2\d|3[01])\./.test(hn) | |||||
| || hn.endsWith('.local') | |||||
| || hn.endsWith('.lan'); | |||||
| const params = new URLSearchParams(location.search); | |||||
| const wantBinary = params.get('binary') === '1' || !isLocal; | |||||
| const bins = parseInt(params.get('bins') || (isLocal ? '0' : '2048'), 10); | |||||
| const fps = parseInt(params.get('fps') || (isLocal ? '0' : '10'), 10); | |||||
| let wsUrl = `${proto}://${location.host}/ws`; | |||||
| if (wantBinary || bins > 0 || fps > 0) { | |||||
| const qp = []; | |||||
| if (wantBinary) qp.push('binary=1'); | |||||
| if (bins > 0) qp.push(`bins=${bins}`); | |||||
| if (fps > 0) qp.push(`fps=${fps}`); | |||||
| wsUrl += '?' + qp.join('&'); | |||||
| } | |||||
| const ws = new WebSocket(wsUrl); | |||||
| ws.binaryType = 'arraybuffer'; | |||||
| setWsBadge('Connecting', 'neutral'); | setWsBadge('Connecting', 'neutral'); | ||||
| ws.onopen = () => setWsBadge('Live', 'ok'); | ws.onopen = () => setWsBadge('Live', 'ok'); | ||||
| ws.onmessage = (ev) => { | ws.onmessage = (ev) => { | ||||
| latest = JSON.parse(ev.data); | |||||
| if (ev.data instanceof ArrayBuffer) { | |||||
| try { | |||||
| const decoded = decodeBinaryFrame(ev.data); | |||||
| if (decoded) latest = decoded; | |||||
| } catch (e) { | |||||
| console.warn('binary frame decode error:', e); | |||||
| return; | |||||
| } | |||||
| } else { | |||||
| latest = JSON.parse(ev.data); | |||||
| } | |||||
| markSpectrumDirty(); | markSpectrumDirty(); | ||||
| if (followLive) pan = 0; | if (followLive) pan = 0; | ||||
| updateHeroMetrics(); | updateHeroMetrics(); | ||||
| @@ -1349,6 +1525,59 @@ function connect() { | |||||
| ws.onerror = () => ws.close(); | ws.onerror = () => ws.close(); | ||||
| } | } | ||||
| // Decode binary spectrum frame v4 (hybrid: binary spectrum + JSON signals) | |||||
| function decodeBinaryFrame(buf) { | |||||
| const view = new DataView(buf); | |||||
| if (buf.byteLength < 32) return null; | |||||
| // Header: 32 bytes | |||||
| const magic0 = view.getUint8(0); | |||||
| const magic1 = view.getUint8(1); | |||||
| if (magic0 !== 0x53 || magic1 !== 0x50) return null; // not "SP" | |||||
| const version = view.getUint16(2, true); | |||||
| const ts = Number(view.getBigInt64(4, true)); | |||||
| const centerHz = view.getFloat64(12, true); | |||||
| const binCount = view.getUint32(20, true); | |||||
| const sampleRateHz = view.getUint32(24, true); | |||||
| const jsonOffset = view.getUint32(28, true); | |||||
| if (buf.byteLength < 32 + binCount * 2) return null; | |||||
| // Spectrum: binCount × int16 at offset 32 | |||||
| const spectrum = new Float64Array(binCount); | |||||
| let off = 32; | |||||
| for (let i = 0; i < binCount; i++) { | |||||
| spectrum[i] = view.getInt16(off, true) / 100; | |||||
| off += 2; | |||||
| } | |||||
| // JSON signals + debug after the spectrum data | |||||
| let signals = []; | |||||
| let debug = null; | |||||
| if (jsonOffset > 0 && jsonOffset < buf.byteLength) { | |||||
| try { | |||||
| const jsonBytes = new Uint8Array(buf, jsonOffset); | |||||
| const jsonStr = new TextDecoder().decode(jsonBytes); | |||||
| const parsed = JSON.parse(jsonStr); | |||||
| signals = parsed.signals || []; | |||||
| debug = parsed.debug || null; | |||||
| } catch (e) { | |||||
| // JSON parse failed — continue with empty signals | |||||
| } | |||||
| } | |||||
| return { | |||||
| ts: ts, | |||||
| center_hz: centerHz, | |||||
| sample_rate: sampleRateHz, | |||||
| fft_size: binCount, | |||||
| spectrum_db: spectrum, | |||||
| signals: signals, | |||||
| debug: debug | |||||
| }; | |||||
| } | |||||
| function renderLoop() { | function renderLoop() { | ||||
| renderFrames += 1; | renderFrames += 1; | ||||
| const now = performance.now(); | const now = performance.now(); | ||||
| @@ -1447,7 +1676,7 @@ window.addEventListener('mousemove', (ev) => { | |||||
| hoveredSignal = hoverHit.signal; | hoveredSignal = hoverHit.signal; | ||||
| renderSignalPopover(hoverHit, hoverHit.signal); | renderSignalPopover(hoverHit, hoverHit.signal); | ||||
| } else { | } else { | ||||
| scheduleHideSignalPopover(); | |||||
| hideSignalPopover(); | |||||
| } | } | ||||
| if (isDraggingSpectrum) { | if (isDraggingSpectrum) { | ||||
| const dx = ev.clientX - dragStartX; | const dx = ev.clientX - dragStartX; | ||||
| @@ -1664,13 +1893,33 @@ if (liveListenEventBtn) { | |||||
| liveListenEventBtn.addEventListener('click', () => { | liveListenEventBtn.addEventListener('click', () => { | ||||
| const ev = eventsById.get(selectedEventId); | const ev = eventsById.get(selectedEventId); | ||||
| if (!ev) return; | if (!ev) return; | ||||
| // Toggle off if already listening | |||||
| if (liveListenWS && liveListenWS.playing) { | |||||
| liveListenWS.stop(); | |||||
| liveListenWS = null; | |||||
| liveListenEventBtn.textContent = 'Listen'; | |||||
| liveListenEventBtn.classList.remove('active'); | |||||
| if (liveListenBtn) { liveListenBtn.textContent = 'Live Listen'; liveListenBtn.classList.remove('active'); } | |||||
| return; | |||||
| } | |||||
| const freq = ev.center_hz; | const freq = ev.center_hz; | ||||
| const bw = ev.bandwidth_hz || 12000; | const bw = ev.bandwidth_hz || 12000; | ||||
| const mode = (listenModeSelect?.value || ev.class?.mod_type || 'NFM'); | const mode = (listenModeSelect?.value || ev.class?.mod_type || 'NFM'); | ||||
| const sec = parseInt(listenSecondsInput?.value || '2', 10); | |||||
| const url = `/api/demod?freq=${freq}&bw=${bw}&mode=${mode}&sec=${sec}`; | |||||
| const audio = new Audio(url); | |||||
| audio.play(); | |||||
| if (liveAudio) { liveAudio.pause(); liveAudio = null; } | |||||
| liveListenWS = new LiveListenWS(freq, bw, mode); | |||||
| liveListenWS.onStop(() => { | |||||
| liveListenEventBtn.textContent = 'Listen'; | |||||
| liveListenEventBtn.classList.remove('active'); | |||||
| if (liveListenBtn) { liveListenBtn.textContent = 'Live Listen'; liveListenBtn.classList.remove('active'); } | |||||
| liveListenWS = null; | |||||
| }); | |||||
| liveListenWS.start(); | |||||
| liveListenEventBtn.textContent = '■ Stop'; | |||||
| liveListenEventBtn.classList.add('active'); | |||||
| }); | }); | ||||
| } | } | ||||
| if (decodeEventBtn) { | if (decodeEventBtn) { | ||||
| @@ -1729,6 +1978,15 @@ signalList.addEventListener('click', (ev) => { | |||||
| if (liveListenBtn) { | if (liveListenBtn) { | ||||
| liveListenBtn.addEventListener('click', async () => { | liveListenBtn.addEventListener('click', async () => { | ||||
| // Toggle: if already listening, stop | |||||
| if (liveListenWS && liveListenWS.playing) { | |||||
| liveListenWS.stop(); | |||||
| liveListenWS = null; | |||||
| liveListenBtn.textContent = 'Live Listen'; | |||||
| liveListenBtn.classList.remove('active'); | |||||
| return; | |||||
| } | |||||
| // Use selected signal if available, otherwise first in list | // Use selected signal if available, otherwise first in list | ||||
| let freq, bw, mode; | let freq, bw, mode; | ||||
| if (window._selectedSignal) { | if (window._selectedSignal) { | ||||
| @@ -1743,14 +2001,20 @@ if (liveListenBtn) { | |||||
| mode = first.dataset.class || ''; | mode = first.dataset.class || ''; | ||||
| } | } | ||||
| if (!Number.isFinite(freq)) return; | if (!Number.isFinite(freq)) return; | ||||
| mode = (listenModeSelect?.value === 'Auto') ? (mode || 'NFM') : listenModeSelect.value; | |||||
| const sec = parseInt(listenSecondsInput?.value || '2', 10); | |||||
| const url = `/api/demod?freq=${freq}&bw=${bw}&mode=${mode}&sec=${sec}`; | |||||
| if (liveAudio) { | |||||
| liveAudio.pause(); | |||||
| } | |||||
| liveAudio = new Audio(url); | |||||
| liveAudio.play().catch(() => {}); | |||||
| mode = (listenModeSelect?.value === 'Auto' || listenModeSelect?.value === '') ? (mode || 'NFM') : listenModeSelect.value; | |||||
| // Stop any old HTTP audio | |||||
| if (liveAudio) { liveAudio.pause(); liveAudio = null; } | |||||
| liveListenWS = new LiveListenWS(freq, bw, mode); | |||||
| liveListenWS.onStop(() => { | |||||
| liveListenBtn.textContent = 'Live Listen'; | |||||
| liveListenBtn.classList.remove('active'); | |||||
| liveListenWS = null; | |||||
| }); | |||||
| liveListenWS.start(); | |||||
| liveListenBtn.textContent = '■ Stop'; | |||||
| liveListenBtn.classList.add('active'); | |||||
| }); | }); | ||||
| } | } | ||||
| @@ -496,3 +496,15 @@ body.mode-lab .hero-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); | |||||
| input[type="number"]::-webkit-inner-spin-button, | input[type="number"]::-webkit-inner-spin-button, | ||||
| input[type="number"]::-webkit-outer-spin-button { opacity: 0.3; } | input[type="number"]::-webkit-outer-spin-button { opacity: 0.3; } | ||||
| input[type="number"]:hover::-webkit-inner-spin-button { opacity: 0.7; } | input[type="number"]:hover::-webkit-inner-spin-button { opacity: 0.7; } | ||||
| /* Active live-listen button */ | |||||
| .act-btn.active { | |||||
| background: var(--accent); | |||||
| color: var(--bg-0); | |||||
| box-shadow: 0 0 12px rgba(0, 255, 200, 0.3); | |||||
| animation: listen-pulse 1.5s ease-in-out infinite; | |||||
| } | |||||
| @keyframes listen-pulse { | |||||
| 0%, 100% { box-shadow: 0 0 8px rgba(0, 255, 200, 0.2); } | |||||
| 50% { box-shadow: 0 0 16px rgba(0, 255, 200, 0.5); } | |||||
| } | |||||