Sfoglia il codice sorgente

Wire live listen status metadata

master
Jan Svabenik 19 ore fa
parent
commit
bf5d976920
6 ha cambiato i file con 219 aggiunte e 53 eliminazioni
  1. +20
    -13
      cmd/sdrd/helpers.go
  2. +9
    -7
      cmd/sdrd/ws_handlers.go
  3. +71
    -30
      internal/recorder/streamer.go
  4. +79
    -3
      web/app.js
  5. +8
    -0
      web/index.html
  6. +32
    -0
      web/style.css

+ 20
- 13
cmd/sdrd/helpers.go Vedi File

@@ -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 {


+ 9
- 7
cmd/sdrd/ws_handlers.go Vedi File

@@ -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)


+ 71
- 30
internal/recorder/streamer.go Vedi File

@@ -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":


+ 79
- 3
web/app.js Vedi File

@@ -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();


+ 8
- 0
web/index.html Vedi File

@@ -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 ── -->


+ 32
- 0
web/style.css Vedi File

@@ -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 {


Loading…
Annulla
Salva