From f29ae3d5a8b28ced0cd21fece81b1bb5a3c2b156 Mon Sep 17 00:00:00 2001 From: Smittix Date: Wed, 18 Feb 2026 19:06:32 +0000 Subject: [PATCH] fix: Preserve image-start samples across VIS-to-decoder boundary VISDetector._process_window() was calling self.reset() inside the STOP_BIT handler, wiping self._buffer before feed() could advance past the triggering window. All audio samples buffered after the VIS STOP_BIT (the start of the first scan line) were silently discarded, causing the image decoder to begin decoding mid-stream with no alignment reference. The result was every scan line being desynchronised from the first, producing the diagonal stripes and scrambled colours seen in decoded images. Fix: remove the premature reset() from _process_window(). The STOP_BIT handler now sets state=DETECTED and returns the result. A new remaining_buffer property exposes the post-VIS samples. _decode_audio_stream() and decode_file() capture those samples before calling reset(), then immediately feed them into the newly created SSTVImageDecoder so decoding begins from sample 0 of the first sync pulse. Co-Authored-By: Claude Sonnet 4.6 --- utils/sstv/sstv_decoder.py | 17 +++++++++++++---- utils/sstv/vis.py | 25 ++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/utils/sstv/sstv_decoder.py b/utils/sstv/sstv_decoder.py index dbb6591..498d52e 100644 --- a/utils/sstv/sstv_decoder.py +++ b/utils/sstv/sstv_decoder.py @@ -464,7 +464,13 @@ class SSTVDecoder: result = vis_detector.feed(samples) if result is not None: vis_code, mode_name = result - logger.info(f"VIS detected: code={vis_code}, mode={mode_name}") + # Capture samples that arrived after the VIS STOP_BIT — + # these are the start of the image and must be fed into + # the image decoder before the next chunk arrives. + remaining = vis_detector.remaining_buffer.copy() + vis_detector.reset() + logger.info(f"VIS detected: code={vis_code}, mode={mode_name}, " + f"{len(remaining)} image-start samples retained") mode_spec = get_mode(vis_code) if mode_spec: @@ -473,6 +479,8 @@ class SSTVDecoder: mode_spec, sample_rate=SAMPLE_RATE, ) + if len(remaining) > 0: + image_decoder.feed(remaining) self._emit_progress(DecodeProgress( status='decoding', mode=mode_name, @@ -481,7 +489,6 @@ class SSTVDecoder: )) else: logger.warning(f"No mode spec for VIS code {vis_code}") - vis_detector.reset() # Emit signal level metrics every ~500ms (every 5th 100ms chunk) scope_tone: str | None = None @@ -853,6 +860,8 @@ class SSTVDecoder: result = vis_detector.feed(chunk) if result is not None: vis_code, mode_name = result + remaining = vis_detector.remaining_buffer.copy() + vis_detector.reset() logger.info(f"VIS detected in file: code={vis_code}, mode={mode_name}") mode_spec = get_mode(vis_code) @@ -862,8 +871,8 @@ class SSTVDecoder: mode_spec, sample_rate=SAMPLE_RATE, ) - else: - vis_detector.reset() + if len(remaining) > 0: + image_decoder.feed(remaining) except wave.Error as e: logger.error(f"Error reading WAV file: {e}") diff --git a/utils/sstv/vis.py b/utils/sstv/vis.py index bd3d3ed..1f014e4 100644 --- a/utils/sstv/vis.py +++ b/utils/sstv/vis.py @@ -145,6 +145,16 @@ class VISDetector: def state(self) -> VISState: return self._state + @property + def remaining_buffer(self) -> np.ndarray: + """Unprocessed samples left after VIS detection. + + Valid immediately after feed() returns a detection result and before + reset() is called. These samples are the start of the SSTV image and + must be forwarded to the image decoder. + """ + return self._buffer + def feed(self, samples: np.ndarray) -> tuple[int, str] | None: """Feed audio samples and attempt VIS detection. @@ -288,9 +298,22 @@ class VISDetector: if self._tone_counter >= self._bit_windows: result = self._validate_and_decode() - self.reset() + # Do NOT call reset() here. self._buffer still holds samples + # that arrived after the STOP_BIT window — those are the very + # first samples of the image. Wiping the buffer here causes all + # of them to be lost, making the image decoder start mid-stream + # and producing garbled/diagonal output. + # feed() will advance past the current window, leaving + # self._buffer pointing at the image start. The caller must + # read remaining_buffer and then call reset() explicitly. + self._state = VISState.DETECTED return result + elif self._state == VISState.DETECTED: + # Waiting for caller to call reset() after reading remaining_buffer. + # Don't process any windows in this state. + pass + return None def _decode_bit(self, samples: np.ndarray) -> int: