Patrick Lidstone
Self-hosted

loracss: an audio-band chirp-spread-spectrum text mode for the SSB passband

**A narrowband LoRa-family CSS modem that fits a ~1.5 kHz SSB channel — whitening

  • Hamming(8,4) SECDED FEC + block interleaving + CRC-8, carried on cyclic-shift chirps and recovered with a dechirp / alias-folded FFT.**

Rafe project · app/radio/loracss.py, registered by app/radio/codecs/lora_css.py · complete, in use


Abstract

loracss is a self-contained weak-signal keyboard text mode built entirely in-repo: pure NumPy at runtime, no external binary, no SDR. It borrows the idea that makes Semtech's LoRa robust — chirp spread spectrum (CSS) with data encoded as the cyclic start-offset of a linear chirp — and shrinks it to fit an ordinary single-sideband audio passband so it can be keyed through an IC-705 (or any SSB radio) exactly like RTTY, PSK31 or the WSJT-X modes. A symbol is one up-chirp swept over BW Hz around a 1500 Hz audio centre; the chirp's cyclic shift carries SF raw bits (2^SF possible shifts). The transmit data path mirrors LoRa's — CRC-8 → Hamming(8,4) forward error correction per nibble → block interleave → whiten (LFSR PN) → Gray → pack into SF-bit symbols — and the receiver reverses it after a preamble/SFD matched-filter frame sync and a dechirp + alias-folded FFT symbol demodulator (S.reshape(os, n).sum(axis=0)). Three selectable SF/BW sub-modes trade speed for processing gain: loracss-fast (SF7 / 2 kHz), loracss (SF8 / 1.5 kHz, the default), and loracss-robust (SF10 / 1 kHz). It self-registers as three codecs in the project's codec SDK, so it appears in the mode picker and is wired into the RX/TX audio paths automatically. The chirp core is verified to recover symbols with zero errors in loopback, and with additive noise the FEC buys roughly 4 dB: the SF8 mode decodes to about −9.6 dB and the SF10 mode to about −12.7 dB wideband SNR. It is deliberately not Semtech-interoperable — real LoRa is ≥125 kHz in the UHF ISM bands and lives in a completely separate SDR subsystem — and that separation is a design choice, not a limitation of the idea.


1. Background

1.1 Chirp spread spectrum, and why it is robust

A linear chirp sweeps its instantaneous frequency across a bandwidth BW during one symbol. Chirp spread spectrum (CSS) modulation encodes data not in the chirp's shape but in its cyclic start offset: every symbol is the same base up-chirp rotated cyclically, and the amount of rotation is the data. This is the scheme Semtech's LoRa popularised, and it is attractive for weak-signal work for two reasons:

  • Processing gain. A symbol occupies BW Hz for a time T = 2^SF / BW, so its time-bandwidth product is 2^SF. The receiver correlates the whole chirp against a reference (the "dechirp"), collapsing that spread energy into a single narrow spectral bin. The coherent gain is ~10·log10(2^SF) dB — about 24 dB at SF8, 30 dB at SF10 — which is why CSS decodes well below the noise floor.
  • A cheap, robust demodulator. Because the data is a cyclic shift, a single FFT after dechirping reads out the symbol as the position of the spectral peak. No per-symbol phase tracking, no equaliser; frequency offset shows up as a predictable bin shift, and timing offset as a controlled leakage the folding step (§5.1) tolerates.

1.2 Fitting CSS into an audio passband

Real LoRa needs ≥125 kHz of RF bandwidth. An SSB voice channel gives you roughly 300–3000 Hz of audio. loracss keeps the CSS idea but narrows the sweep to BW ≈ 1–2 kHz and centres it at 1500 Hz, so the whole signal lives inside the radio's normal SSB passband and rides the same audio TX/RX chain as every other keyboard mode. The price is throughput — a symbol lasts tens of milliseconds to a second, so this is a slow text mode, not a data link — but the payoff is that CSS's sub-noise robustness becomes available to anyone with an SSB radio and a sound device, with no SDR and no wide channel.

1.3 Relationship to the real (Semtech) LoRa in this repo

Rafe contains two independent LoRa implementations, deliberately kept apart (see ../lora.md):

Audio-band CSS (this doc) Real Semtech LoRa
Module app/radio/loracss.py app/radio/lorasdr/
Spec this document lora-phy.md (PHY), lora-modem.md (IQ modem)
Path IC-705 SSB audio, 300–3000 Hz LimeSDR wideband IQ, UHF ISM
Bandwidth ~1–2 kHz (audio) 125–500 kHz (RF)
Interoperable with commercial LoRa? No Yes (validated interop-correct)
In the codec SDK / mode picker? Yes (loracss*) No (SDR subsystem)

They share only the idea of chirp spread spectrum; every wire-format detail differs. The Semtech PHY (app/radio/lorasdr/phy.py) uses a diagonal interleaver, a variable-rate Hamming code (CR 4/5…4/8), an explicit header with a GF(2) checksum matrix, a fixed whitening table, a CRC-16 (poly x¹⁶+x¹²+x⁵+1), LDRO, and Gray on a byte-quarter grid. loracss uses a column-major block interleaver, one fixed-rate Hamming(8,4) SECDED code, an LFSR-generated whitening sequence, a CRC-8 (poly 0x07), and plain single-XOR Gray. A loracss frame will not decode on a commercial LoRa receiver and vice-versa. Do not confuse the two.


2. Signal structure

2.1 Sub-modes (SF / BW)

Three sub-modes are registered (app/radio/codecs/lora_css.py). Higher SF and lower BW mean slower symbols but more processing gain. BW must divide both sample rates in the chain (12 kHz RX, 48 kHz TX):

codec id SF BW (Hz) bins n=2^SF symbol time 2^SF/BW audio band (centre ± BW/2) trade-off
loracss-fast 7 2000 128 64 ms 500–2500 Hz fastest
loracss (default) 8 1500 256 170.7 ms 750–2250 Hz balanced
loracss-robust 10 1000 1024 1.024 s 1000–2000 Hz slowest, most sensitive

The module-level defaults are SF = 8, BW = 1500.0, CENTER = 1500.0 (app/radio/loracss.py). The audio centre is 1500 Hz for all sub-modes; the chirp sweeps ±BW/2 around it, and every band above lies inside the 300–3000 Hz SSB passband.

Approximate net payload throughput (derived: 2 codewords/byte × 8 chirp-bits = 16 chirp-bits/payload-byte; chirp-bit rate = SF·BW / 2^SF): ~2.9 B/s at SF8, ~6.8 B/s at SF7, ~0.6 B/s at SF10 — i.e. a few characters per second, appropriate for a weak-signal text mode.

2.2 Sample rates and oversampling

The modulator emits 48 kHz s16le PCM; the streaming decoder consumes 12 kHz s16le PCM (tx_rate=48000, rx_rate=12000, app/radio/codecs/lora_css.py). The app decimates the 48 k RX audio by 4 before feeding the decoder (the test drives it with sig[::4]).

Per-mode geometry is computed by _cfg(sr, sf, bw) (app/radio/loracss.py):

n  = 1 << sf                      # number of chips / FFT bins = 2^SF
os = max(1, round(sr / bw))       # oversampling factor
sps = n * os                      # samples per symbol
mode RX (12 kHz): n, os, sps TX (48 kHz): n, os, sps
SF7 / 2000 128, 6, 768 128, 24, 3072
SF8 / 1500 256, 8, 2048 256, 32, 8192
SF10 / 1000 1024, 12, 12288 1024, 48, 49152

Note that the baseband chirp is identical in shape at both rates; only os (and hence the number of samples) changes. The symbol duration sps/sr = 2^SF/BW is rate-independent.

2.3 Frame

[ 8 preamble up-chirps ][ 2 SFD down-chirps ][ header block ][ payload block ]
  • PreamblePREAMBLE_LEN = 8 copies of the un-shifted base up-chirp (_sym(0, up, os)), used by the receiver's matched filter to find the frame.
  • SFD (start-of-frame delimiter)SFD_LEN = 2 down-chirps (conj(up)); the up-then-down transition gives the matched filter a sharp, unambiguous correlation peak and marks where data begins.
  • Header block — the single-byte payload length, run through the full data path with whitening offset 0. It occupies _hdr_syms(sf) = ceil(16/sf) symbols (2 symbols at SF8/SF10, 3 at SF7): one length byte → 2 Hamming codewords → 16 chirp-bits.
  • Payload block — the ASCII text (≤255 bytes) followed by its CRC-8, run through the data path with whitening offset 1.

The whole baseband frame is up-converted to a 1500 Hz real passband signal, padded front and back with sps zero samples, scaled by 0.6, clipped and quantised to s16le (app/radio/loracss.py).

Each data symbol is one base up-chirp cyclically rotated by the symbol value:

_sym(s, up, os) = np.roll(up, -s * os)          # app/radio/loracss.py

so symbol value s ∈ [0, n) shifts the chirp by s·os samples (one "chip" = os samples), giving n = 2^SF distinguishable symbols.


3. The chirp core

3.1 Base up-chirp

_base_bb(n, os) (app/radio/loracss.py) builds the complex baseband up-chirp of length sps = n·os:

m    = arange(n*os)
chip = m / os
up   = exp( 1j · 2π · cumsum( (chip/n − 0.5) / os ) )

The per-sample phase increment is 2π·(chip/n − 0.5)/os, so the instantaneous normalised frequency sweeps linearly from −0.5/os to +0.5/os cycles/sample over the symbol. At sample rate sr ≈ os·bw, that span is sr/os ≈ BW Hz — the chirp sweeps a bandwidth of BW, from −BW/2 to +BW/2 at baseband. The down-chirp is its conjugate, down = conj(up).

3.2 Modulation (cyclic-shift keying)

Symbol s is np.roll(up, −s·os): a cyclic rotation of the base up-chirp. Because the chirp is periodic in frequency, a cyclic time-shift is equivalent to a cyclic frequency start-offset — the LoRa scheme. There are exactly n shifts on an os- sample grid.

3.3 Demodulation (dechirp + alias-folded FFT)

The receiver recovers a symbol with _symbol (app/radio/loracss.py):

S      = fft( bb[a : a+sps] · down )            # dechirp then transform
folded = S.reshape(os, n).sum(axis=0)           # fold the os oversampled images
sym    = _ungray( argmax(|folded|) )            # peak bin -> Gray-decode

Dechirp. Multiplying the received window by the down-chirp cancels the linear sweep, turning the cyclic-shifted up-chirp into a pure complex sinusoid whose frequency is proportional to the shift s.

Alias-folded FFT. The FFT is length sps = n·os, i.e. os× oversampled. A symbol's energy appears not in one bin but in a comb of os images spaced n bins apart. S.reshape(os, n) lays the n·os bins out as os rows of n, and .sum(axis=0) adds bins {c, c+n, c+2n, …, c+(os−1)n} into a single length-n spectrum — coherently combining the oversampled images so the symbol shows up as one sharp peak at bin c = s. argmax(|folded|) reads out the raw (Gray-coded) symbol; _ungray converts it back to data bits. This folding is what lets the demod use a long, noise-robust FFT while still resolving to the n-bin symbol grid, and it tolerates the sub-chip timing error that oversampling introduces.

The chirp core is annotated # ---- chirp core (verified: 0/N symbol errors) ---- (app/radio/loracss.py); test_css_symbol_core confirms exact recovery for representative symbols across the full n = 256 alphabet (§7).


4. Encode — step by step

Reference: modulate() (app/radio/loracss.py) and its inner block() helper.

Input. modulate(text, sr=48000, sf=8, bw=1500, center=1500).

  1. ASCII-encode & truncate. data = text.encode("ascii","ignore")[:255] — non-ASCII bytes are dropped, payload capped at 255 bytes.
  2. CRC-8. payload = list(data) + [_crc8(list(data))] — one CRC-8 byte (poly 0x07, init 0) appended over the data bytes only (§6.4).
  3. Build the header block from the single length byte [len(data)] with whitening offset pn_off = 0 (hsyms = block([len(data)], 0)).
  4. Build the payload block from payload (data + CRC) with pn_off = 1 (psyms = block(payload, 1)).

Each block(content, pn_off) performs, in order:

  1. Whiten + Hamming-encode (_bytes_to_codewords): derive the PN sequence pn = _pn(len(content)+pn_off)[pn_off:]; for each content byte b, whiten w = b ^ pn[i], then split into two nibbles and Hamming(8,4)-encode each — high nibble first: cw += [_H84_ENC[(w>>4)&0xF], _H84_ENC[w&0xF]]. Result: 2 codewords (8 bits each) per content byte.
  2. Block-interleave (_interleave): with c codewords, emit a bit list of length 8c in column-major bit-plane orderbits[j*c + i] = (cw[i] >> j) & 1 for bit-plane j = 0..7, codeword i = 0..c−1. All the LSBs come first, then all bit-1s, and so on. This scatters the 8 bits of any one codeword across the symbol stream so a single corrupted chirp symbol hits at most one bit per codeword — within Hamming(8,4)'s single-error-correcting reach.
  3. Pack into SF-bit symbols (_pack_symbols): group the bit list into SF-bit values, MSB-first, zero-padding the final partial symbol.
  4. Gray-encode (_gray): each packed value s becomes _gray(s & (n−1)) = s ^ (s>>1) — the single-XOR binary→Gray map applied on the transmit side (the mask to n−1 is redundant since values are already < 2^SF).

Back in modulate, assemble and radiate:

  1. Frame assembly: PREAMBLE_LEN copies of _sym(0), then SFD_LEN down-chirps, then _sym(s) for every header symbol followed by every payload symbol.
  2. Up-convert to passband: pb = real( bb · exp(2πj·center·m/sr) ) — shift the complex baseband frame up to the 1500 Hz audio centre and take the real part.
  3. Pad, scale, quantise: prepend/append sps zeros, then round(clip(out·0.6, −1, 1) · 32767) as little-endian int16 → bytes. The 0.6 factor leaves headroom.

5. Decode — step by step

Reference: class Decoder (app/radio/loracss.py).

5.1 Streaming buffer

feed(pcm) accepts s16le bytes (carrying a spare odd byte between calls in self._carry) or a float array, normalises to float in [−1, 1), appends to self._buf, and calls _try_frames(). The decoder is fed mono s16le at sr = 12000.

5.2 Frame sync — matched filter over the preamble + SFD

_try_frames works while at least len(ref) + 3·sps samples are buffered past a moving cursor self._phase0:

  1. Down-convert to baseband: multiply the segment by exp(−2πj·center·m/sr) — undo the 1500 Hz up-conversion.
  2. Matched filter: cross-correlate the baseband segment against the known reference template ref = [up]·8 + [down]·2 (the preamble + SFD), using scipy.signal.correlate(..., "valid", "fft") (with a NumPy np.correlate fallback).
  3. Peak test: pk = argmax(|corr|); accept only if corr[pk] ≥ max(6.0·median(|corr|), 0.10·ref_energy²), where ref_energy = ‖ref‖₂. A weak peak advances the cursor by sps and retries. This is a dual threshold: 6× the local median (relative) OR 10 % of the reference energy squared (absolute floor).
  4. data_start = pk + len(ref) — the header begins immediately after the matched preamble + SFD.

5.3 Header, then payload

  1. Header decode: _block_bytes(bb, data_start, hsyms, 2, pn_off=0) with hsyms = ceil(16/sf) symbols and 2 codewords. If Hamming decoding fails (any codeword had ≥2 bit errors → None), advance past this peak and rescan. Otherwise plen = hdr[0] is the payload length.
  2. Payload geometry: ncw = (plen+1)·2 codewords (payload = plen data bytes + 1 CRC byte, 2 codewords/byte); psyms = ceil(ncw·8 / sf) payload symbols; p_start = data_start + hsyms·sps.
  3. Payload decode: _block_bytes(bb, p_start, psyms, ncw, pn_off=1).
  4. Validate & emit: accept the frame only if the decoded content has length plen+1 and _crc8(content[:plen]) == content[plen]. On success, emit the text via on_text, keeping only printable ASCII plus CR/LF (32 ≤ b < 127 or b in (10,13)).
  5. Advance the cursor past the consumed frame (or past a failed peak) and slide the buffer, retaining ~sps samples of context.

5.4 Per-block symbol demod (the reverse data path)

_block_bytes(bb, start, nsyms, ncw, pn_off):

  1. Demodulate nsyms symbols with _symbol (§3.3): dechirp + alias-folded FFT
    • argmax + _ungray. _ungray is the cumulative Gray→binary inverse of _gray (repeatedly XOR the value by its right-shifts).
  2. Unpack bits (_unpack_bits): expand each symbol into SF bits MSB-first; keep the first ncw·8 bits.
  3. De-interleave (_deinterleave): invert the column-major mapping back into ncw 8-bit codewords: cw[i] |= bit(j*c+i) << j.
  4. Hamming-decode + de-whiten (_codewords_to_bytes): for each codeword pair, _H84_DEC[cw] returns the corrected nibble or −1 (≥2 errors); any −1 fails the whole block (returns None). Reassemble bytes (hi<<4)|lo, then de-whiten with the same PN slice used on encode: out[i] ^ pn[i].

6. Constants & tables (all exact)

6.1 Frame / mode constants (app/radio/loracss.py)

SF           = 8            # default spreading factor
BW           = 1500.0       # default bandwidth, Hz
CENTER       = 1500.0       # audio centre, Hz (all sub-modes)
PREAMBLE_LEN = 8            # up-chirps
SFD_LEN      = 2            # down-chirps

Sub-mode table (id, label, SF, BW): loracss-fast SF7/2000, loracss SF8/1500, loracss-robust SF10/1000 (app/radio/codecs/lora_css.py). rx_rate=12000, tx_rate=48000. Output amplitude scale = 0.6; payload cap = 255 bytes.

6.2 Hamming(8,4) SECDED (app/radio/loracss.py)

Systematic (8,4) code, minimum distance 4 (single-error-correcting, double-error-detecting). For data nibble d = d3 d2 d1 d0:

p1 = d0 ^ d1 ^ d3
p2 = d0 ^ d2 ^ d3
p3 = d1 ^ d2 ^ d3
p0 = d0 ^ d1 ^ d2 ^ d3 ^ p1 ^ p2 ^ p3          # overall parity -> distance 4
codeword bit layout (LSB..MSB): [ d0 d1 d2 d3 p1 p2 p3 p0 ]

The encode table _H84_ENC[d] (verified from the running module):

d 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
cw 0x00 0xB1 0xD2 0x63 0xE4 0x55 0x36 0x87 0x78 0xC9 0xAA 0x1B 0x9C 0x2D 0x4E 0xFF

The decode table _H84_DEC[0..255] is built by nearest-neighbour: for each received byte, pick the data nibble whose codeword is at Hamming distance ≤ 1, else −1 (fail). Distance-0/1 (0 or 1 bit-error) → corrected nibble; distance ≥ 2 → −1, which fails the enclosing block. So the code corrects any single-bit error and detects (rejects) two-bit errors per codeword.

6.3 Whitening — LFSR PN sequence (app/radio/loracss.py)

8-bit LFSR, seed 0xFF, feedback polynomial x⁸+x⁶+x⁵+x⁴+1. Each step outputs the current state byte before updating:

out.append(s)
b = ( (s>>0) ^ (s>>2) ^ (s>>3) ^ (s>>4) ) & 1        # tap bits 0,2,3,4
s = ( (s>>1) | (b<<7) ) & 0xFF                        # shift right, feed b into MSB

First 16 PN bytes (verified from the module):

FF 7F 3F 1F 0F 87 43 A1 D0 E8 F4 7A 3D 1E 8F C7

Whitening is applied per content byte as w = byte ^ pn[i]. The header block uses pn_off = 0 (PN starting at index 0); the payload block uses pn_off = 1 (PN starting at index 1). The offset is _pn(len(content)+pn_off)[pn_off:], i.e. the same sequence sampled from a different start, decorrelating the two blocks.

6.4 CRC-8 (app/radio/loracss.py)

poly = 0x07  (x^8 + x^2 + x + 1),  init = 0x00,  no reflection, no final XOR (MSB-first)

This is CRC-8/SMBUS; check value _crc8(b"123456789") = 0xF4 (verified). Computed over the data bytes only and appended as the payload's last byte.

6.5 Gray code (app/radio/loracss.py)

_gray(x)   = x ^ (x >> 1)                     # binary -> Gray  (single XOR, TX side)
_ungray(x): m = x; while (x >>= 1): m ^= x; return m   # Gray -> binary (cumulative, RX side)

_gray maps the packed data symbol to the transmitted chirp offset; _ungray inverts it after the folded-FFT argmax. _ungray(_gray(x)) == x for all x ∈ [0,256) (verified). Gray-adjacency means an off-by-one bin error (the most likely demod slip) corrupts only a single bit.

6.6 Interleaver

Column-major block interleaver over c codewords of 8 bits: bit-plane j of all c codewords is emitted contiguously (bits[j*c+i] = (cw[i]>>j)&1, app/radio/loracss.py). Block size c: 2 codewords for the header (1 length byte), (plen+1)·2 for the payload. This is not LoRa's diagonal interleaver (§1.3).

6.7 Symbol packing

SF bits per symbol, MSB-first, zero-padded final symbol (_pack_symbols/_unpack_bits, app/radio/loracss.py). Header symbol count ceil(16/SF); payload symbol count ceil((plen+1)·2·8 / SF).


7. Interoperability & validation

loracss is validated by loopback and noise tests, not on-air interop (by design it has no external counterpart).

Chirp-core exactness (test_loracss.py, test_css_symbol_core). At SF8/1500 (n=256, os=8), for symbols s ∈ {0, 1, 42, 128, 255} the dechirp + alias-folded FFT recovers demod(_sym(s)) == s exactly — zero symbol errors across the low, mid and top of the 256-symbol alphabet. The source annotates the core as verified: 0/N symbol errors (app/radio/loracss.py), N = 2^SF = 256 at SF8.

Hamming(8,4) SECDED (test_loracss.py). The minimum distance over all 16 codewords is asserted = 4, and every single-bit flip of every codeword (d ∈ 0..15, bit ∈ 0..7) decodes back to the original nibble — confirming 1-bit-correct / 2-bit-detect.

Full FEC round-trip, all sub-modes (test_loracss.py). "CQ DE M0SUP" modulated and decoded end-to-end for (SF7,2000), (SF8,1500), (SF10,1000) at 12 kHz — the message is recovered in every mode.

Timing offset & streaming (test_loracss.py). The signal is prefixed with 999 samples of silence and fed in 1500-sample chunks; the matched-filter sync and the streaming buffer still recover the message across chunk boundaries and an arbitrary start offset.

Codec-SDK registration (test_loracss.py). After load_plugins(), all three ids resolve, carry category == "LoRa CSS", and are both RX- and TX-capable; each modulates at 48 kHz and decodes correctly after ÷4 decimation to 12 kHz.

Noise sensitivity (documented in ../codec-sdk.md). The FEC buys roughly 4 dB; measured decode thresholds are about −9.6 dB wideband SNR at SF8 and about −12.7 dB at SF10 — i.e. well below the noise floor, as CSS processing gain predicts.


8. Limitations

  • Not Semtech-interoperable. Every wire detail (interleaver geometry, FEC rate, whitening source, CRC width/poly, header format, Gray grid) differs from commercial LoRa. A loracss frame will not decode on a LoRa/Meshtastic radio, and the real-LoRa PHY in app/radio/lorasdr/ will not decode loracss. This is intentional — see §1.3 and ../lora.md.
  • Audio-band only. The signal is designed for a ~1–2 kHz SSB passband centred at 1500 Hz. It is a keyboard text mode; net throughput is a few characters per second (slower still at SF10). It is not a data link.
  • Fixed-rate FEC. One Hamming(8,4) rate-½ code (unlike LoRa's selectable CR 4/5…4/8). Correction is one bit per codeword; a symbol slip larger than the interleaver can spread, or ≥2 errors in a codeword, fails the block. The CRC-8 is the final integrity gate.
  • No CFO/STO estimator. Frame sync is a straight matched filter plus a folded-FFT argmax; there is no explicit carrier- or sample-timing-offset correction (the real LoRa modem has one — see lora-modem.md). Oversampling + folding absorb sub-chip timing error, and a small audio-frequency offset shows up as a bin shift the demod tolerates, but large offsets are not tracked.
  • 8-bit header. The payload length is a single byte, capping payloads at 255 bytes; the header carries only length and is protected by Hamming(8,4) alone (no header CRC), so a mis-corrected header is caught only downstream by the payload CRC-8.
  • Validation is loopback + additive-noise, not on-air; there is no fading-channel or real-radio-path characterisation in the test suite.

9. References

  1. Semtech, LoRa Modulation Basics (AN1200.22) — the CSS cyclic-shift-keying idea this mode borrows and narrows to audio bandwidth.
  2. docs/protocols/lora-phy.md / app/radio/lorasdr/phy.py — the real, Semtech-interoperable LoRa PHY in this repo (diagonal interleaver, CR 4/5–4/8 Hamming, header checksum, CRC-16, LDRO); the deliberate contrast to loracss.
  3. docs/lora.md — the "two separate LoRa implementations" overview and why they are kept apart.
  4. docs/codec-sdk.md — the codec SDK loracss self-registers into, and the source of the −9.6 dB / −12.7 dB sensitivity figures.
  5. R. W. Hamming, "Error detecting and error correcting codes," Bell Syst. Tech. J., 1950 — the (8,4) SECDED code (extended Hamming with overall parity).
  6. docs/rvqvoice.md — the depth/structure/tone template for this document.

10. Implementation & reproduction

file role
app/radio/loracss.py the whole modem: chirp core, FEC, interleaver, whitening, CRC, modulate() + streaming Decoder
app/radio/codecs/lora_css.py registers the three SF/BW sub-modes as codec-SDK TextCodecs
app/radio/codec_sdk.py the SDK (TextCodec, register, load_plugins) that auto-wires the modes into the UI and RX/TX paths
test_loracss.py tests: chirp core, Hamming SECDED, all-SF/BW round-trip, timing/streaming, registration

Runtime API (pure NumPy; SciPy only accelerates the matched filter, with a NumPy fallback):

from app.radio import loracss as C

pcm = C.modulate("CQ DE M0SUP", sr=48000, sf=8, bw=1500.0)   # -> s16le bytes @ 48 kHz

got = []
dec = C.Decoder(lambda ch: got.append(ch), sr=12000, sf=8, bw=1500.0)
dec.feed(pcm_bytes_at_12kHz)                                  # streaming; calls on_text per char

Or via the codec SDK (the way the app uses it):

from app.radio import codec_sdk
codec_sdk.load_plugins()
c = codec_sdk.get("loracss")                 # or "loracss-fast" / "loracss-robust"
audio = c.modulate("HELLO CSS", 48000)
c.make_decoder(on_text, 12000).feed(rx_s16le_12k)

To reimplement from scratch, follow §4 (encode) and §5 (decode) with the exact constants in §6; the chirp core in §3 is the only DSP, and everything else is bit manipulation over the tables above.