| @@ -42,6 +42,14 @@ type streamSession struct { | |||
| prevAudioL float64 // second-to-last L sample for boundary transient detection | |||
| lastAudioSet bool | |||
| lastDecIQ complex64 | |||
| prevDecIQ complex64 | |||
| lastDecIQSet bool | |||
| lastDemodL float32 | |||
| prevDemodL float64 | |||
| lastDemodSet bool | |||
| // listenOnly sessions have no WAV file and no disk I/O. | |||
| // They exist solely to feed audio to live-listen subscribers. | |||
| listenOnly bool | |||
| @@ -786,11 +794,100 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([] | |||
| dec = fullSnip | |||
| } | |||
| if logging.EnabledCategory("boundary") && len(dec) > 0 { | |||
| first := dec[0] | |||
| if sess.lastDecIQSet { | |||
| d2Re := math.Abs(2*float64(real(sess.lastDecIQ)) - float64(real(sess.prevDecIQ)) - float64(real(first))) | |||
| d2Im := math.Abs(2*float64(imag(sess.lastDecIQ)) - float64(imag(sess.prevDecIQ)) - float64(imag(first))) | |||
| d2Mag := math.Hypot(d2Re, d2Im) | |||
| if d2Mag > 0.15 { | |||
| logging.Warn("boundary", "dec_iq_boundary", "signal", sess.signalID, "d2", d2Mag) | |||
| } | |||
| } | |||
| headN := 16 | |||
| if len(dec) < headN { | |||
| headN = len(dec) | |||
| } | |||
| tailN := 16 | |||
| if len(dec) < tailN { | |||
| tailN = len(dec) | |||
| } | |||
| var headSum, tailSum, minMag, maxMag float64 | |||
| minMag = math.MaxFloat64 | |||
| for i, v := range dec { | |||
| mag := math.Hypot(float64(real(v)), float64(imag(v))) | |||
| if mag < minMag { | |||
| minMag = mag | |||
| } | |||
| if mag > maxMag { | |||
| maxMag = mag | |||
| } | |||
| if i < headN { | |||
| headSum += mag | |||
| } | |||
| } | |||
| for i := len(dec) - tailN; i < len(dec); i++ { | |||
| if i >= 0 { | |||
| v := dec[i] | |||
| tailSum += math.Hypot(float64(real(v)), float64(imag(v))) | |||
| } | |||
| } | |||
| headAvg := 0.0 | |||
| if headN > 0 { | |||
| headAvg = headSum / float64(headN) | |||
| } | |||
| tailAvg := 0.0 | |||
| if tailN > 0 { | |||
| tailAvg = tailSum / float64(tailN) | |||
| } | |||
| logging.Debug("boundary", "dec_iq_meter", "signal", sess.signalID, "len", len(dec), "head_avg", headAvg, "tail_avg", tailAvg, "min_mag", minMag, "max_mag", maxMag) | |||
| if tailAvg > 0 { | |||
| ratio := headAvg / tailAvg | |||
| if ratio < 0.75 || ratio > 1.25 { | |||
| logging.Warn("boundary", "dec_iq_head_tail_skew", "signal", sess.signalID, "head_avg", headAvg, "tail_avg", tailAvg, "ratio", ratio) | |||
| } | |||
| } | |||
| if len(dec) >= 2 { | |||
| sess.prevDecIQ = dec[len(dec)-2] | |||
| sess.lastDecIQ = dec[len(dec)-1] | |||
| } else { | |||
| sess.prevDecIQ = sess.lastDecIQ | |||
| sess.lastDecIQ = dec[0] | |||
| } | |||
| sess.lastDecIQSet = true | |||
| } | |||
| // --- FM/AM/etc Demod --- | |||
| audio := d.Demod(dec, actualDemodRate) | |||
| if len(audio) == 0 { | |||
| return nil, 0 | |||
| } | |||
| if logging.EnabledCategory("boundary") { | |||
| stride := d.Channels() | |||
| if stride < 1 { | |||
| stride = 1 | |||
| } | |||
| nFrames := len(audio) / stride | |||
| if nFrames > 0 { | |||
| first := float64(audio[0]) | |||
| if sess.lastDemodSet { | |||
| d2 := math.Abs(2*float64(sess.lastDemodL) - sess.prevDemodL - first) | |||
| if d2 > 0.15 { | |||
| logging.Warn("boundary", "demod_boundary", "signal", sess.signalID, "d2", d2) | |||
| } | |||
| } | |||
| if nFrames >= 2 { | |||
| sess.prevDemodL = float64(audio[(nFrames-2)*stride]) | |||
| sess.lastDemodL = audio[(nFrames-1)*stride] | |||
| } else { | |||
| sess.prevDemodL = float64(sess.lastDemodL) | |||
| sess.lastDemodL = audio[0] | |||
| } | |||
| sess.lastDemodSet = true | |||
| } | |||
| } | |||
| logging.Debug("boundary", "audio_path", "signal", sess.signalID, "demod", demodName, "actual_rate", actualDemodRate, "audio_len", len(audio), "channels", d.Channels(), "overlap_applied", overlapApplied, "prev_tail_valid", prevTailValid) | |||
| // --- Stateful stereo decode with conservative lock/hysteresis --- | |||