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) — resamplesfs -> bwwith 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:
- 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. - Decode —
_decode_atruns 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. - 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. - Give-up — a pending packet is abandoned after
max_packet_symssymbols of data have arrived without completing. - 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:
- 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.
- Buffer — a candidate window (
run_start - 0.1 s..run_start + 1.5 s) is kept pending behind the scan frontier. - Decode — the candidate is decoded with
GrFrameSync(_decode_gr) as soon as its window is fully buffered; thesfo_ppmsweep (default'auto') runs per candidate. - 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.