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 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-18 19:06:32 +00:00
parent 37d24a539d
commit f29ae3d5a8
2 changed files with 37 additions and 5 deletions

View File

@@ -464,7 +464,13 @@ class SSTVDecoder:
result = vis_detector.feed(samples) result = vis_detector.feed(samples)
if result is not None: if result is not None:
vis_code, mode_name = result 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) mode_spec = get_mode(vis_code)
if mode_spec: if mode_spec:
@@ -473,6 +479,8 @@ class SSTVDecoder:
mode_spec, mode_spec,
sample_rate=SAMPLE_RATE, sample_rate=SAMPLE_RATE,
) )
if len(remaining) > 0:
image_decoder.feed(remaining)
self._emit_progress(DecodeProgress( self._emit_progress(DecodeProgress(
status='decoding', status='decoding',
mode=mode_name, mode=mode_name,
@@ -481,7 +489,6 @@ class SSTVDecoder:
)) ))
else: else:
logger.warning(f"No mode spec for VIS code {vis_code}") logger.warning(f"No mode spec for VIS code {vis_code}")
vis_detector.reset()
# Emit signal level metrics every ~500ms (every 5th 100ms chunk) # Emit signal level metrics every ~500ms (every 5th 100ms chunk)
scope_tone: str | None = None scope_tone: str | None = None
@@ -853,6 +860,8 @@ class SSTVDecoder:
result = vis_detector.feed(chunk) result = vis_detector.feed(chunk)
if result is not None: if result is not None:
vis_code, mode_name = result 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}") logger.info(f"VIS detected in file: code={vis_code}, mode={mode_name}")
mode_spec = get_mode(vis_code) mode_spec = get_mode(vis_code)
@@ -862,8 +871,8 @@ class SSTVDecoder:
mode_spec, mode_spec,
sample_rate=SAMPLE_RATE, sample_rate=SAMPLE_RATE,
) )
else: if len(remaining) > 0:
vis_detector.reset() image_decoder.feed(remaining)
except wave.Error as e: except wave.Error as e:
logger.error(f"Error reading WAV file: {e}") logger.error(f"Error reading WAV file: {e}")

View File

@@ -145,6 +145,16 @@ class VISDetector:
def state(self) -> VISState: def state(self) -> VISState:
return self._state 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: def feed(self, samples: np.ndarray) -> tuple[int, str] | None:
"""Feed audio samples and attempt VIS detection. """Feed audio samples and attempt VIS detection.
@@ -288,9 +298,22 @@ class VISDetector:
if self._tone_counter >= self._bit_windows: if self._tone_counter >= self._bit_windows:
result = self._validate_and_decode() 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 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 return None
def _decode_bit(self, samples: np.ndarray) -> int: def _decode_bit(self, samples: np.ndarray) -> int: