ft4_encoder.py
Rama main · réplica de lectura del repositorio original.
"""
ft4_encoder.py — Custom FT4 encoder/transmitter.
FT4 shares the entire source-encoding stage with FT8 up to the addition of
parity bits: pack77 (77 bits), CRC-14 (the same polynomial 0x2757),
LDPC(174,91) (the same Nm/Mn/rawg matrices). These functions are REUSED
unchanged from ft8_encoder.py — see the import below.
The differences from FT8 start AFTER pack77 and continue through to the
final frame:
1. SCRAMBLING: the 77 source bits are XORed with the 'rvec' mask BEFORE
the CRC is appended (FT8 has no scrambling step — this is the only
real difference at the source-encoding layer)
2. Gray mapping: 2 bits/symbol (not 3 as in FT8) -> 4 tones (not 8) -> 87
data symbols (from 174 bits), not 58
3. Frame structure: FOUR different 4-symbol Costas patterns (not one
7-symbol pattern like FT8), interleaved with three 29-symbol data
blocks: Costas1(4) + Block1(29) + Costas2(4) + Block2(29) + Costas3(4)
+ Block3(29) + Costas4(4) = 103 symbols total
4. Modulation: 4-GFSK (not 8-GFSK), tone spacing 20.833Hz (not 6.25Hz),
symbol period 0.048s (not 0.16s), BT=1.0 (not 2.0) — a significantly
faster signal: ~5s of transmission within a 7.5s window (vs ~12.6s
within a 15s window for FT8)
SPEC SOURCE: the scrambling mask 'rvec' (77 bits), costas_symbols (4
patterns of 4x4), costas_offsets ([0,33,66,99]) and graymap ([0,1,3,2])
were cross-checked between two independent public sources of the FT4
protocol spec.
- The frame structure (4x Costas + 3x29 data block = 103 symbols, plus a
"ramped null symbol" at the start/end = 105 total) was confirmed in
BOTH sources (the same Sync1+Block1+Sync2+Block2+Sync3+Block3+Sync4 pattern).
- Tone spacing 20.833Hz / symbol period 0.048s confirmed in BOTH sources
("576-point FFT @ 12000 sps" / "tone spacing 20.833Hz, symbol interval 48ms").
NOT YET VERIFIED against a real FT4 decoder — needs a live test with real
radio hardware, or comparison against a reference .wav recording.
DECODER (RX) — NOT IMPLEMENTED in this module. Requires a separate
pipeline analogous to ft8_rx_decoder.py, with a different Costas sync (4
patterns instead of 1, searched at specific offsets 0/33/66/99) and a
different FFT bin size (576-point FFT @ 12000Hz). That's a separate, large
piece of work for later.
"""
import numpy as np
from scipy.special import erf
# Reused unchanged: pack77 (77-bit source encoding), _crc14, _ldpc_encode,
# _ldpc_check, _build_gen_sys (already invoked at ft8_encoder's import time).
from ft8_encoder import (
pack77, _crc14, _ldpc_encode, _ldpc_check,
)
# ============================================================
# PART 1: FT4-specific constants
# ============================================================
# Scrambling mask — 77 bits, XORed with pack77()'s output BEFORE the CRC is
# appended. Purpose: avoiding long runs of zeros for CQ-type messages
# (which have many zero bits in the raw packing). FT8 does NOT have this step.
_RVEC = np.array([
0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0,
1, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 1,
0, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 1
], dtype=np.int32)
assert len(_RVEC) == 77
# Four different 4-symbol Costas (sync) patterns, each used once per frame.
_COSTAS_FT4 = [
[0, 1, 3, 2], # Sync1
[1, 0, 2, 3], # Sync2
[2, 3, 1, 0], # Sync3
[3, 2, 0, 1], # Sync4
]
# Starting positions (0-indexed) of each Costas block in the 103-symbol frame.
_COSTAS_OFFSETS = [0, 33, 66, 99]
# Gray mapping for bit pairs (2 bits/symbol, 4 tones) — the same idea as
# FT8's _GRAYMAP but for 2-bit indices instead of 3-bit.
# '00'->0, '01'->1, '10'->3, '11'->2 (confirmed on g4jnt.com and in weakmon)
_GRAYMAP_FT4 = [0, 1, 3, 2]
def _scramble77(bits77):
"""XOR 77 bits with the _RVEC mask. The operation is its own inverse
(x XOR mask XOR mask == x), so the same function does scrambling at
encode time and de-scrambling at decode time."""
assert len(bits77) == 77
a = np.array(bits77, dtype=np.int32)
return list(np.mod(a + _RVEC, 2))
def encode_message_ft4(call_to, call_de, report_or_grid, r_flag=False):
"""
Full FT4 pipeline: text -> 77-bit pack -> scrambling -> CRC-14 ->
LDPC(174,91) -> Gray mapping (2bit) -> insert 4x Costas -> 103
symbols (0-3).
Returns (symbols103, debug_dict). Analogous to
ft8_encoder.encode_message but with an added scrambling step and a
different frame structure.
"""
bits77 = pack77(call_to, call_de, report_or_grid, r_flag)
scrambled77 = _scramble77(bits77)
# CRC is computed on the SCRAMBLED bits (same as the FT8 pipeline,
# _crc14 expects 82 bits = 77 + 5 padding zeros)
padded82 = scrambled77 + [0, 0, 0, 0, 0]
crc = _crc14(padded82)
bits91 = np.array(scrambled77 + crc, dtype=np.int32)
codeword174 = _ldpc_encode(bits91)
# Gray-map bit pairs (174 bits -> 87 symbols, 2 bits each)
data_symbols = []
for i in range(0, 174, 2):
b0, b1 = codeword174[i], codeword174[i + 1]
idx = b0 * 2 + b1
data_symbols.append(_GRAYMAP_FT4[idx])
assert len(data_symbols) == 87
# Split the 87 data symbols into 3 blocks of 29 and interleave with 4x Costas
block1 = data_symbols[0:29]
block2 = data_symbols[29:58]
block3 = data_symbols[58:87]
symbols103 = (_COSTAS_FT4[0] + block1 + _COSTAS_FT4[1] + block2 +
_COSTAS_FT4[2] + block3 + _COSTAS_FT4[3])
assert len(symbols103) == 103
debug = {
"bits77": bits77,
"scrambled77": scrambled77,
"bits91": bits91.tolist(),
"codeword174": codeword174.tolist(),
"ldpc_valid": _ldpc_check(codeword174),
}
return symbols103, debug
# ============================================================
# PART 2: 4-GFSK audio generator
# ============================================================
FT4_SAMPLE_RATE = 12000
FT4_SYMBOL_PERIOD = 0.048 # 48ms — confirmed in 2 independent sources
FT4_SAMPLES_PER_SYMBOL = round(FT4_SAMPLE_RATE * FT4_SYMBOL_PERIOD) # 576
FT4_TONE_SPACING = FT4_SAMPLE_RATE / FT4_SAMPLES_PER_SYMBOL # 20.8333... Hz
FT4_BT = 1.0 # FT4 uses a more smoothing Gaussian filter than FT8 (BT=2.0)
FT4_SLOT_TIME = 7.5 # T/R window in seconds (vs 15.0 for FT8)
TARGET_SAMPLE_RATE = 48000 # required by feed_tx_pcm (audio_stream.py)
def _gaussian_pulse_ft4(t, bt, symbol_period):
"""Identical formula to FT8's _gaussian_pulse, but bt and symbol_period
are FT4-specific parameters (BT=1.0, period=0.048s)."""
k = bt * 2 * np.pi / np.sqrt(np.log(2))
arg1 = k * (t / symbol_period - 0.5)
arg2 = k * (t / symbol_period + 0.5)
q1 = 0.5 * (1 - erf(arg1 / np.sqrt(2)))
q2 = 0.5 * (1 - erf(arg2 / np.sqrt(2)))
return q1 - q2
def _precompute_pulse_table_ft4():
n = FT4_SAMPLES_PER_SYMBOL
t = (np.arange(3 * n) - 1.5 * n) / FT4_SAMPLE_RATE
return _gaussian_pulse_ft4(t, FT4_BT, FT4_SYMBOL_PERIOD)
_PULSE_TABLE_FT4 = _precompute_pulse_table_ft4()
def synthesize_gfsk_ft4(symbols, base_freq_hz=1000.0):
"""103 symbols (0-3) -> numpy float32 PCM @ 12000Hz, normalized to -1..1.
Identical logic to FT8's synthesize_gfsk (summing overlapping Gaussian
frequency pulses), just with FT4's parameters (4 tones instead of 8,
smaller tone spacing, shorter symbol period)."""
n_sym = len(symbols)
n = FT4_SAMPLES_PER_SYMBOL
total_samples = n_sym * n
freq_dev = np.zeros(total_samples + 2 * n)
for i, sym in enumerate(symbols):
center = i * n + n
start = center - n
seg_start = max(0, start)
seg_end = min(len(freq_dev), start + 3 * n)
pulse_start = seg_start - start
pulse_end = pulse_start + (seg_end - seg_start)
freq_dev[seg_start:seg_end] += sym * FT4_TONE_SPACING * _PULSE_TABLE_FT4[pulse_start:pulse_end]
freq_dev = freq_dev[n:n + total_samples]
inst_freq = base_freq_hz + freq_dev
phase = 2 * np.pi * np.cumsum(inst_freq) / FT4_SAMPLE_RATE
return np.sin(phase).astype(np.float32)
def generate_tx_pcm48k_ft4(call_to, call_de, report_or_grid, r_flag=False,
base_freq_hz=1000.0, amplitude=0.12):
"""
Full pipeline: text -> 103 symbols -> 12kHz audio -> resample to 48kHz ->
int16 PCM bytes, ready for feed_tx_pcm(). The interface is identical to
ft8_encoder.generate_tx_pcm48k (same return type), so the calling code
(_ft8_tx_sequence_inner in webapp.py) can use either encoder
interchangeably depending on the selected mode.
Default amplitude=0.12 — the same value and the same anti-clipping
rationale as in ft8_encoder (see its docstring): the backend further
multiplies by txVolume (max 8.0), so 0.12*8.0=0.96 stays safely below clipping.
Returns (pcm_bytes, debug_dict, duration_seconds).
"""
from scipy.signal import resample_poly
symbols103, debug = encode_message_ft4(call_to, call_de, report_or_grid, r_flag)
audio_12k = synthesize_gfsk_ft4(symbols103, base_freq_hz=base_freq_hz)
audio_48k = resample_poly(audio_12k, up=4, down=1)
pcm16 = np.clip(audio_48k * 32767 * amplitude, -32768, 32767).astype(np.int16)
duration = len(audio_12k) / FT4_SAMPLE_RATE
return pcm16.tobytes(), debug, duration
if __name__ == "__main__":
# Self-test when the module is run directly
print("=== FT4 Encoder self-test ===")
print(f"FT4_SAMPLES_PER_SYMBOL = {FT4_SAMPLES_PER_SYMBOL}")
print(f"FT4_TONE_SPACING = {FT4_TONE_SPACING:.4f} Hz")
print(f"Transmission time (103 symbols) = {103 * FT4_SYMBOL_PERIOD:.3f}s")
test_messages = [
("CQ", "XX0XXX", "KO02"),
("XX0XXX", "DL1ABC", "-12"),
("DL1ABC", "XX0XXX", "R-08"),
("XX0XXX", "DL1ABC", "RRR"),
("DL1ABC", "XX0XXX", "73"),
]
for call_to, call_de, rg in test_messages:
symbols, debug = encode_message_ft4(call_to, call_de, rg)
status = "OK" if debug["ldpc_valid"] else "LDPC FAIL"
print(f" '{call_to} {call_de} {rg}' -> {len(symbols)} symbols, "
f"ldpc_valid={debug['ldpc_valid']} [{status}]")