Feld Hell (Hellschreiber): a native on/off-keyed facsimile text mode
A from-scratch reproduction spec for the in-repo Feld Hell modem — a 7×14 bitmap font scanned column-by-column and on/off-keyed onto a single tone, then recovered to text by correlating the received ink raster against the same font.
Rafe project · app/radio/feld.py, app/radio/_tables/feld_font.py · ports fldigi's feld.cxx / Feld7x7-14.cxx (W1HKJ et al., GPL-3)
1. Abstract
Feld Hell (Hellschreiber, "light-writer") is the oldest of the amateur digital modes and the odd one out: it carries no character code on the air at all. A character is not a symbol to be decoded — it is a small picture, a bitmap that the transmitter paints one vertical column of pixels at a time by keying a single audio tone on and off. A classical Hell receiver is a printer: it inks a dot wherever the tone is present and lets the human eye read the resulting scrolling raster. It is a facsimile mode, effectively a one-line fax.
This repo implements Feld Hell natively in pure NumPy, with two departures from a classical printer. The transmitter (app/radio/feld.py) renders text through fldigi's 7-wide × 14-tall bitmap font, scanning each glyph column-by-column and on/off-keying (OOK/AM) a 1000 Hz tone at the classical 17.5 columns per second — 245 pixels/s. The receiver (app/radio/feld.py) does what a printer cannot: because the transmit font is known, it envelope-detects the tone, rebuilds the on/off column raster against an adaptive ink threshold, locks a fixed 9-column character grid to the first inked column, and correlates each cell's glyph columns against every font glyph by Hamming distance — so it recovers actual text rather than a bitmap image. fldigi itself only ever displays the raster; this decoder reads it back. A short-sentence loopback (test_jt.py) round-trips "CQ DE M0SUP" and "HELLO WORLD 123" exactly. Feld Hell completes the ninth and last of the fldigi keyboard-mode family to go fully in-repo.
2. Background
2.1 A scanning fax, not a code
Rudolf Hell's 1929 Hellschreiber predates every character-coded teleprinter mode on the ham bands. Where RTTY sends a 5-bit Baudot code per character and PSK31 sends a varicode, Hell sends no code. The character set lives entirely in a shared font: a fixed raster of dots. The transmitter walks the glyph left to right; for each column it walks top to bottom; and for every pixel it keys the carrier on if that pixel is inked and off if it is blank. The receiver integrates the tone energy in each pixel slot and marks paper (or a screen) accordingly. Sender and receiver need only agree on three things — the font geometry, the scan order, and the pixel clock — and a picture of the text appears. There is nothing to "decode": legibility is entirely a property of the human visual system, which is remarkably good at reading noisy, smeared, or half-printed rasters. That robustness-by-eye is why Hell survives on HF.
2.2 The classical parameters, and this implementation's
Amateur Feld Hell is standardised at a column rate of 17.5 columns/s and a dot (baud) rate of 122.5 Hz, giving 122.5 / 17.5 = 7 rows per column and 122.5 / 49 = 2.5 characters/s for a 7×7 = 49-dot glyph cell. fldigi renders the same glyphs at double vertical resolution — its font file is named Feld7x7-14: a 7×7 design expanded to 14 rows by row-doubling. This repo inherits that font verbatim, so its numbers are the fldigi numbers, not the 7-row classical ones:
| quantity | classical Hell | this implementation | source |
|---|---|---|---|
| glyph rows | 7 | 14 (ROWS) |
feld.py |
| column rate | 17.5 col/s | 17.5 col/s (COLRATE) |
feld.py |
| dot / pixel rate | 122.5 px/s | 245 px/s (ROWS·COLRATE) |
feld.py |
| glyph width | 7 col | 7 col (GLYPH_COLS) |
feld.py |
The column rate — the one figure a human printer's paper-feed is geared to — is identical to classical Hell, so the raster scrolls at the standard speed and is visually interoperable; only the vertical pixel resolution (and hence the dot rate) is doubled. See §7 for what that means for interop with real Hell stations.
2.3 Why a decoder exists at all
A faithful Hell receiver produces an image, and fldigi does exactly that — its XML-RPC get_rx returns only a text approximation of the printed raster (fldigi_bridge.py). Because the font is fixed and known, this repo goes further and treats reception as a template-matching classification problem: rebuild the clean on/off raster, cut it into character cells on a fixed grid, and pick, per cell, the font glyph whose bitmap is closest in Hamming distance. That turns a fax mode back into a text mode without any per-character coding on the air — the coding is implicit in the shared font.
3. Signal structure
3.1 The glyph grid and scan order
Every character is a 7-column × 14-row bitmap (GLYPH_COLS = 7, ROWS = 14). Columns are scanned left → right; within a column, pixels are scanned top → bottom (row r = 0 is the top, r = 13 the bottom). Each pixel is one dot slot; each column is ROWS = 14 dot slots.
The transmitter renders fixed-pitch: after a glyph's 7 columns it appends GAP_COLS = 2 blank columns (feld.py), so one on-air character cell is
CHAR_COLS = GLYPH_COLS + GAP_COLS = 7 + 2 = 9 columns (feld.py)
The 2 blank columns are inter-character spacing; they guarantee the raster segments cleanly into cells and give the receiver's fixed grid something to lock to. (Classical Hell folds spacing into the 7-column glyph itself; this implementation adds explicit gap columns instead — a deliberate simplification, see §7–8.)
3.2 Rates
column rate COLRATE = 17.5 columns/s (feld.py)
dot rate pixrate = ROWS·COLRATE = 14 · 17.5 = 245 px/s (feld.py)
pixel period 1 / 245 ≈ 4.082 ms
column period ROWS / 245 ≈ 57.14 ms
char rate COLRATE / CHAR_COLS = 17.5 / 9 ≈ 1.944 char/s (fixed-pitch, incl. gap)
glyph-cell rate COLRATE / GLYPH_COLS = 17.5 / 7 = 2.5 char/s (= classical Hell spec)
The sustained text throughput is ≈ 1.94 char/s because each cell spends 9 columns; the classical 2.5 char/s figure counts only the 7 glyph columns.
3.3 The modulation: OOK/AM of one tone
There is exactly one carrier, CENTER = 1000.0 Hz by default (feld.py), and the only thing that varies is whether it is on or off in each pixel slot — amplitude-shift keying (specifically on/off keying). An inked pixel is a spp-sample burst of the tone shaped by a raised-cosine (Hann) envelope; a blank pixel is spp samples of silence. "Samples per pixel" is set by the sample rate:
spp = round( sr / pixrate ) = round( sr / 245 ) (feld.py)
so the physical pixel duration is ≈ 4.08 ms regardless of sr:
sample rate sr |
spp (samples/pixel) |
samples/column spc = spp·14 |
effective px rate |
|---|---|---|---|
| 48000 (TX default) | 196 | 2744 | 244.9 px/s |
| 12000 (RX default) | 49 | 686 | 244.9 px/s |
| 8000 (test) | 33 | 462 | 242.4 px/s |
The bandwidth is that of a ~245 baud OOK envelope on a 1000 Hz tone — a narrow signal a few hundred Hz wide, which is the point of Hell.
4. Encode / TX — step by step
modulate(text, mode="feldhell", sr=48000, center=CENTER, preamble=4) (feld.py) turns a string into signed-16-bit little-endian mono PCM bytes.
Step 0 — precompute the pixel waveform. With spp = round(sr/245) samples per pixel (feld.py):
t = np.arange(spp)
tone = np.sin(2*np.pi*center * t / sr) # the carrier (feld.py)
key = 0.5 * (1 - np.cos(2*np.pi*np.arange(spp)/spp)) # Hann envelope (feld.py)
key is a raised cosine rising 0→1→0 across the pixel — it soft-keys each dot to suppress key clicks. Every inked pixel is tone * key; every blank pixel is np.zeros(spp). Note the envelope returns to zero at every pixel boundary, so a vertical run of ink pulses at the dot rate rather than holding a continuous tone — an approximation of classical Hell keying that the non-coherent receiver (§5) is indifferent to.
Step 1 — character → font bitmap → column bitmaps. For each input character, _char_columns(c) (feld.py) produces 7 column bitmaps:
- Look up the glyph rows:
rows = FONT[ord(c.upper())], falling back to the blank_SPACEglyph if absent (feld.py). The input is upper-cased — TX paints only the uppercase/rendered forms (Hell is a picture; case is just glyph shape). - For each glyph column
col ∈ 0..6and each rowr ∈ 0..13, test bit15 - colof the 16-bitrows[r](MSB = leftmost column) and, if set, set bitrof that column's bitmap:if (rows[r] >> (15 - col)) & 1: v |= (1 << r)(feld.py).
The result is a list of seven 14-bit integers; bit r of column col is the ink at raster cell (row r, col). Only the top 7 bits (15…9) of each stored 16-bit row are ever consulted (§6).
Step 2 — build the pixel-column stream. columns is a flat list of 0/1 pixels in scan order (feld.py):
[0]*(ROWS*preamble) # 4 blank lead-in columns (feld.py)
for each character:
for each of its 7 column bitmaps:
col_pixels(cb) = [(cb>>r)&1 for r in 0..13] # 14 pixels, top→bottom (feld.py)
[0]*(ROWS*GAP_COLS) # 2 blank inter-char columns (feld.py)
[0]*(ROWS*preamble) # 4 blank trailing columns (feld.py)
The default preamble = 4 blank columns front and back give the receiver's ink tracker and grid time to settle and flush.
Step 3 — key the tone and serialize. Each pixel is expanded to its waveform and concatenated (feld.py):
out = np.concatenate([tone*key if px else np.zeros(spp) for px in columns])
Step 4 — scale to PCM. Clip to ±1 at 0.9 full scale and quantise to int16 LE (feld.py):
np.round(np.clip(out*0.9, -1, 1) * 32767).astype("<i2").tobytes()
A space character maps through the same path: FONT[32] is all-zero, so a space is 7 blank glyph columns + 2 gap = 9 blank columns, i.e. one empty cell.
5. Decode / RX — step by step
class FeldDecoder (feld.py) is a streaming decoder: FeldDecoder(on_text, mode="feldhell", sr=12000, center=CENTER) calls on_text(char) as characters are recovered. State set in __init__ (feld.py):
spp = round(sr / (ROWS*COLRATE)) = round(sr/245) samples per pixel (feld.py)
spc = spp * ROWS samples per column (feld.py)
_buf = float sample buffer _pos = read cursor (samples, float)
_cols = reconstructed 14-bit column raster (list of ints)
_start= grid origin (index of first inked column, None until found)
_ink = running "ink present" envelope level (adaptive AGC)
spp/spc use the same round(sr/245) as the modulator, so a pixel is the same physical length on both ends (§7).
5.1 Per-pixel envelope detection (_pixel_env, feld.py)
For a pixel starting at sample start, take the spp-sample window and compute the magnitude of a single-bin DFT (Goertzel) at the carrier, normalised by window length (feld.py):
seg = _buf[a : a + spp] # a = round(start)
env = abs( Σ seg[n] · exp(-2j·π·center·n / sr) ) / len(seg)
This is a non-coherent tone-amplitude estimate — it needs no carrier phase, so the per-pixel Hann windowing and phase resets of the TX are irrelevant. It is the "how much ink is in this pixel" measurement.
5.2 Column assembly and adaptive ink threshold (feed, feld.py)
Input may be int16 LE bytes (buffered with an odd-byte _carry, scaled by 1/32768) or a float array (feld.py); it is appended to _buf. Then, while a whole column plus one pixel of look-ahead is available (feld.py), process one column at a time:
- 14 pixel envelopes across the column:
envs[r] = _pixel_env(_pos + r·spp)forr ∈ 0..13(feld.py). - Track the ink level with a slow-decay running max (peak-hold AGC):
_ink = max(_ink*0.98, envs.max())(feld.py)._inkfollows the brightest recent pixel, decaying 2 %/column (≈ 30 %/s at 17.5 col/s). - Is this column inked? Only if
_ink > 1e-3and the column peak exceeds 30 % of the tracked ink level:peak > 0.30 * _ink(feld.py). Blank columns fall below this and stay all-zero. - Threshold each pixel at 50 % of the ink level:
thr = 0.5*_ink; set bitrofcolbitsiffenvs[r] >= thr(feld.py). Both thresholds are relative to_ink, so the decoder self-calibrates to signal level rather than an absolute amplitude — the "ink-level threshold" that makes the raster reconstruction robust to fading and gain. - Hand
colbits(a 14-bit column, bitr= rowr) to_add_column, then advance_pos += spc(feld.py).
After the loop, old samples are dropped keeping one column of backlog: drop = int(_pos) - int(spc) (feld.py).
5.3 Grid sync and per-cell correlation (_add_column, feld.py)
Columns accumulate in _cols. Registration is one-shot:
Before sync (
_start is None): the first non-zero column sets_start = len(_cols) - 1— the grid origin is the first inked column (feld.py). While still idle,_colsis cleared if it grows past 64 columns, to bound memory (feld.py).After sync: the character grid is rigid thereafter — every
CHAR_COLS = 9columns is one cell. Whenever ≥ 9 columns past_startare buffered, emit the cell and step the origin (feld.py):while len(_cols) - _start >= CHAR_COLS: _emit_char(_cols[_start : _start + GLYPH_COLS]) # correlate first 7 cols _start += CHAR_COLS # skip the 2 gap columns
There is no per-character re-sync; the fixed 9-column cadence is trusted because the transmitter guarantees it. Only the first 7 columns of each cell are classified; the trailing 2 gap columns are discarded.
5.4 Glyph classification (_emit_char, feld.py)
Given the cell's 7 column bitmaps cols7, compare against every printable font template and pick the minimum Hamming distance (feld.py):
dist = Σ_{i=0..6} popcount( (cols7[i] ^ tmpl[i]) & 0x3FFF ) # 14-bit mask
0x3FFF masks to 14 rows, so dist counts mismatched dots over the whole 7×14 = 98-cell glyph. Decision (feld.py):
- Blank cell (
sum(cols7) == 0) → emit" "(a space). - Else if the best distance
best <= 20→ emitchr(bestc). - Else emit nothing — a cell no glyph matches within 20 dots is dropped as noise. (
bestalso breaks ties toward the earlier-inserted codepoint; since the font inserts uppercase 65–90 before lowercase 97–122, and the lowercase templates are identical to their uppercase ones (§6), letters resolve to uppercase — matching the upper-cased TX.)
The _TEMPLATES dict is precomputed once at import: {c: _char_columns(chr(c)) for c in FONT if 33 <= c < 127} (feld.py) — the same column-bitmap form as the raster, for every printable codepoint 33…126.
Because the trailing preamble produces blank cells, the decoder emits trailing spaces; the loopback test compares with .strip() (test_jt.py).
6. Constants and tables
6.1 Modem constants (app/radio/feld.py)
| name | value | meaning |
|---|---|---|
ROWS |
14 |
glyph rows / dot slots per column |
COLRATE |
17.5 |
columns per second |
GLYPH_COLS |
7 |
glyph width in columns |
GAP_COLS |
2 |
blank inter-character columns |
CHAR_COLS |
9 |
full character cell = 7 + 2 |
CENTER |
1000.0 |
tone frequency, Hz |
_SPACE |
[0]*14 |
blank-glyph fallback (FONT[32]) |
pixrate |
245 |
ROWS·COLRATE, dots/s |
spp |
round(sr/245) |
samples per pixel (TX & RX) |
spc |
spp·ROWS |
samples per column (RX) |
| ink-column gate | peak > 0.30·_ink |
column considered inked |
| pixel threshold | 0.5·_ink |
per-pixel ink decision |
| ink decay | _ink*0.98 per column |
peak-hold AGC |
| match threshold | best <= 20 |
max Hamming distance to accept |
| idle memory bound | len(_cols) > 64 |
pre-sync raster cap |
| default TX rate | sr=48000 |
modulator output rate |
| default RX rate | sr=12000 |
decoder rate (app's RX_RATE) |
6.2 The font table (app/radio/_tables/feld_font.py)
Storage format, from the module docstring: "char → 14 rows × 16-bit columns (MSB = leftmost column)." FONT is a single dict literal mapping an ASCII codepoint to a list of 14 integers, one per raster row, each a 16-bit value whose bits are the row's pixels with bit 15 = leftmost column. Although 16 bits are stored per row, only the top 7 bits (15…9) are used — the glyph is 7 columns wide (feld.py); the low 9 bits are always zero. The 14 rows are a row-doubled rendering of a 7×7 design (each logical row appears twice), which is why the file is a port of fldigi's Feld7x7-14.cxx and why adjacent row entries are usually equal.
Coverage: 95 entries, codepoints 32…126, complete and contiguous (no gaps). All printable ASCII is present, including both cases of the letters (65–90 and 97–122). Note, however, that TX up-cases (feld.py) and the templates for 97–122 are built via _char_columns(chr(c)), which also up-cases (feld.py) — so the lowercase font rows are never actually painted or matched; the mode is effectively uppercase.
Historical note: codepoint 39, the apostrophe ', was originally absent — the literal jumped 38 → 40 — which was found while writing this spec; the exact glyph from fldigi's Feld7x7-14.cxx, 0xC000,0xC000,0x4000,0x4000,0x8000,0x8000 = a top tick, was added, so ' now round-trips, covered by test_feld_roundtrip. The full table below includes it (verified present).
The complete font. Every entry, exactly as stored — codepoint char [14 rows], each row a 16-bit integer with bit 15 = leftmost column. This is the entire on-air alphabet of the mode; nothing lives outside it.
FONT = {
# cp ch [ r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12, r13 ]
32:' ' : [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
33:'!' : [0, 0, 32768, 32768, 32768, 32768, 32768, 32768, 0, 0, 32768, 32768, 0, 0],
34:'"' : [0, 0, 40960, 40960, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
35:'#' : [0, 0, 20480, 20480, 63488, 63488, 20480, 20480, 63488, 63488, 20480, 20480, 0, 0],
36:'$' : [0, 8192, 30720, 30720, 40960, 40960, 28672, 28672, 10240, 10240, 61440, 61440, 8192, 0],
37:'%' : [0, 0, 51200, 51200, 4096, 4096, 8192, 8192, 16384, 16384, 38912, 38912, 0, 0],
38:'&' : [0, 0, 16384, 16384, 57344, 57344, 26624, 26624, 36864, 36864, 30720, 30720, 0, 0],
39:"'" : [0, 0, 49152, 49152, 16384, 16384, 32768, 32768, 0, 0, 0, 0, 0, 0],
40:'(' : [0, 0, 24576, 24576, 32768, 32768, 32768, 32768, 32768, 32768, 24576, 24576, 0, 0],
41:')' : [0, 0, 49152, 49152, 8192, 8192, 8192, 8192, 8192, 8192, 49152, 49152, 0, 0],
42:'*' : [0, 0, 43008, 43008, 28672, 28672, 63488, 63488, 28672, 28672, 43008, 43008, 0, 0],
43:'+' : [0, 0, 0, 0, 8192, 8192, 63488, 63488, 8192, 8192, 0, 0, 0, 0],
44:',' : [0, 0, 0, 0, 0, 0, 0, 49152, 49152, 16384, 16384, 32768, 32768, 0],
45:'-' : [0, 0, 0, 0, 0, 0, 63488, 63488, 0, 0, 0, 0, 0, 0],
46:'.' : [0, 0, 0, 0, 0, 0, 0, 0, 0, 49152, 49152, 49152, 0, 0],
47:'/' : [0, 0, 2048, 2048, 4096, 4096, 8192, 8192, 16384, 16384, 32768, 32768, 0, 0],
48:'0' : [0, 0, 28672, 28672, 38912, 38912, 43008, 43008, 51200, 51200, 28672, 28672, 0, 0],
49:'1' : [0, 0, 24576, 24576, 40960, 40960, 8192, 8192, 8192, 8192, 8192, 8192, 0, 0],
50:'2' : [0, 0, 57344, 57344, 4096, 4096, 8192, 8192, 16384, 16384, 63488, 63488, 0, 0],
51:'3' : [0, 0, 61440, 61440, 2048, 2048, 12288, 12288, 2048, 2048, 61440, 61440, 0, 0],
52:'4' : [0, 0, 36864, 36864, 36864, 36864, 63488, 63488, 4096, 4096, 4096, 4096, 0, 0],
53:'5' : [0, 0, 63488, 63488, 32768, 32768, 61440, 61440, 2048, 2048, 61440, 61440, 0, 0],
54:'6' : [0, 0, 28672, 28672, 32768, 32768, 61440, 61440, 34816, 34816, 28672, 28672, 0, 0],
55:'7' : [0, 0, 63488, 63488, 2048, 2048, 4096, 4096, 8192, 8192, 16384, 16384, 0, 0],
56:'8' : [0, 0, 28672, 28672, 34816, 34816, 28672, 28672, 34816, 34816, 28672, 28672, 0, 0],
57:'9' : [0, 0, 28672, 28672, 34816, 34816, 30720, 30720, 2048, 2048, 28672, 28672, 0, 0],
58:':' : [0, 0, 0, 0, 49152, 49152, 0, 0, 49152, 49152, 0, 0, 0, 0],
59:';' : [0, 0, 0, 49152, 49152, 0, 0, 49152, 49152, 16384, 16384, 32768, 32768, 0],
60:'<' : [0, 0, 2048, 2048, 12288, 12288, 49152, 49152, 12288, 12288, 2048, 2048, 0, 0],
61:'=' : [0, 0, 0, 0, 63488, 63488, 0, 0, 63488, 63488, 0, 0, 0, 0],
62:'>' : [0, 0, 32768, 32768, 24576, 24576, 6144, 6144, 24576, 24576, 32768, 32768, 0, 0],
63:'?' : [0, 0, 57344, 57344, 4096, 4096, 8192, 8192, 0, 0, 8192, 8192, 0, 0],
64:'@' : [0, 0, 28672, 28672, 34816, 34816, 45056, 45056, 32768, 32768, 30720, 30720, 0, 0],
65:'A' : [0, 0, 28672, 28672, 34816, 34816, 63488, 63488, 34816, 34816, 34816, 34816, 0, 0],
66:'B' : [0, 0, 61440, 61440, 18432, 18432, 28672, 28672, 18432, 18432, 61440, 61440, 0, 0],
67:'C' : [0, 0, 30720, 30720, 32768, 32768, 32768, 32768, 32768, 32768, 30720, 30720, 0, 0],
68:'D' : [0, 0, 61440, 61440, 18432, 18432, 18432, 18432, 18432, 18432, 61440, 61440, 0, 0],
69:'E' : [0, 0, 63488, 63488, 32768, 32768, 57344, 57344, 32768, 32768, 63488, 63488, 0, 0],
70:'F' : [0, 0, 63488, 63488, 32768, 32768, 57344, 57344, 32768, 32768, 32768, 32768, 0, 0],
71:'G' : [0, 0, 30720, 30720, 32768, 32768, 38912, 38912, 34816, 34816, 30720, 30720, 0, 0],
72:'H' : [0, 0, 34816, 34816, 34816, 34816, 63488, 63488, 34816, 34816, 34816, 34816, 0, 0],
73:'I' : [0, 0, 57344, 57344, 16384, 16384, 16384, 16384, 16384, 16384, 57344, 57344, 0, 0],
74:'J' : [0, 0, 2048, 2048, 2048, 2048, 2048, 2048, 34816, 34816, 28672, 28672, 0, 0],
75:'K' : [0, 0, 34816, 34816, 36864, 36864, 57344, 57344, 36864, 36864, 34816, 34816, 0, 0],
76:'L' : [0, 0, 32768, 32768, 32768, 32768, 32768, 32768, 32768, 32768, 63488, 63488, 0, 0],
77:'M' : [0, 0, 34816, 34816, 55296, 55296, 43008, 43008, 34816, 34816, 34816, 34816, 0, 0],
78:'N' : [0, 0, 34816, 34816, 51200, 51200, 43008, 43008, 38912, 38912, 34816, 34816, 0, 0],
79:'O' : [0, 0, 28672, 28672, 34816, 34816, 34816, 34816, 34816, 34816, 28672, 28672, 0, 0],
80:'P' : [0, 0, 61440, 61440, 34816, 34816, 61440, 61440, 32768, 32768, 32768, 32768, 0, 0],
81:'Q' : [0, 0, 28672, 28672, 34816, 34816, 34816, 34816, 43008, 43008, 28672, 28672, 4096, 4096],
82:'R' : [0, 0, 61440, 61440, 34816, 34816, 61440, 61440, 36864, 36864, 34816, 34816, 0, 0],
83:'S' : [0, 0, 30720, 30720, 32768, 32768, 28672, 28672, 2048, 2048, 61440, 61440, 0, 0],
84:'T' : [0, 0, 63488, 63488, 8192, 8192, 8192, 8192, 8192, 8192, 8192, 8192, 0, 0],
85:'U' : [0, 0, 34816, 34816, 34816, 34816, 34816, 34816, 34816, 34816, 28672, 28672, 0, 0],
86:'V' : [0, 0, 34816, 34816, 36864, 36864, 40960, 40960, 49152, 49152, 32768, 32768, 0, 0],
87:'W' : [0, 0, 34816, 34816, 34816, 34816, 43008, 43008, 43008, 43008, 20480, 20480, 0, 0],
88:'X' : [0, 0, 34816, 34816, 20480, 20480, 8192, 8192, 20480, 20480, 34816, 34816, 0, 0],
89:'Y' : [0, 0, 34816, 34816, 20480, 20480, 8192, 8192, 8192, 8192, 8192, 8192, 0, 0],
90:'Z' : [0, 0, 63488, 63488, 4096, 4096, 8192, 8192, 16384, 16384, 63488, 63488, 0, 0],
91:'[' : [0, 0, 57344, 57344, 32768, 32768, 32768, 32768, 32768, 32768, 57344, 57344, 0, 0],
92:'\\': [0, 0, 32768, 32768, 16384, 16384, 8192, 8192, 4096, 4096, 2048, 2048, 0, 0],
93:']' : [0, 0, 57344, 57344, 8192, 8192, 8192, 8192, 8192, 8192, 57344, 57344, 0, 0],
94:'^' : [0, 0, 8192, 8192, 20480, 20480, 34816, 34816, 0, 0, 0, 0, 0, 0],
95:'_' : [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 63488, 63488, 0, 0],
96:'`' : [0, 0, 49152, 49152, 32768, 32768, 16384, 16384, 0, 0, 0, 0, 0, 0],
97:'a' : [0, 0, 0, 0, 57344, 57344, 4096, 4096, 61440, 61440, 28672, 28672, 0, 0],
98:'b' : [0, 0, 32768, 32768, 32768, 61440, 61440, 34816, 34816, 34816, 61440, 61440, 0, 0],
99:'c' : [0, 0, 0, 0, 28672, 28672, 32768, 32768, 32768, 32768, 28672, 28672, 0, 0],
100:'d' : [0, 0, 2048, 2048, 30720, 30720, 34816, 34816, 34816, 34816, 30720, 30720, 0, 0],
101:'e' : [0, 0, 0, 0, 28672, 28672, 63488, 63488, 32768, 32768, 30720, 30720, 0, 0],
102:'f' : [0, 0, 14336, 14336, 16384, 16384, 57344, 57344, 16384, 16384, 57344, 57344, 0, 0],
103:'g' : [0, 0, 0, 30720, 30720, 34816, 34816, 30720, 30720, 2048, 2048, 28672, 28672, 0],
104:'h' : [0, 0, 32768, 32768, 61440, 61440, 34816, 34816, 34816, 34816, 34816, 34816, 0, 0],
105:'i' : [16384, 16384, 0, 0, 49152, 49152, 16384, 16384, 16384, 16384, 57344, 57344, 0, 0],
106:'j' : [4096, 4096, 0, 0, 12288, 12288, 4096, 4096, 4096, 4096, 36864, 36864, 24576, 24576],
107:'k' : [0, 0, 36864, 36864, 40960, 40960, 49152, 49152, 40960, 40960, 36864, 36864, 0, 0],
108:'l' : [0, 0, 57344, 57344, 8192, 8192, 8192, 8192, 8192, 8192, 63488, 63488, 0, 0],
109:'m' : [0, 0, 0, 0, 53248, 53248, 43008, 43008, 43008, 34816, 34816, 34816, 0, 0],
110:'n' : [0, 0, 0, 0, 45056, 45056, 51200, 51200, 34816, 34816, 34816, 34816, 0, 0],
111:'o' : [0, 0, 0, 0, 28672, 28672, 34816, 34816, 34816, 34816, 28672, 28672, 0, 0],
112:'p' : [0, 0, 0, 0, 61440, 61440, 34816, 34816, 51200, 51200, 45056, 45056, 32768, 32768],
113:'q' : [0, 0, 0, 0, 30720, 30720, 34816, 34816, 38912, 38912, 26624, 26624, 2048, 2048],
114:'r' : [0, 0, 0, 0, 55296, 55296, 24576, 24576, 16384, 16384, 57344, 57344, 0, 0],
115:'s' : [0, 0, 0, 0, 30720, 30720, 49152, 49152, 14336, 14336, 61440, 61440, 0, 0],
116:'t' : [0, 0, 16384, 16384, 57344, 57344, 16384, 16384, 16384, 16384, 12288, 12288, 0, 0],
117:'u' : [0, 0, 0, 0, 34816, 34816, 34816, 34816, 34816, 34816, 28672, 28672, 0, 0],
118:'v' : [0, 0, 0, 0, 34816, 34816, 34816, 34816, 20480, 20480, 8192, 8192, 0, 0],
119:'w' : [0, 0, 0, 0, 34816, 34816, 34816, 43008, 43008, 43008, 20480, 20480, 0, 0],
120:'x' : [0, 0, 0, 0, 34816, 34816, 28672, 28672, 28672, 28672, 34816, 34816, 0, 0],
121:'y' : [0, 0, 0, 0, 34816, 34816, 18432, 18432, 12288, 12288, 8192, 8192, 16384, 16384],
122:'z' : [0, 0, 0, 0, 63488, 63488, 4096, 4096, 24576, 24576, 63488, 63488, 0, 0],
123:'{' : [0, 0, 24576, 24576, 16384, 16384, 49152, 49152, 16384, 16384, 24576, 24576, 0, 0],
124:'|' : [0, 0, 32768, 32768, 32768, 32768, 0, 0, 32768, 32768, 32768, 32768, 0, 0],
125:'}' : [0, 0, 49152, 49152, 16384, 16384, 24576, 24576, 16384, 16384, 49152, 49152, 0, 0],
126:'~' : [0, 0, 0, 0, 8192, 8192, 20480, 20480, 34816, 34816, 0, 0, 0, 0],
}
(The codepoint:char labels are a reading aid; the actual FONT literal is keyed by integer codepoint only. Row values are decimal; e.g. 32768 = 0x8000 = bit 15 = leftmost column, 63488 = 0xF800 = the left five columns all inked.)
Escape / extraction handling. The table was mechanically transcribed from fldigi's C source, where glyph rows are literal data and two characters are hazardous to any extractor: space (codepoint 32) and backslash (codepoint 92, \, the C escape lead-in). Both survive correctly here:
32(space) →[0,0,0,0,0,0,0,0,0,0,0,0,0,0]— genuinely blank (feld_font.py), which is what lets a space round-trip as an empty cell (§4, §5.4).92(backslash) →[0,0,32768,32768,16384,16384,8192,8192,4096,4096,2048,2048,0,0]— a proper top-left-to-bottom-right diagonal (bit 15 → 14 → 13 → 12 → 11 as the scan descends), not a dropped or mis-escaped entry.
A reimplementer transcribing the font must likewise preserve the literal space row and the backslash glyph, and include the apostrophe glyph (codepoint 39).
6.3 A representative glyph: A (codepoint 65)
Stored rows (feld_font.py):
65: [0, 0, 28672, 28672, 34816, 34816, 63488, 63488, 34816, 34816, 34816, 34816, 0, 0]
Reading the top 7 bits of each 16-bit row (bit 15 = column 0, leftmost) gives the 7×14 raster (# = ink):
col: 0123456
r 0 .......
r 1 .......
r 2 .###... 28672 = 0b0111000_000000000
r 3 .###...
r 4 #...#.. 34816 = 0b1000100_000000000
r 5 #...#..
r 6 #####.. 63488 = 0b1111100_000000000
r 7 #####..
r 8 #...#..
r 9 #...#..
r10 #...#..
r11 #...#..
r12 .......
r13 .......
The row-doubling (r2==r3, r4==r5, …) is visible. _char_columns('A') (feld.py) transposes this to the 7 column bitmaps actually transmitted and correlated (bit r = row r):
[4080, 204, 204, 204, 4080, 0, 0]
col0 col1 col2 col3 col4 col5 col6
e.g. col0 = 4080 = 0x0FF0 = bits 4…11 set = the left vertical stroke at rows 4–11; col5 = col6 = 0 are the glyph's own blank right margin (before the 2 explicit gap columns are even added). Note that across the whole font no glyph ever sets bit 10 or bit 9, so columns 5 and 6 are always blank ink — the font's own built-in right margin, on top of which the 2 explicit gap columns are added.
7. Interoperability and validation
7.1 fldigi reference and lineage
The modem ports fldigi's feld.cxx and font Feld7x7-14.cxx (Dave Freese W1HKJ et al., GPL-3), as stated in feld.py and in the commit that introduced it (648509b, "Native Feld Hell (FELDHELL): raster TX + font-correlation text RX"). It is wired into the app exactly like the other native fldigi-family modes: mode key "hell" → module feld in fldigi_native.py, registered as the SDK codec Feld Hell / category Hell (codecs/builtin_fldigi.py), decoder class FeldDecoder (fldigi_native.py). The external fallback is fldigi's own FELDHELL modem over XML-RPC (fldigi_bridge.py), whose get_rx returns only a text approximation of the printed raster (fldigi_bridge.py) — i.e. the native decoder recovers text where the bridge only relays fldigi's best-effort raster read.
Because the column rate (17.5 col/s) and the 7-wide × 14-row fldigi font are identical, the transmitted raster is the standard Feld Hell picture and prints correctly on fldigi and on classical Hell equipment (which reads the 14-row raster as a 7-row glyph at 2× vertical oversampling). The dot rate is 245 px/s (2× the classical 122.5) purely because of the 14-row font; it does not change the on-screen speed or width.
7.2 The two fixes that make it work
TX/RX pixel-length must match. The whole scheme hinges on both ends slicing the tone into pixels of the same physical duration. Both compute
spp = round(sr / (ROWS·COLRATE))— modulator and decoder alike (feld.py) — so a pixel is ≈ 4.08 ms at any sample rate and TX@48 k, RX@12 k, or the test's 8 k all interoperate. The app feeds the decoder post-decimation audio atRX_RATE = 12000(fldigi_native.py) while the modulator renders at 48 kHz; the matchedsppformula is what keeps the rasters aligned across that rate change. (Note the small quantisation: at 8 kHzspp = 33gives 242.4 px/s, a 0.6 % slow clock — harmless over short messages.)Ink-level threshold (relative, adaptive). Reconstruction never uses an absolute amplitude.
_inkis a peak-hold of the brightest recent pixel decaying 2 %/column (feld.py); a column counts as inked only above 30 % of_ink(feld.py) and a pixel is set above 50 % of_ink(feld.py). This self-calibrating threshold is what lets the raster survive fading and arbitrary receive gain, and thebest <= 20-dot correlation gate (feld.py) suppresses cells that match nothing.
7.3 Validation
test_jt.py (test_feld_roundtrip) drives the full chain in-process at sr = 8000: FeldDecoder(...).feed(modulate(txt, sr=8000)) and asserts "".join(got).strip() == txt for both "CQ DE M0SUP" and "HELLO WORLD 123" — so alphanumerics, digits and embedded spaces all round-trip exactly (spaces verify the blank-cell → " " path of §5.4; .strip() absorbs the trailing preamble's blank cells). The commit note additionally records a clean round-trip of "THE QUICK BROWN FOX 123".
8. Limitations
Honest gaps in this first cut, roughly by impact:
Fixed-pitch, not variable-width. Real Feld Hell packs proportional glyphs and folds spacing into the 7-column matrix; this TX pads every glyph to a rigid 9-column cell (
feld.py). The printed raster is legible and standard-rate but slightly more spread out than a classical Hell station's, and the decoder assumes that exact 9-column cadence — it will not correctly segment a variable-width Hell transmission from other software. The commit calls exact variable-width visual interop "a refinement."No sub-column timing recovery. The receive cursor
_posstarts at 0 and only ever steps byspc(feld.py); the character grid is aligned to the first inked column, but the column sampling phase is whatever the buffer happens to start on. In loopback the preamble is column-aligned from sample 0 so the phase is exact; over the air an arbitrary start offset δ shifts every pixel window by δ and smears each sampled pixel across two true pixels. There is no correlation-based clock recovery to remove it.First-ink sync assumes an inked column 0. Grid registration latches onto the first non-zero column (
feld.py). If a message's opening glyph had an entirely blank column 0, the grid would lock one column late and mis-segment the whole line. Alphanumerics (which carry a left stroke in column 0) are safe — the tested strings start withC/H— but some punctuation is not.Uppercase only. TX up-cases (
feld.py) and the lowercase templates are built up-cased too (feld.py), so case is not carried — consistent, but lossy versus fldigi, which prints mixed case.Approximated keying pulse. Each pixel is an independent Hann-windowed tone burst (
feld.py), so vertical ink runs pulse at the dot rate instead of holding a continuous tone. The non-coherent detector doesn't care, but the emitted waveform is not a byte-exact match to fldigi's keying filter.No FEC, no CRC, no ARQ. Robustness is entirely the eye-and-correlator model — errors are per-cell mis-picks or drops, not corrected. That is faithful to Hell, which has never had channel coding.
None of these are architectural; each is a bounded refinement on a working base.
9. References
- R. Hell, Hellschreiber (1929) — the original scanning-fax teleprinter; the "paint a font, print by eye" principle this mode still uses.
- D. Freese (W1HKJ) et al., fldigi —
feld.cxxand theFeld7x7-14.cxxfont (GPL-3); the reference modem and the exact font ported here (app/radio/feld.py,app/radio/_tables/feld_font.py). - Amateur Feld Hell mode parameters: 17.5 columns/s, 122.5 Hz dot rate, 7×7 = 49-dot glyph, 2.5 char/s — the classical figures this implementation doubles vertically to 14 rows / 245 px/s (§2.2).
- G. Goertzel, "An Algorithm for the Evaluation of Finite Trigonometric Series" (1958) — the single-bin DFT used for per-pixel tone-envelope detection (
feld.py).
Source files
app/radio/feld.py— modem:modulate()(TX),FeldDecoder(RX), all modem constants of §6.1.app/radio/_tables/feld_font.py— theFONTdict reproduced in full in §6.2.app/radio/fldigi_native.py— mode wiring andRX_RATE.app/radio/fldigi_bridge.py— external fldigi (FELDHELL) XML-RPC fallback.app/radio/codecs/builtin_fldigi.py— SDK codec/category registration.test_jt.py—test_feld_roundtriploopback test.- Companion codec spec:
docs/rvqvoice.md.