| @@ -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 { | |||
| @@ -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) | |||
| @@ -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": | |||
| @@ -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(); | |||
| @@ -289,6 +289,14 @@ | |||
| </label> | |||
| </div> | |||
| <button class="act-btn" id="liveListenBtn" type="button">Live Listen</button> | |||
| <div class="listen-meta" id="listenMeta"> | |||
| <div class="listen-meta__title">Live listen status</div> | |||
| <div class="listen-meta__row"><span>Demod</span><span id="listenMetaDemod">-</span></div> | |||
| <div class="listen-meta__row"><span>Playback</span><span id="listenMetaPlayback">-</span></div> | |||
| <div class="listen-meta__row"><span>Stereo</span><span id="listenMetaStereo">-</span></div> | |||
| <div class="listen-meta__row"><span>Sample rate</span><span id="listenMetaRate">-</span></div> | |||
| <div class="listen-meta__row"><span>Channels</span><span id="listenMetaChannels">-</span></div> | |||
| </div> | |||
| </section> | |||
| <!-- ── Events Tab ── --> | |||
| @@ -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 { | |||