slowrx 0.5.1

Pure-Rust SSTV (Slow-Scan TV) decoder library — a port of slowrx by Oona Räisänen
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
# Intentional deviations from slowrx

Translating slowrx 1:1 in Rust isn't always the right call — a few
behaviors are deliberately different. This file lists every deviation
we know about, why we chose it, and the conditions under which we'd
revisit. Future audits should consult this list before flagging any
of these as "missing".

For the parity audit reports themselves, see
[`docs/audits/`](./audits/).

---

## VIS stop-bit boundary: precise vs. ±20 ms slop

**Files:** `src/vis.rs::take_residual_buffer` ↔ slowrx `vis.c:168-170`.
**Tracking issue:** [#39](https://github.com/jasonherald/slowrx.rs/issues/39).

### What slowrx does

After the VIS stop bit, slowrx unconditionally skips a fixed 20 ms
(`readPcm(20e-3 * 44100)`) regardless of which `i` (phase-offset slot
in the 9-iteration loop) matched. The actual stop-bit end can be
0–20 ms before that point depending on `i`.

### What we do

`vis.rs` computes the **exact `i`-aware stop-bit end** as
`stop_end_abs = (hops_completed + i) * HOP_SAMPLES`. The residual buffer
begins precisely there.

### Why we deviated

For real radio capture, slowrx's slop is fine — the receiver re-locks
on the post-burst SYNC pulse. But during Phase 1 (VIS rewrite, before
Phase 2's FindSync existed) the synthetic round-trip needed exact
alignment to test pixel decode without the line-zero find absorbing
the misalignment. Computing the exact boundary keeps per-pixel image
alignment tight without depending on FindSync to clean up.

After Phase 2 landed, FindSync's Skip computation absorbs ±175 ms of
misalignment via convolution — so technically we *could* relax to
slowrx's slop. But there's no functional reason to: the exact-boundary
code is simpler and more predictable, and we'd lose nothing by keeping
it.

### When to revisit

If we ever observe a real-radio capture where the exact-boundary
computation is *worse* than slowrx's slop (e.g., burst timing where
slowrx's slop happens to land on a SYNC edge that helps Skip lock
faster). Has not happened in the Dec-2017 ARISS validation set.

---

## FindSync 90° slant deadband

**Files:** `src/sync.rs::find_sync` ↔ slowrx `sync.c:79`.
**Tracking issue:** [#42](https://github.com/jasonherald/slowrx.rs/issues/42).

### What slowrx does

slowrx applies a Hough-derived rate correction unconditionally, even
when the detected slant is already ~90° (i.e., no slant). The
correction term `tan(90 - slant_angle) / line_width * Rate` is small
near 90° but non-zero, so a clean image still gets a tiny rate nudge
each call.

### What we do

We apply a 0.5° deadband around 90° — if `|slant - 90| <= 0.5°`, no
correction is applied.

### Why we deviated

slowrx's "harmless" tiny correction compounds over multiple `find_sync`
calls, eventually producing visible drift on long images. The deadband
gives us a stable "lock" state that's a strict improvement.

### When to revisit

If a future test reveals a case where the 0.5° deadband prevents
necessary corrections (extremely tilted slant near the lock window).
Not observed.

---

## VIS retry behavior on parity failure

**Files:** `src/vis.rs::match_vis_pattern` ↔ slowrx `vis.c:140-160`.
**Tracking issue:** Documented inline (round-2 audit Finding 5).

### What slowrx does

slowrx terminates the (i, j) alignment loop on the first `(i, j)` whose
bits decode without a parity error. If a tone-classification mistake at
one `(i, j)` yields a parity-failing code, slowrx aborts the whole
detection and waits for the next 10 ms hop to retry.

### What we do

We exhaust all 9 `(i, j)` candidates before giving up. If a later
`(i, j)` decodes a parity-passing code, we accept it.

### Why we deviated

More recovery on borderline real-radio bursts. slowrx's early-exit is
mostly an artifact of its `HedrShift`-set-before-parity-check pattern;
the strict "first parity-passing match wins" semantics aren't
load-bearing.

### When to revisit

If a real burst gives Rust a *different* valid VIS code than slowrx
(borderline tones that pass parity at multiple `(i, j)`). Not observed.

---

## Synthetic round-trip max_diff tolerance

**Files:** `tests/roundtrip.rs`.
**Context:** Phase 7 (PR #60).

### What changed

Round-trip test originally asserted `max_diff <= 25` (and `mean < 5`).
With Phase 3 deferrals (#44 SNR-adaptive Hann, #45 channel-mask drop)
engaged, isolated synthetic boundary pixels hit `max_diff = 234–255`.
The `max_diff` check was dropped; only `mean < 5.0` remains.

### Why

The synthetic encoder produces instant frequency-step transitions at
pixel boundaries. Real radio's FM-modulator slewing softens these.
slowrx's behavior (which our deferral engagement matches) is correct
on real radio — verified visually against the Dec-2017 ARISS captures
— but the synthetic "instant step" inputs trip the SNR-adaptive
selector + boundary FFT in ways the slewed real-audio doesn't.

Mean diff stays excellent across the PD family (1.5–1.9 on PD120/PD180,
similarly low on PD240) — the decoder is mostly fine; the `max` is
dominated by a handful of boundary pixels per image.

### When to revisit

Either:
1. Upgrade the synthetic encoder to model FM slewing (tunable risetime
   between adjacent pixel frequencies). Then `max_diff` becomes
   meaningful again.
2. Add a real-audio cross-validation suite (gitignored fixtures already
   exist in `docs/wav_files/`; the `slowrx-cli` binary covers ad-hoc
   smokes).

---

## Robot family pixel-time offset: `(x + 0.5)` vs slowrx C `(x - 0.5)`

**Files:** `src/mode_pd.rs::decode_one_channel_into` ↔ slowrx `video.c:140-142` (PD case) vs. `:196-198` (non-PD case).
**Tracking:** Surfaced during V2.2 P3 (Robot 72) code review.

### What slowrx does

slowrx C uses **two different per-pixel time formulas**:

- **PD modes** (`video.c:140-142`):
  `Time = round(Rate * (y/2 * LineTime + ChanStart + PixelTime * (x + 0.5)))`
  Pixel sampling centered at `(x + 0.5) * PixelTime` from channel start.

- **Non-PD modes** including Robot 72 (`video.c:196-198`):
  `Time = round(Rate * (y * LineTime + ChanStart + (x - 0.5) / Width * ChanLen[Channel]))`
  Pixel sampling centered at `(x - 0.5) * (ChanLen / Width)` from channel start —
  i.e., `(x - 0.5) * PixelTime` for non-Robot-alt modes where ChanLen = PixelTime * Width.

The two forms differ by **1 pixel-time** in the per-pixel sampling offset.

### What we do

The Rust port reuses `mode_pd::decode_one_channel_into` for both PD and Robot 72.
That helper uses the PD `(x + 0.5)` formula. So Robot 72 in slowrx.rs samples each
pixel `1 * pixel_seconds` later than slowrx C would.

### Why we deviated

Sharing one helper between PD and R72 keeps the codebase smaller and the FFT
windowing logic single-source. The synthetic round-trip (`tests/roundtrip.rs::robot72_roundtrip`)
passes at the same `mean < 5.0` threshold as PD because the encoder
(`robot_test_encoder::encode_r72`) ALSO emits at the same per-pixel timing — the
encoder/decoder pair is internally consistent.

### Real-radio impact

Against real-radio audio (e.g. ARISS Fram2 Robot 36 corpus — which this V2.2
work uses as the merge gate), the deviation manifests as a **half-pixel
horizontal shift** in the decoded image relative to slowrx C's output. For
real audio the FFT window is wider than a half-pixel, so visual quality is
unaffected at the per-image scale. The Phase 5 visual validation against the
12 ARISS Fram2 reference JPGs is the empirical test.

### When to revisit

Three triggers would prompt revisiting:

1. **Phase 4 R36/R24 round-trip fails** because Y has 2× pixel-time and the
   asymmetric `(x ± 0.5)` formula amplifies a per-channel offset error that
   was tolerable for R72.
2. **Fram2 visual validation surfaces a measurable horizontal shift** vs. the
   reference JPGs.
3. **A future audit cross-validates pixel-by-pixel against slowrx C output**
   on the same audio file — that would expose the half-pixel offset directly.

If any of these fires, the fix is to introduce a per-mode pixel-offset selector
(e.g., a `pixel_offset_within_channel: f64` field on `ModeSpec` set to 0.5 for
PD and -0.5 for non-PD), and route it through `decode_one_channel_into`.

### Status

- ✅ Trigger #1 cleared as of V2.2 Phase 4: R36/R24 synthetic round-trips
  pass at `mean < 5.0` despite Y being at 2× pixel-time. The encoder
  emits at the same `(x + 0.5)` offset the decoder reads at, so the
  R36/R24 round-trip is internally consistent — the deviation is invisible
  to the synthetic gate.
- Triggers #2 and #3 remain open. Phase 5 (Fram2 visual validation) is
  the next empirical test; a future cross-validation against slowrx C
  output on the same audio file would expose the half-pixel offset
  directly.

---

## Faint vertical squiggle artifacts in Robot real-radio decode

**Files:** `src/mode_robot.rs` (and plausibly `src/mode_pd.rs::decode_one_channel_into`).
**Tracking issue:** [#71](https://github.com/jasonherald/slowrx.rs/issues/71).

### What we observe

When decoding real-radio Robot 36 audio (verified against the 12 ARISS
Fram2 WAVs during V2.2 Phase 5), our output PNGs exhibit faint vertical
squiggle artifacts every ~20–30 pixels. The image content is correct
and recognizable — the artifacts are a fine pattern overlaid on the
content.

The reference JPGs ARISS publishes alongside the WAVs do NOT show these
artifacts, which means whatever decoder produced the references handles
this case better than ours.

### Why this isn't a "deviation" yet

This is more of an **open quality gap** than a deliberate deviation.
We're tracking it here so future audits know it's a known and
documented behavior, not a missed bug. V2.2 ships with this gap because
the image content is correct and visually validates against the
reference.

### When to revisit

When [#71](https://github.com/jasonherald/slowrx.rs/issues/71) is
prioritized, or whenever a downstream consumer asks for cleaner output.
The investigation paths in #71 cover SNR-adaptive Hann selector
re-engagement (V1 deferral #44), slowrx C cross-validation, and per-
pixel sub-bin interpolation.

---

## SNR hysteresis on adaptive Hann window selection

**Files:** `src/snr.rs::window_idx_for_snr_with_hysteresis` ↔ slowrx `video.c:354-367`.
**Tracking issue:** [#71](https://github.com/jasonherald/slowrx.rs/issues/71).
**Shipped in:** 0.3.2.

### What slowrx does

slowrx C selects the per-pixel Hann window length using pure-threshold
logic on the SNR estimate (`video.c:354-367`):

```c
if      (!Adaptive)  WinIdx = 0;
else if (SNR >=  20) WinIdx = 0;
else if (SNR >=  10) WinIdx = 1;
else if (SNR >=   9) WinIdx = 2;
else if (SNR >=   3) WinIdx = 3;
else if (SNR >=  -5) WinIdx = 4;
else if (SNR >= -10) WinIdx = 5;
else                 WinIdx = 6;
```

No hysteresis. When SNR fluctuates near a threshold (e.g., real-radio
SNR oscillating ±0.5 dB across the 9 dB boundary between `WinIdx=2` and
`WinIdx=3`), the selector flips every SNR re-estimation cadence (5.8 ms
wall-clock).

### What we do

We use the same threshold table but apply a 1 dB hysteresis band at
each transition. The function `window_idx_for_snr_with_hysteresis(snr_db,
prev_idx)` ratchets one band per call toward
`window_idx_for_snr(snr_db)`, applying a 0.5 dB hysteresis at the
adjacent boundary:

1. Compute `baseline = window_idx_for_snr(snr_db)`. If it equals
   `prev_idx` the SNR is in `prev_idx`'s band — return immediately.
2. Pick `target_idx` one band closer to `baseline` than `prev_idx`.
3. Re-evaluate `window_idx_for_snr` at `snr_db ± 0.5` (away from
   `target_idx`).
4. If the shifted lookup confirms the SNR is past `target_idx`'s side
   of the boundary, accept `target_idx`. Otherwise stay at `prev_idx`.

Per-pixel FFTs converge in O(`n_bands`) calls. Ratcheting one step at
a time (rather than jumping straight to `baseline`) keeps the selector
convergent even when `prev_idx` is far from `baseline` — e.g.
cold-start at idx 6 with a strong signal — without breaking the 1 dB
hysteresis guarantee at any individual boundary.

### Why we deviated

V2.2 Phase 5 visual validation against the 12 ARISS Fram2 R36 reference
WAVs revealed faint vertical squiggle artifacts every ~20–30 pixels in
the decoded PNGs. The squiggle period (5.8 ms of audio at R36 Y's
0.275 ms/pixel cadence ≈ 21 px) matches the SNR re-estimation cadence
exactly. A code-only audit (#71) found the DSP otherwise arithmetically
equivalent to slowrx C. Hypothesis: real-radio SNR fluctuates near a
window-selection threshold, the selector flip-flops, and that produces
periodic vertical banding.

slowrx C exhibits the same algorithmic property (no hysteresis) and
would in principle produce the same artifact at its 5.8 ms cadence.
The reference JPGs ARISS published were almost certainly decoded by a
different tool (MMSSTV or RX-SSTV in the ARISS community) that either
uses a fixed window or has hysteresis.

The 1 dB band is small enough that real SNR changes still propagate
quickly (≥ 0.5 dB past threshold = one cadence delay max), and large
enough that typical real-radio fluctuation (0.5–1.5 dB) doesn't cause
flip-flop.

### When to revisit

Three triggers would prompt revisiting:

1. **Empirical Fram2 validation shows the squiggles persist or worsen
   after this hysteresis lands.** Re-run the procedure at
   [`tests/ariss_fram2_validation.md`]../tests/ariss_fram2_validation.md
   and compare visually against the V2.2 baseline. Persistence means
   hysteresis isn't the root cause; move on to other paths in #71
   (sub-pixel FFT interpolation, resampler quality).
2. **Decode quality regresses at SNR edges** (e.g., images with
   alternating bands of high and low SNR show banding at the band
   boundaries because hysteresis is filtering legitimate SNR changes).
   Tune the band size or switch to a debouncer/smoother strategy.
3. **A future audit cross-validates pixel-by-pixel against slowrx C
   output on the same WAV** and finds slowrx's pure-threshold behavior
   matters in some specific way. Unlikely but possible.

---

## FFT frequency resolution exceeds slowrx C by 4×

**Files:** `src/snr.rs::FFT_LEN`, `src/mode_pd.rs::FFT_LEN` ↔ slowrx `video.c::FFTLen`.
**Tracking issue:** [#71](https://github.com/jasonherald/slowrx.rs/issues/71) (squiggle context).
**Shipped in:** 0.3.3.

### What slowrx does

slowrx C uses `FFTLen = 1024` at `44_100` Hz, giving
`44100 / 1024 ≈ 43.07` Hz/bin frequency resolution for the per-pixel
demod and SNR estimator (`video.c:303-340, 369-395`).

### What we do

We use `FFT_LEN = 1024` at [`crate::resample::WORKING_SAMPLE_RATE_HZ`]
= `11_025` Hz, giving `11025 / 1024 ≈ 10.77` Hz/bin —
**4× finer than slowrx C**.

The bump produces two coupled DSP changes:

1. **Per-pixel demod (`mode_pd::PdDemod::pixel_freq`)**: 4× finer
   bin density only. `HANN_LENS` is unchanged at
   `[12, 16, 24, 32, 64, 128, 256]` (slowrx's
   `[48, 64, 96, 128, 256, 512, 1024]` divided by 4) so the Hann is
   applied to the first `HANN_LENS[idx]` samples of the FFT input
   and the rest is zero-padded — time-domain support identical to
   slowrx C, only the FFT bin density changes.
2. **SNR estimator (`SnrEstimator::estimate`)**: the long Hann window
   `hann_long = build_hann(FFT_LEN)` scales with `FFT_LEN`, so it
   grows from 256 samples (~23 ms at 11_025 Hz, matching slowrx C) to
   1024 samples (~93 ms, 4× longer than slowrx C). The SNR estimator
   therefore integrates over a 4× longer time window. This is a
   second, real deviation that comes "for free" with the FFT_LEN
   bump and is desirable: the longer integration produces a cleaner
   SNR estimate, which in turn reduces flip-flop in the
   adaptive-Hann selector beyond what the 0.3.2 hysteresis already
   delivers.

Both effects were validated together on the parallel experiment
branch and contribute to the "WAY clearer" visual finding on the
12 ARISS Fram2 R36 reference WAVs. We do not attempt to decouple
them — the longer SNR-estimator window is part of the package, not
a regression to mitigate.

### Why we deviated

0.3.2 shipped a 1 dB SNR hysteresis band as a partial fix for the
real-radio squiggle artifacts ([#71]). Hysteresis reduced but didn't
eliminate the squiggles. While CodeRabbit reviewed PR #74, a
parallel experiment branch tested bumping `FFT_LEN` to 1024. Result
on the 12 ARISS Fram2 R36 reference WAVs: synthetic round-trips all
passed at the unchanged `mean < 5.0` threshold, and visual inspection
showed **noticeably clearer pixel content** vs. the 0.3.2 baseline
(by-eye comparison; the user judged it "WAY clearer").

The squiggle artifacts themselves were unchanged — that's a separate
concern tracked in [#71]. The finer Hz/bin is a complementary DSP
improvement that's worth shipping on its own.

[#71]: https://github.com/jasonherald/slowrx.rs/issues/71

### When to revisit

1. **Squiggle root cause turns out to require coarser FFT.** Unlikely
   given the 0.3.2 hypothesis (SNR-cadence flip-flop) was unaffected
   by FFT_LEN. But if a future audit finds an FFT-resolution-dependent
   artifact, this is the knob.
2. **CPU cost becomes an issue.** The 4× FFT compute per call is
   negligible at SSTV's per-pixel cadence. If a future profile shows
   the per-pixel FFT dominating wall-clock time on resource-constrained
   targets, consider reverting or adding a `cli`-feature-gated coarse
   mode.
3. **A future audit cross-validates pixel-by-pixel against slowrx C
   output** and finds slowrx's `usize` bin counts matter in some
   specific way. Unlikely — bandwidth integration is in Hz domain —
   but possible.