Fix shared waterfall monitor tuning across in-span clicks

This commit is contained in:
Smittix
2026-02-23 23:14:37 +00:00
parent d69613ec2f
commit aa3b21b307
+32 -13
View File
@@ -212,18 +212,21 @@ def _demodulate_monitor_audio(
monitor_freq_mhz: float, monitor_freq_mhz: float,
modulation: str, modulation: str,
squelch: int, squelch: int,
) -> bytes | None: rotator_phase: float = 0.0,
) -> tuple[bytes | None, float]:
if samples.size < 32 or sample_rate <= 0: if samples.size < 32 or sample_rate <= 0:
return None return None, float(rotator_phase)
fs = float(sample_rate) fs = float(sample_rate)
freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6 freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6
nyquist = fs * 0.5 nyquist = fs * 0.5
if abs(freq_offset_hz) > nyquist * 0.98: if abs(freq_offset_hz) > nyquist * 0.98:
return None return None, float(rotator_phase)
n = np.arange(samples.size, dtype=np.float32) phase_inc = (2.0 * np.pi * freq_offset_hz) / fs
rotator = np.exp(-1j * (2.0 * np.pi * freq_offset_hz / fs) * n) n = np.arange(samples.size, dtype=np.float64)
rotator = np.exp(-1j * (float(rotator_phase) + phase_inc * n)).astype(np.complex64)
next_phase = float((float(rotator_phase) + phase_inc * samples.size) % (2.0 * np.pi))
shifted = samples * rotator shifted = samples * rotator
mod = str(modulation or 'wfm').lower().strip() mod = str(modulation or 'wfm').lower().strip()
@@ -232,11 +235,11 @@ def _demodulate_monitor_audio(
if pre_decim > 1: if pre_decim > 1:
usable = (shifted.size // pre_decim) * pre_decim usable = (shifted.size // pre_decim) * pre_decim
if usable < pre_decim: if usable < pre_decim:
return None return None, next_phase
shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1) shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1)
fs1 = fs / pre_decim fs1 = fs / pre_decim
if shifted.size < 16: if shifted.size < 16:
return None return None, next_phase
if mod in ('wfm', 'fm'): if mod in ('wfm', 'fm'):
audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32) audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32)
@@ -251,7 +254,7 @@ def _demodulate_monitor_audio(
audio = np.real(shifted).astype(np.float32) audio = np.real(shifted).astype(np.float32)
if audio.size < 8: if audio.size < 8:
return None return None, next_phase
audio = audio - float(np.mean(audio)) audio = audio - float(np.mean(audio))
@@ -263,7 +266,7 @@ def _demodulate_monitor_audio(
out_len = int(audio.size * AUDIO_SAMPLE_RATE / fs1) out_len = int(audio.size * AUDIO_SAMPLE_RATE / fs1)
if out_len < 32: if out_len < 32:
return None return None, next_phase
x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float32) x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float32)
x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float32) x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float32)
audio = np.interp(x_new, x_old, audio).astype(np.float32) audio = np.interp(x_new, x_old, audio).astype(np.float32)
@@ -278,7 +281,7 @@ def _demodulate_monitor_audio(
audio = audio * min(20.0, 0.85 / peak) audio = audio * min(20.0, 0.85 / peak)
pcm = np.clip(audio, -1.0, 1.0) pcm = np.clip(audio, -1.0, 1.0)
return (pcm * 32767.0).astype(np.int16).tobytes() return (pcm * 32767.0).astype(np.int16).tobytes(), next_phase
def _parse_center_freq_mhz(payload: dict[str, Any]) -> float: def _parse_center_freq_mhz(payload: dict[str, Any]) -> float:
@@ -620,6 +623,8 @@ def init_waterfall_websocket(app: Flask):
timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps))) timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps)))
bytes_per_frame = timeslice_samples * 2 bytes_per_frame = timeslice_samples * 2
frame_interval = 1.0 / _fps frame_interval = 1.0 / _fps
monitor_rotator_phase = 0.0
last_monitor_offset_hz = None
try: try:
while not stop_evt.is_set(): while not stop_evt.is_set():
@@ -664,16 +669,30 @@ def init_waterfall_websocket(app: Flask):
monitor_cfg = _snapshot_monitor_config() monitor_cfg = _snapshot_monitor_config()
if monitor_cfg: if monitor_cfg:
audio_chunk = _demodulate_monitor_audio( center_mhz_cfg = float(monitor_cfg.get('center_mhz', _center_mhz))
monitor_mhz_cfg = float(monitor_cfg.get('monitor_freq_mhz', _center_mhz))
offset_hz = (monitor_mhz_cfg - center_mhz_cfg) * 1e6
if (
last_monitor_offset_hz is None
or abs(offset_hz - last_monitor_offset_hz) > 1.0
):
monitor_rotator_phase = 0.0
last_monitor_offset_hz = offset_hz
audio_chunk, monitor_rotator_phase = _demodulate_monitor_audio(
samples=samples, samples=samples,
sample_rate=_sample_rate, sample_rate=_sample_rate,
center_mhz=monitor_cfg.get('center_mhz', _center_mhz), center_mhz=center_mhz_cfg,
monitor_freq_mhz=monitor_cfg.get('monitor_freq_mhz', _center_mhz), monitor_freq_mhz=monitor_mhz_cfg,
modulation=monitor_cfg.get('modulation', 'wfm'), modulation=monitor_cfg.get('modulation', 'wfm'),
squelch=int(monitor_cfg.get('squelch', 0)), squelch=int(monitor_cfg.get('squelch', 0)),
rotator_phase=monitor_rotator_phase,
) )
if audio_chunk: if audio_chunk:
_push_shared_audio_chunk(audio_chunk) _push_shared_audio_chunk(audio_chunk)
else:
monitor_rotator_phase = 0.0
last_monitor_offset_hz = None
# Pace to target FPS # Pace to target FPS
elapsed = time.monotonic() - frame_start elapsed = time.monotonic() - frame_start