diff --git a/cmd/sdrd/helpers.go b/cmd/sdrd/helpers.go index 4c1a63a..a47343c 100644 --- a/cmd/sdrd/helpers.go +++ b/cmd/sdrd/helpers.go @@ -129,14 +129,14 @@ func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleR isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) jobOutRate := decimTarget if isWFM { - jobOutRate = 500000 + jobOutRate = wfmStreamOutRate } // Minimum extraction BW: ensure enough bandwidth for demod features - // FM broadcast (87.5-108 MHz) needs >=150kHz for stereo pilot + RDS at 57kHz + // FM broadcast (87.5-108 MHz) needs >=250kHz for stereo pilot + RDS at 57kHz // Also widen for any signal classified as WFM (in case of re-extraction) if isWFM { - if bw < 250000 { - bw = 250000 + if bw < wfmStreamMinBW { + bw = wfmStreamMinBW } } else if bw < 20000 { bw = 20000 @@ -162,12 +162,12 @@ func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleR offset := sig.CenterHz - centerHz shifted := dsp.FreqShift(iq, sampleRate, offset) bw := sig.BWHz - // FM broadcast (87.5-108 MHz) needs >=150kHz for stereo + RDS + // FM broadcast (87.5-108 MHz) needs >=250kHz for stereo + RDS sigMHz := sig.CenterHz / 1e6 isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) if isWFM { - if bw < 250000 { - bw = 250000 + if bw < wfmStreamMinBW { + bw = wfmStreamMinBW } } else if bw < 20000 { bw = 20000 @@ -225,6 +225,10 @@ type extractionConfig struct { } const streamOverlapLen = 512 // must be >= FIR tap count with margin +const ( + wfmStreamOutRate = 500000 + wfmStreamMinBW = 250000 +) // extractForStreaming performs GPU-accelerated extraction with: // - Per-signal phase-continuous FreqShift (via PhaseStart in ExtractJob) @@ -289,9 +293,9 @@ func extractForStreaming( (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) jobOutRate := decimTarget if isWFM { - jobOutRate = 300000 - if bw < 150000 { - bw = 150000 + jobOutRate = wfmStreamOutRate + if bw < wfmStreamMinBW { + bw = wfmStreamMinBW } } else if bw < 20000 { bw = 20000 @@ -325,11 +329,14 @@ func extractForStreaming( results, err := runner.ShiftFilterDecimateBatchWithPhase(gpuIQ, jobs) if err == nil && len(results) == len(signals) { for i, res := range results { - outRate := decimTarget + outRate := res.Rate + if outRate <= 0 { + outRate = decimTarget + } sigMHz := signals[i].CenterHz / 1e6 isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (signals[i].Class != nil && (signals[i].Class.ModType == "WFM" || signals[i].Class.ModType == "WFM_STEREO")) if isWFM { - outRate = 500000 + outRate = wfmStreamOutRate } decim := sampleRate / outRate if decim < 1 { @@ -393,7 +400,7 @@ func extractForStreaming( isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) outRate := decimTarget if isWFM { - outRate = 500000 + outRate = wfmStreamOutRate } decim := sampleRate / outRate if decim < 1 { diff --git a/cmd/sdrd/ws_handlers.go b/cmd/sdrd/ws_handlers.go index 87d29e0..5e60c5a 100644 --- a/cmd/sdrd/ws_handlers.go +++ b/cmd/sdrd/ws_handlers.go @@ -164,13 +164,15 @@ func registerWSHandlers(mux *http.ServeMux, h *hub, recMgr *recorder.Manager) { // LL-2: Send actual audio info (channels, sample rate from session) info := map[string]any{ - "type": "audio_info", - "sample_rate": audioInfo.SampleRate, - "channels": audioInfo.Channels, - "format": audioInfo.Format, - "demod": audioInfo.DemodName, - "freq": freq, - "mode": mode, + "type": "audio_info", + "sample_rate": audioInfo.SampleRate, + "channels": audioInfo.Channels, + "format": audioInfo.Format, + "demod": audioInfo.DemodName, + "playback_mode": audioInfo.PlaybackMode, + "stereo_state": audioInfo.StereoState, + "freq": freq, + "mode": mode, } if infoBytes, err := json.Marshal(info); err == nil { _ = conn.WriteMessage(websocket.TextMessage, infoBytes) diff --git a/internal/recorder/streamer.go b/internal/recorder/streamer.go index 37daa99..55af71d 100644 --- a/internal/recorder/streamer.go +++ b/internal/recorder/streamer.go @@ -25,14 +25,16 @@ import ( // --------------------------------------------------------------------------- type streamSession struct { - signalID int64 - centerHz float64 - bwHz float64 - snrDb float64 - peakDb float64 - class *classifier.Classification - startTime time.Time - lastFeed time.Time + signalID int64 + centerHz float64 + bwHz float64 + snrDb float64 + peakDb float64 + class *classifier.Classification + startTime time.Time + lastFeed time.Time + playbackMode string + stereoState string // listenOnly sessions have no WAV file and no disk I/O. // They exist solely to feed audio to live-listen subscribers. @@ -116,10 +118,12 @@ type audioSub struct { // AudioInfo describes the audio format of a live-listen subscription. // Sent to the WebSocket client as the first message. type AudioInfo struct { - SampleRate int `json:"sample_rate"` - Channels int `json:"channels"` - Format string `json:"format"` // always "s16le" - DemodName string `json:"demod"` + SampleRate int `json:"sample_rate"` + Channels int `json:"channels"` + Format string `json:"format"` // always "s16le" + DemodName string `json:"demod"` + PlaybackMode string `json:"playback_mode,omitempty"` + StereoState string `json:"stereo_state,omitempty"` } const ( @@ -437,12 +441,7 @@ func (st *Streamer) attachPendingListeners(sess *streamSession) { // Send updated audio_info now that we know the real session params. // Prefix with 0x00 tag byte so ws/audio handler sends as TextMessage. - infoJSON, _ := json.Marshal(AudioInfo{ - SampleRate: sess.sampleRate, - Channels: sess.channels, - Format: "s16le", - DemodName: sess.demodName, - }) + infoJSON, _ := json.Marshal(sess.audioInfo()) tagged := make([]byte, 1+len(infoJSON)) tagged[0] = 0x00 // tag: audio_info copy(tagged[1:], infoJSON) @@ -520,12 +519,7 @@ func (st *Streamer) SubscribeAudio(freq float64, bw float64, mode string) (int64 if bestSess != nil && bestDist < 200000 { bestSess.audioSubs = append(bestSess.audioSubs, audioSub{id: subID, ch: ch}) - info := AudioInfo{ - SampleRate: bestSess.sampleRate, - Channels: bestSess.channels, - Format: "s16le", - DemodName: bestSess.demodName, - } + info := bestSess.audioInfo() log.Printf("STREAM: subscriber %d attached to signal %d (%.1fMHz %s)", subID, bestSess.signalID, bestSess.centerHz/1e6, bestSess.demodName) return subID, ch, info, nil @@ -538,12 +532,7 @@ func (st *Streamer) SubscribeAudio(freq float64, bw float64, mode string) (int64 mode: mode, ch: ch, } - info := AudioInfo{ - SampleRate: streamAudioRate, - Channels: 1, - Format: "s16le", - DemodName: "NFM", - } + info := defaultAudioInfoForMode(mode) log.Printf("STREAM: subscriber %d pending (freq=%.1fMHz)", subID, freq/1e6) log.Printf("LIVEAUDIO MATCH: subscriber=%d pending req=%.3fMHz bw=%.0f mode=%s", subID, freq/1e6, bw, mode) return subID, ch, info, nil @@ -664,6 +653,7 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([] // --- Stateful stereo decode with conservative lock/hysteresis --- channels := 1 if isWFMStereo { + sess.playbackMode = "WFM_STEREO" channels = 2 // keep transport format stable for live WFM_STEREO sessions stereoAudio, locked := sess.stereoDecodeStateful(audio, actualDemodRate) if locked { @@ -680,8 +670,10 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([] } } if sess.stereoEnabled && len(stereoAudio) > 0 { + sess.stereoState = "locked" audio = stereoAudio } else { + sess.stereoState = "mono-fallback" dual := make([]float32, len(audio)*2) for i, s := range audio { dual[i*2] = s @@ -999,6 +991,8 @@ func (st *Streamer) openRecordingSession(sig *detector.Signal, now time.Time) (* return nil, err } + playbackMode, stereoState := initialPlaybackState(demodName) + sess := &streamSession{ signalID: sig.ID, centerHz: sig.CenterHz, @@ -1014,6 +1008,8 @@ func (st *Streamer) openRecordingSession(sig *detector.Signal, now time.Time) (* sampleRate: streamAudioRate, channels: channels, demodName: demodName, + playbackMode: playbackMode, + stereoState: stereoState, deemphasisUs: st.policy.DeemphasisUs, } @@ -1039,6 +1035,7 @@ func (st *Streamer) openListenSession(sig *detector.Signal, now time.Time) *stre } } } + playbackMode, stereoState := initialPlaybackState(demodName) sess := &streamSession{ signalID: sig.ID, @@ -1053,6 +1050,8 @@ func (st *Streamer) openListenSession(sig *detector.Signal, now time.Time) *stre sampleRate: streamAudioRate, channels: channels, demodName: demodName, + playbackMode: playbackMode, + stereoState: stereoState, deemphasisUs: st.policy.DeemphasisUs, } @@ -1077,6 +1076,48 @@ func resolveDemod(sig *detector.Signal) (string, int) { return demodName, channels } +func initialPlaybackState(demodName string) (string, string) { + playbackMode := demodName + stereoState := "mono" + if demodName == "WFM_STEREO" { + stereoState = "searching" + } + return playbackMode, stereoState +} + +func (sess *streamSession) audioInfo() AudioInfo { + return AudioInfo{ + SampleRate: sess.sampleRate, + Channels: sess.channels, + Format: "s16le", + DemodName: sess.demodName, + PlaybackMode: sess.playbackMode, + StereoState: sess.stereoState, + } +} + +func defaultAudioInfoForMode(mode string) AudioInfo { + demodName := "NFM" + if requested := normalizeRequestedMode(mode); requested != "" { + demodName = requested + } + channels := 1 + if demodName == "WFM_STEREO" { + channels = 2 + } else if d := demod.Get(demodName); d != nil { + channels = d.Channels() + } + playbackMode, stereoState := initialPlaybackState(demodName) + return AudioInfo{ + SampleRate: streamAudioRate, + Channels: channels, + Format: "s16le", + DemodName: demodName, + PlaybackMode: playbackMode, + StereoState: stereoState, + } +} + func normalizeRequestedMode(mode string) string { switch strings.ToUpper(strings.TrimSpace(mode)) { case "", "AUTO": diff --git a/web/app.js b/web/app.js index 6e973f1..9d94b27 100644 --- a/web/app.js +++ b/web/app.js @@ -124,12 +124,18 @@ const presetButtons = Array.from(document.querySelectorAll('.preset-btn')); const liveListenBtn = qs('liveListenBtn'); const listenSecondsInput = qs('listenSeconds'); const listenModeSelect = qs('listenMode'); +const listenMetaDemod = qs('listenMetaDemod'); +const listenMetaPlayback = qs('listenMetaPlayback'); +const listenMetaStereo = qs('listenMetaStereo'); +const listenMetaRate = qs('listenMetaRate'); +const listenMetaChannels = qs('listenMetaChannels'); let latest = null; let currentConfig = null; let liveAudio = null; let liveListenWS = null; // WebSocket-based live listen let liveListenTarget = null; // { freq, bw, mode } +let liveListenInfo = null; let stats = { buffer_samples: 0, dropped: 0, resets: 0, last_sample_ago_ms: -1 }; let refinementInfo = {}; let decisionIndex = new Map(); @@ -165,9 +171,12 @@ class LiveListenWS { // 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; + handleLiveListenAudioInfo(info); + const hasRate = Number.isFinite(info.sample_rate) && info.sample_rate > 0; + const hasCh = Number.isFinite(info.channels) && info.channels > 0; + if (hasRate || hasCh) { + const newRate = hasRate ? info.sample_rate : this.sampleRate; + const newCh = hasCh ? info.channels : this.channels; // If channels or rate changed, reinit AudioContext if (newRate !== this.sampleRate || newCh !== this.channels) { this.sampleRate = newRate; @@ -279,6 +288,61 @@ class LiveListenWS { } } +const liveListenDefaults = { + demod: '-', + playback_mode: 'Inactive', + stereo_state: '-', + sample_rate: null, + channels: null +}; + +function formatListenMetaValue(value, fallback = '-') { + if (value === undefined || value === null || value === '') return fallback; + return String(value); +} + +function renderLiveListenMeta(info) { + if (!listenMetaDemod) return; + const demod = formatListenMetaValue(info?.demod); + const playback = formatListenMetaValue(info?.playback_mode, 'Inactive'); + const stereo = formatListenMetaValue(info?.stereo_state); + const sampleRate = Number.isFinite(info?.sample_rate) && info.sample_rate > 0 + ? fmtHz(info.sample_rate) + : '-'; + const channels = Number.isFinite(info?.channels) && info.channels > 0 + ? String(info.channels) + : '-'; + + listenMetaDemod.textContent = demod; + listenMetaPlayback.textContent = playback; + listenMetaStereo.textContent = stereo; + listenMetaRate.textContent = sampleRate; + listenMetaChannels.textContent = channels; +} + +function resetLiveListenMeta() { + liveListenInfo = { ...liveListenDefaults }; + renderLiveListenMeta(liveListenInfo); +} + +function updateLiveListenMeta(partial) { + liveListenInfo = { ...(liveListenInfo || liveListenDefaults), ...partial }; + renderLiveListenMeta(liveListenInfo); +} + +function handleLiveListenAudioInfo(info) { + if (!info || typeof info !== 'object') return; + const partial = {}; + if (info.demod) partial.demod = info.demod; + if (info.playback_mode) partial.playback_mode = info.playback_mode; + if (info.stereo_state) partial.stereo_state = info.stereo_state; + if (Number.isFinite(info.sample_rate)) partial.sample_rate = info.sample_rate; + if (Number.isFinite(info.channels)) partial.channels = info.channels; + if (Object.keys(partial).length > 0) { + updateLiveListenMeta(partial); + } +} + function resolveListenMode(detectedMode) { const manual = listenModeSelect?.value || ''; if (manual) return manual; @@ -304,6 +368,7 @@ function stopLiveListen() { } liveListenTarget = null; setLiveListenUI(false); + resetLiveListenMeta(); } function startLiveListen(freq, bw, detectedMode) { @@ -328,9 +393,19 @@ function startLiveListen(freq, bw, detectedMode) { liveListenWS = null; liveListenTarget = null; setLiveListenUI(false); + resetLiveListenMeta(); }); liveListenWS.start(); setLiveListenUI(true); + + const startingInfo = { + demod: mode || '-', + playback_mode: 'Starting', + stereo_state: mode === 'WFM_STEREO' ? 'searching' : 'mono', + sample_rate: 48000, + channels: mode === 'WFM_STEREO' ? 2 : 1 + }; + updateLiveListenMeta(startingInfo); } function matchesListenTarget(signal) { @@ -2292,6 +2367,7 @@ window.addEventListener('keydown', (ev) => { }); loadConfig(); +resetLiveListenMeta(); loadStats(); loadGPU(); loadRefinement(); diff --git a/web/index.html b/web/index.html index 2a2f5e9..969c85c 100644 --- a/web/index.html +++ b/web/index.html @@ -289,6 +289,14 @@ +
diff --git a/web/style.css b/web/style.css index c4413ec..1800183 100644 --- a/web/style.css +++ b/web/style.css @@ -373,6 +373,38 @@ input[type="range"]::-moz-range-thumb { background: rgba(15, 25, 40, 0.8); border: 1px solid var(--line); } +.listen-meta { + margin-top: 8px; + padding: 8px 10px; + border-radius: var(--r); + border: 1px solid var(--line); + background: rgba(5, 8, 14, 0.6); + display: grid; + gap: 4px; +} +.listen-meta__title { + font-family: var(--mono); + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-mute); + margin-bottom: 2px; +} +.listen-meta__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-family: var(--mono); + font-size: 0.7rem; + color: var(--text-dim); +} +.listen-meta__row span:last-child { + color: var(--text); + font-weight: 600; +} + /* Health Grid */ .health-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } .health-card {