Skip to content

Streaming decode

LoRaDecoder.decode_stream feeds IQ in arbitrary-sized chunks. Packets may straddle chunk boundaries and the internal buffer stays bounded, so it is safe for live SDR feeds and for streaming multi-GB files (which decode_file does internally).

Both sync algorithms stream:

  • xhonneux (default) — resamples fs -> bw with the stateful FIR decimator and runs the streaming scan at the Nyquist rate.
  • gr-lora-sdr — runs its native-rate dechirp run-detection (_GrFrameStream), no resampling; each detected preamble run is decoded from a buffered window as soon as the window is complete.

Stateful resampling (xhonneux)

The xhonneux path cannot use the offline zero-phase decimation (it needs the whole signal). Instead _StatefulDecimator:

  • uses a causal linear-phase FIR anti-alias filter (matching scipy.signal.decimate(ftype='fir')), carrying the filter state across chunks;
  • compensates the fixed group delay once at the stream start;
  • tracks the downsampling phase globally across chunks, so an odd-length chunk does not flip the decimation grid.

The output is bit-identical to feeding the whole buffer at once, for any chunk size — this is covered by the test_resample_equivalence test.

The scan loop (xhonneux)

Inside the decoder the signal lives in a buffer (at BW rate). A monotonic scan frontier walks forward in one-symbol steps:

  1. Gate — a cheap pre-filter (_gate) FFTs three consecutive windows and requires them to peak in nearly the same bin with a sufficient peak-to-median ratio. Pure noise rarely satisfies this, so the expensive full 3-stage sync only runs near real preambles.
  2. Decode_decode_at runs the Xhonneux decode on the buffer from the scan position. Because the sync is sensitive to the slice start, it re-slices exactly at the detected preamble boundary and prefers the cleaner result.
  3. Truncation — if sync succeeds but the packet's declared span (sample_end) does not fit in the buffer yet, the packet is pending: the scan holds position and waits for more data, retrying on every chunk.
  4. Give-up — a pending packet is abandoned after max_packet_syms symbols of data have arrived without completing.
  5. Emit — a complete packet is emitted with absolute stream positions, and the scan jumps to the packet end.

Run detection (gr-lora-sdr)

The gr-lora-sdr path streams at the native fs (no resampling). _GrFrameStream keeps a bounded rolling buffer and advances a scan frontier in one-symbol (NSYM = N·fs/bw) steps:

  1. Detect — each window is dechirped and its peak-to-median ratio and bin tracked. A run of ≥ 4 consecutive windows dechirping to nearly the same bin with mean ratio ≥ 5.0 marks a preamble.
  2. Buffer — a candidate window (run_start - 0.1 s .. run_start + 1.5 s) is kept pending behind the scan frontier.
  3. Decode — the candidate is decoded with GrFrameSync (_decode_gr) as soon as its window is fully buffered; the sfo_ppm sweep (default 'auto') runs per candidate.
  4. Flush — at flush() any candidate whose window ran past end-of-stream is decoded against whatever was actually captured.

Bounded memory

The buffer is compacted when a packet is emitted, and periodically as the scan advances (_maybe_compact), so long stretches of idle noise — or a multi-GB file — do not accumulate in memory. Peak usage is roughly O(chunk + scan window + pending packet).

Absolute positions

Packet.sample_start, payload_start and sample_end are reported absolute within the stream (or file): at the decoder bandwidth rate for xhonneux and at the native fs for gr-lora-sdr. time_start_sec is sample_start / bw for xhonneux and sample_start / fs for gr-lora-sdr.

Lifecycle

decoder = LoRaDecoder(sf=10, bw=125_000, fs=250_000, fc=437e6)

for chunk in stream():
    for p in decoder.decode_stream(chunk):
        handle(p)

tail = decoder.flush()     # decode whatever is left, then reset state
decoder.reset()            # optional: start a fresh transmission

After flush() the state is reset automatically; reset() can be called at any time to discard buffered data.