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
BWHz for a timeT = 2^SF / BW, so its time-bandwidth product is2^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 ]
- Preamble —
PREAMBLE_LEN = 8copies 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 = 2down-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).
- ASCII-encode & truncate.
data = text.encode("ascii","ignore")[:255]— non-ASCII bytes are dropped, payload capped at 255 bytes. - CRC-8.
payload = list(data) + [_crc8(list(data))]— one CRC-8 byte (poly 0x07, init 0) appended over the data bytes only (§6.4). - Build the header block from the single length byte
[len(data)]with whitening offsetpn_off = 0(hsyms = block([len(data)], 0)). - Build the payload block from
payload(data + CRC) withpn_off = 1(psyms = block(payload, 1)).
Each block(content, pn_off) performs, in order:
- Whiten + Hamming-encode (
_bytes_to_codewords): derive the PN sequencepn = _pn(len(content)+pn_off)[pn_off:]; for each content byteb, whitenw = 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. - Block-interleave (
_interleave): withccodewords, emit a bit list of length8cin column-major bit-plane order —bits[j*c + i] = (cw[i] >> j) & 1for bit-planej = 0..7, codewordi = 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. - Pack into SF-bit symbols (
_pack_symbols): group the bit list intoSF-bit values, MSB-first, zero-padding the final partial symbol. - Gray-encode (
_gray): each packed valuesbecomes_gray(s & (n−1)) = s ^ (s>>1)— the single-XOR binary→Gray map applied on the transmit side (the mask ton−1is redundant since values are already< 2^SF).
Back in modulate, assemble and radiate:
- Frame assembly:
PREAMBLE_LENcopies of_sym(0), thenSFD_LENdown-chirps, then_sym(s)for every header symbol followed by every payload symbol. - 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. - Pad, scale, quantise: prepend/append
spszeros, thenround(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:
- Down-convert to baseband: multiply the segment by
exp(−2πj·center·m/sr)— undo the 1500 Hz up-conversion. - Matched filter: cross-correlate the baseband segment against the known reference template
ref = [up]·8 + [down]·2(the preamble + SFD), usingscipy.signal.correlate(..., "valid", "fft")(with a NumPynp.correlatefallback). - Peak test:
pk = argmax(|corr|); accept only ifcorr[pk] ≥ max(6.0·median(|corr|), 0.10·ref_energy²), whereref_energy = ‖ref‖₂. A weak peak advances the cursor byspsand retries. This is a dual threshold: 6× the local median (relative) OR 10 % of the reference energy squared (absolute floor). data_start = pk + len(ref)— the header begins immediately after the matched preamble + SFD.
5.3 Header, then payload
- Header decode:
_block_bytes(bb, data_start, hsyms, 2, pn_off=0)withhsyms = ceil(16/sf)symbols and 2 codewords. If Hamming decoding fails (any codeword had ≥2 bit errors →None), advance past this peak and rescan. Otherwiseplen = hdr[0]is the payload length. - Payload geometry:
ncw = (plen+1)·2codewords (payload =plendata bytes + 1 CRC byte, 2 codewords/byte);psyms = ceil(ncw·8 / sf)payload symbols;p_start = data_start + hsyms·sps. - Payload decode:
_block_bytes(bb, p_start, psyms, ncw, pn_off=1). - Validate & emit: accept the frame only if the decoded content has length
plen+1and_crc8(content[:plen]) == content[plen]. On success, emit the text viaon_text, keeping only printable ASCII plus CR/LF (32 ≤ b < 127 or b in (10,13)). - Advance the cursor past the consumed frame (or past a failed peak) and slide the buffer, retaining ~
spssamples of context.
5.4 Per-block symbol demod (the reverse data path)
_block_bytes(bb, start, nsyms, ncw, pn_off):
- Demodulate
nsymssymbols with_symbol(§3.3): dechirp + alias-folded FFTargmax+_ungray._ungrayis the cumulative Gray→binary inverse of_gray(repeatedly XOR the value by its right-shifts).
- Unpack bits (
_unpack_bits): expand each symbol intoSFbits MSB-first; keep the firstncw·8bits. - De-interleave (
_deinterleave): invert the column-major mapping back intoncw8-bit codewords:cw[i] |= bit(j*c+i) << j. - Hamming-decode + de-whiten (
_codewords_to_bytes): for each codeword pair,_H84_DEC[cw]returns the corrected nibble or−1(≥2 errors); any−1fails the whole block (returnsNone). 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
loracssframe will not decode on a LoRa/Meshtastic radio, and the real-LoRa PHY inapp/radio/lorasdr/will not decodeloracss. 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
- Semtech, LoRa Modulation Basics (AN1200.22) — the CSS cyclic-shift-keying idea this mode borrows and narrows to audio bandwidth.
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 toloracss.docs/lora.md— the "two separate LoRa implementations" overview and why they are kept apart.docs/codec-sdk.md— the codec SDKloracssself-registers into, and the source of the −9.6 dB / −12.7 dB sensitivity figures.- R. W. Hamming, "Error detecting and error correcting codes," Bell Syst. Tech. J., 1950 — the (8,4) SECDED code (extended Hamming with overall parity).
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.