| @@ -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")) | isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) | ||||
| jobOutRate := decimTarget | jobOutRate := decimTarget | ||||
| if isWFM { | if isWFM { | ||||
| jobOutRate = 500000 | |||||
| jobOutRate = wfmStreamOutRate | |||||
| } | } | ||||
| // Minimum extraction BW: ensure enough bandwidth for demod features | // 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) | // Also widen for any signal classified as WFM (in case of re-extraction) | ||||
| if isWFM { | if isWFM { | ||||
| if bw < 250000 { | |||||
| bw = 250000 | |||||
| if bw < wfmStreamMinBW { | |||||
| bw = wfmStreamMinBW | |||||
| } | } | ||||
| } else if bw < 20000 { | } else if bw < 20000 { | ||||
| bw = 20000 | bw = 20000 | ||||
| @@ -162,12 +162,12 @@ func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleR | |||||
| offset := sig.CenterHz - centerHz | offset := sig.CenterHz - centerHz | ||||
| shifted := dsp.FreqShift(iq, sampleRate, offset) | shifted := dsp.FreqShift(iq, sampleRate, offset) | ||||
| bw := sig.BWHz | 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 | sigMHz := sig.CenterHz / 1e6 | ||||
| isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) | isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) | ||||
| if isWFM { | if isWFM { | ||||
| if bw < 250000 { | |||||
| bw = 250000 | |||||
| if bw < wfmStreamMinBW { | |||||
| bw = wfmStreamMinBW | |||||
| } | } | ||||
| } else if bw < 20000 { | } else if bw < 20000 { | ||||
| bw = 20000 | bw = 20000 | ||||
| @@ -225,6 +225,10 @@ type extractionConfig struct { | |||||
| } | } | ||||
| const streamOverlapLen = 512 // must be >= FIR tap count with margin | const streamOverlapLen = 512 // must be >= FIR tap count with margin | ||||
| const ( | |||||
| wfmStreamOutRate = 500000 | |||||
| wfmStreamMinBW = 250000 | |||||
| ) | |||||
| // 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) | ||||
| @@ -289,9 +293,9 @@ func extractForStreaming( | |||||
| (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) | (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) | ||||
| jobOutRate := decimTarget | jobOutRate := decimTarget | ||||
| if isWFM { | if isWFM { | ||||
| jobOutRate = 300000 | |||||
| if bw < 150000 { | |||||
| bw = 150000 | |||||
| jobOutRate = wfmStreamOutRate | |||||
| if bw < wfmStreamMinBW { | |||||
| bw = wfmStreamMinBW | |||||
| } | } | ||||
| } else if bw < 20000 { | } else if bw < 20000 { | ||||
| bw = 20000 | bw = 20000 | ||||
| @@ -325,11 +329,14 @@ func extractForStreaming( | |||||
| results, err := runner.ShiftFilterDecimateBatchWithPhase(gpuIQ, jobs) | results, err := runner.ShiftFilterDecimateBatchWithPhase(gpuIQ, jobs) | ||||
| if err == nil && len(results) == len(signals) { | if err == nil && len(results) == len(signals) { | ||||
| for i, res := range results { | for i, res := range results { | ||||
| outRate := decimTarget | |||||
| outRate := res.Rate | |||||
| if outRate <= 0 { | |||||
| outRate = decimTarget | |||||
| } | |||||
| sigMHz := signals[i].CenterHz / 1e6 | 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")) | isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (signals[i].Class != nil && (signals[i].Class.ModType == "WFM" || signals[i].Class.ModType == "WFM_STEREO")) | ||||
| if isWFM { | if isWFM { | ||||
| outRate = 500000 | |||||
| outRate = wfmStreamOutRate | |||||
| } | } | ||||
| decim := sampleRate / outRate | decim := sampleRate / outRate | ||||
| if decim < 1 { | 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")) | isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) | ||||
| outRate := decimTarget | outRate := decimTarget | ||||
| if isWFM { | if isWFM { | ||||
| outRate = 500000 | |||||
| outRate = wfmStreamOutRate | |||||
| } | } | ||||
| decim := sampleRate / outRate | decim := sampleRate / outRate | ||||
| if decim < 1 { | 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) | // LL-2: Send actual audio info (channels, sample rate from session) | ||||
| info := map[string]any{ | 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 { | if infoBytes, err := json.Marshal(info); err == nil { | ||||
| _ = conn.WriteMessage(websocket.TextMessage, infoBytes) | _ = conn.WriteMessage(websocket.TextMessage, infoBytes) | ||||
| @@ -25,14 +25,16 @@ import ( | |||||
| // --------------------------------------------------------------------------- | // --------------------------------------------------------------------------- | ||||
| type streamSession struct { | 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. | // listenOnly sessions have no WAV file and no disk I/O. | ||||
| // They exist solely to feed audio to live-listen subscribers. | // 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. | // AudioInfo describes the audio format of a live-listen subscription. | ||||
| // Sent to the WebSocket client as the first message. | // Sent to the WebSocket client as the first message. | ||||
| type AudioInfo struct { | 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 ( | const ( | ||||
| @@ -437,12 +441,7 @@ func (st *Streamer) attachPendingListeners(sess *streamSession) { | |||||
| // Send updated audio_info now that we know the real session params. | // Send updated audio_info now that we know the real session params. | ||||
| // Prefix with 0x00 tag byte so ws/audio handler sends as TextMessage. | // 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 := make([]byte, 1+len(infoJSON)) | ||||
| tagged[0] = 0x00 // tag: audio_info | tagged[0] = 0x00 // tag: audio_info | ||||
| copy(tagged[1:], infoJSON) | copy(tagged[1:], infoJSON) | ||||
| @@ -520,12 +519,7 @@ func (st *Streamer) SubscribeAudio(freq float64, bw float64, mode string) (int64 | |||||
| if bestSess != nil && bestDist < 200000 { | if bestSess != nil && bestDist < 200000 { | ||||
| bestSess.audioSubs = append(bestSess.audioSubs, audioSub{id: subID, ch: ch}) | 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)", | log.Printf("STREAM: subscriber %d attached to signal %d (%.1fMHz %s)", | ||||
| subID, bestSess.signalID, bestSess.centerHz/1e6, bestSess.demodName) | subID, bestSess.signalID, bestSess.centerHz/1e6, bestSess.demodName) | ||||
| return subID, ch, info, nil | return subID, ch, info, nil | ||||
| @@ -538,12 +532,7 @@ func (st *Streamer) SubscribeAudio(freq float64, bw float64, mode string) (int64 | |||||
| mode: mode, | mode: mode, | ||||
| ch: ch, | 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("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) | log.Printf("LIVEAUDIO MATCH: subscriber=%d pending req=%.3fMHz bw=%.0f mode=%s", subID, freq/1e6, bw, mode) | ||||
| return subID, ch, info, nil | return subID, ch, info, nil | ||||
| @@ -664,6 +653,7 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([] | |||||
| // --- Stateful stereo decode with conservative lock/hysteresis --- | // --- Stateful stereo decode with conservative lock/hysteresis --- | ||||
| channels := 1 | channels := 1 | ||||
| if isWFMStereo { | if isWFMStereo { | ||||
| sess.playbackMode = "WFM_STEREO" | |||||
| channels = 2 // keep transport format stable for live WFM_STEREO sessions | channels = 2 // keep transport format stable for live WFM_STEREO sessions | ||||
| stereoAudio, locked := sess.stereoDecodeStateful(audio, actualDemodRate) | stereoAudio, locked := sess.stereoDecodeStateful(audio, actualDemodRate) | ||||
| if locked { | if locked { | ||||
| @@ -680,8 +670,10 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([] | |||||
| } | } | ||||
| } | } | ||||
| if sess.stereoEnabled && len(stereoAudio) > 0 { | if sess.stereoEnabled && len(stereoAudio) > 0 { | ||||
| sess.stereoState = "locked" | |||||
| audio = stereoAudio | audio = stereoAudio | ||||
| } else { | } else { | ||||
| sess.stereoState = "mono-fallback" | |||||
| dual := make([]float32, len(audio)*2) | dual := make([]float32, len(audio)*2) | ||||
| for i, s := range audio { | for i, s := range audio { | ||||
| dual[i*2] = s | dual[i*2] = s | ||||
| @@ -999,6 +991,8 @@ func (st *Streamer) openRecordingSession(sig *detector.Signal, now time.Time) (* | |||||
| return nil, err | return nil, err | ||||
| } | } | ||||
| playbackMode, stereoState := initialPlaybackState(demodName) | |||||
| sess := &streamSession{ | sess := &streamSession{ | ||||
| signalID: sig.ID, | signalID: sig.ID, | ||||
| centerHz: sig.CenterHz, | centerHz: sig.CenterHz, | ||||
| @@ -1014,6 +1008,8 @@ func (st *Streamer) openRecordingSession(sig *detector.Signal, now time.Time) (* | |||||
| sampleRate: streamAudioRate, | sampleRate: streamAudioRate, | ||||
| channels: channels, | channels: channels, | ||||
| demodName: demodName, | demodName: demodName, | ||||
| playbackMode: playbackMode, | |||||
| stereoState: stereoState, | |||||
| deemphasisUs: st.policy.DeemphasisUs, | 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{ | sess := &streamSession{ | ||||
| signalID: sig.ID, | signalID: sig.ID, | ||||
| @@ -1053,6 +1050,8 @@ func (st *Streamer) openListenSession(sig *detector.Signal, now time.Time) *stre | |||||
| sampleRate: streamAudioRate, | sampleRate: streamAudioRate, | ||||
| channels: channels, | channels: channels, | ||||
| demodName: demodName, | demodName: demodName, | ||||
| playbackMode: playbackMode, | |||||
| stereoState: stereoState, | |||||
| deemphasisUs: st.policy.DeemphasisUs, | deemphasisUs: st.policy.DeemphasisUs, | ||||
| } | } | ||||
| @@ -1077,6 +1076,48 @@ func resolveDemod(sig *detector.Signal) (string, int) { | |||||
| return demodName, channels | 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 { | func normalizeRequestedMode(mode string) string { | ||||
| switch strings.ToUpper(strings.TrimSpace(mode)) { | switch strings.ToUpper(strings.TrimSpace(mode)) { | ||||
| case "", "AUTO": | case "", "AUTO": | ||||
| @@ -124,12 +124,18 @@ const presetButtons = Array.from(document.querySelectorAll('.preset-btn')); | |||||
| const liveListenBtn = qs('liveListenBtn'); | const liveListenBtn = qs('liveListenBtn'); | ||||
| const listenSecondsInput = qs('listenSeconds'); | const listenSecondsInput = qs('listenSeconds'); | ||||
| const listenModeSelect = qs('listenMode'); | 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 latest = null; | ||||
| let currentConfig = null; | let currentConfig = null; | ||||
| let liveAudio = null; | let liveAudio = null; | ||||
| let liveListenWS = null; // WebSocket-based live listen | let liveListenWS = null; // WebSocket-based live listen | ||||
| let liveListenTarget = null; // { freq, bw, mode } | let liveListenTarget = null; // { freq, bw, mode } | ||||
| let liveListenInfo = null; | |||||
| 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 }; | ||||
| let refinementInfo = {}; | let refinementInfo = {}; | ||||
| let decisionIndex = new Map(); | let decisionIndex = new Map(); | ||||
| @@ -165,9 +171,12 @@ class LiveListenWS { | |||||
| // audio_info JSON message (initial or updated when session attached) | // audio_info JSON message (initial or updated when session attached) | ||||
| try { | try { | ||||
| const info = JSON.parse(ev.data); | 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 channels or rate changed, reinit AudioContext | ||||
| if (newRate !== this.sampleRate || newCh !== this.channels) { | if (newRate !== this.sampleRate || newCh !== this.channels) { | ||||
| this.sampleRate = newRate; | 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) { | function resolveListenMode(detectedMode) { | ||||
| const manual = listenModeSelect?.value || ''; | const manual = listenModeSelect?.value || ''; | ||||
| if (manual) return manual; | if (manual) return manual; | ||||
| @@ -304,6 +368,7 @@ function stopLiveListen() { | |||||
| } | } | ||||
| liveListenTarget = null; | liveListenTarget = null; | ||||
| setLiveListenUI(false); | setLiveListenUI(false); | ||||
| resetLiveListenMeta(); | |||||
| } | } | ||||
| function startLiveListen(freq, bw, detectedMode) { | function startLiveListen(freq, bw, detectedMode) { | ||||
| @@ -328,9 +393,19 @@ function startLiveListen(freq, bw, detectedMode) { | |||||
| liveListenWS = null; | liveListenWS = null; | ||||
| liveListenTarget = null; | liveListenTarget = null; | ||||
| setLiveListenUI(false); | setLiveListenUI(false); | ||||
| resetLiveListenMeta(); | |||||
| }); | }); | ||||
| liveListenWS.start(); | liveListenWS.start(); | ||||
| setLiveListenUI(true); | 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) { | function matchesListenTarget(signal) { | ||||
| @@ -2292,6 +2367,7 @@ window.addEventListener('keydown', (ev) => { | |||||
| }); | }); | ||||
| loadConfig(); | loadConfig(); | ||||
| resetLiveListenMeta(); | |||||
| loadStats(); | loadStats(); | ||||
| loadGPU(); | loadGPU(); | ||||
| loadRefinement(); | loadRefinement(); | ||||
| @@ -289,6 +289,14 @@ | |||||
| </label> | </label> | ||||
| </div> | </div> | ||||
| <button class="act-btn" id="liveListenBtn" type="button">Live Listen</button> | <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> | </section> | ||||
| <!-- ── Events Tab ── --> | <!-- ── Events Tab ── --> | ||||
| @@ -373,6 +373,38 @@ input[type="range"]::-moz-range-thumb { | |||||
| background: rgba(15, 25, 40, 0.8); border: 1px solid var(--line); | 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 */ | ||||
| .health-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } | .health-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } | ||||
| .health-card { | .health-card { | ||||