""" Stage 2: Demodulation - from audio + a candidate position (freq_hz, time_offset_s) we extract 79 symbols (hard decision, for fast sync verification) and 174 soft LLRs (for full LDPC belief-propagation later). Bit<->symbol mapping: the 79 symbols contain 3x7=21 Costas sync symbols (ignored when decoding data) + 58 data symbols. Each data symbol encodes 3 Gray-mapped bits from the 174-bit LDPC(174,91) code. Matches our encoder (ft8_encoder.py): the _SYMBOL_GRAY Gray-coding table and the Costas placement at positions [0:7], [36:43], [72:79]. """ import numpy as np from params import SAMPLE_RATE, SAMPLES_PER_SYMBOL, N_TONES, COSTAS, COSTAS_POS, N_SYM # EXACTLY the same table as in ft8_encoder.py (_GRAYMAP), verified by # reading the source. idx (3-bit value 0-7) -> tone (transmitted symbol). GRAYMAP = [0, 1, 3, 2, 5, 6, 4, 7] # Inverse: tone -> idx (3-bit value), needed for decoding GRAYMAP_INV = [0] * 8 for _idx, _tone in enumerate(GRAYMAP): GRAYMAP_INV[_tone] = _idx # Positions of data symbols in the 79-symbol frame (after removing the # 3x7=21 Costas symbols): # symbols79 = Costas[0:7] + data[0:29] + Costas[36:43] + data[29:58] + Costas[72:79] DATA_SYM_INDICES = list(range(7, 36)) + list(range(43, 72)) assert len(DATA_SYM_INDICES) == 58 def extract_tone_power(audio, freq_hz, time_offset_s, freq_osr=2): """ For a given position (freq_hz = frequency of tone 0, time_offset_s = start of the first symbol), extracts the power matrix [79 symbols x 8 tones] via correlation with pure tones (Goertzel-like, via FFT on each symbol window). """ start_sample = int(round(time_offset_s * SAMPLE_RATE)) n = SAMPLES_PER_SYMBOL tone_spacing = 6.25 power = np.zeros((N_SYM, N_TONES)) window = np.hanning(n) for sym in range(N_SYM): s0 = start_sample + sym * n s1 = s0 + n if s0 < 0 or s1 > len(audio): power[sym, :] = 0 continue seg = audio[s0:s1] * window spec = np.fft.rfft(seg, n=n * 4) # zero-padding for better frequency resolution freqs = np.fft.rfftfreq(n * 4, d=1.0 / SAMPLE_RATE) for tone in range(N_TONES): f_target = freq_hz + tone * tone_spacing idx = np.argmin(np.abs(freqs - f_target)) power[sym, tone] = np.abs(spec[idx]) ** 2 return power def costas_sync_quality(power): """Measures how well the symbols at the Costas positions match the pattern (0..1).""" correct = 0 total = 0 for offset in COSTAS_POS: for k, expected_tone in enumerate(COSTAS): sym_idx = offset + k if sym_idx >= power.shape[0]: continue tone_detected = np.argmax(power[sym_idx]) if tone_detected == expected_tone: correct += 1 total += 1 return correct / total if total else 0.0 def hard_decode_symbols(power): """Returns 79 tone values (0-7) via a hard decision (argmax).""" return np.argmax(power, axis=1) def extract_bits174(power): """ From the power matrix [79 x 8], extracts 174 hard LDPC code bits, using EXACTLY the same Gray table as the encoder (inverted). """ tones = hard_decode_symbols(power) bits = [] for sym_idx in DATA_SYM_INDICES: tone = tones[sym_idx] idx3 = GRAYMAP_INV[tone] bits.append((idx3 >> 2) & 1) bits.append((idx3 >> 1) & 1) bits.append(idx3 & 1) assert len(bits) == 174 return bits def extract_llr174(power): """ From the power matrix [79 x 8], computes soft LLRs for the 174 LDPC bits. Positive LLR = bit more likely 0, negative = bit more likely 1 (convention: log(P(bit=0)/P(bit=1))). Simplified approach: for each of the 3 bit positions within a symbol, we sum (in the log domain) the power of tones that give bit=0 vs bit=1 according to the inverted Gray table, on a max-log-MAP basis. """ llrs = [] # tone_to_bits[tone] = (b0,b1,b2) corresponding to that tone tone_to_bits = {} for idx3 in range(8): tone = GRAYMAP[idx3] b0 = (idx3 >> 2) & 1 b1 = (idx3 >> 1) & 1 b2 = idx3 & 1 tone_to_bits[tone] = (b0, b1, b2) eps = 1e-12 for sym_idx in DATA_SYM_INDICES: p = power[sym_idx] + eps logp = np.log(p) for bit_pos in range(3): tones_0 = [t for t in range(8) if tone_to_bits[t][bit_pos] == 0] tones_1 = [t for t in range(8) if tone_to_bits[t][bit_pos] == 1] max0 = np.max(logp[tones_0]) max1 = np.max(logp[tones_1]) llrs.append(max0 - max1) assert len(llrs) == 174 return llrs def refine_sync(audio, freq_hz, time_offset_s, freq_search_hz=40.0, freq_step_hz=1.0, time_search_s=0.3, time_step_s=0.02): """ Refines a candidate's position (coarse from sync.py, quantized to the oversampling grid) via a local search around it, maximizing the Costas match quality. Necessary because demodulation is very sensitive to positional accuracy (e.g. 100% symbol agreement at the ideal position vs 14% when shifted by half a grid step). Returns (best_freq, best_time, best_power, best_quality). """ best_quality = -1 best_freq = freq_hz best_time = time_offset_s best_power = None freqs = np.arange(freq_hz - freq_search_hz, freq_hz + freq_search_hz + 1e-9, freq_step_hz) times = np.arange(time_offset_s - time_search_s, time_offset_s + time_search_s + 1e-9, time_step_s) for f in freqs: for t in times: if t < 0: continue power = extract_tone_power(audio, f, t) q = costas_sync_quality(power) if q > best_quality: best_quality = q best_freq = f best_time = t best_power = power return best_freq, best_time, best_power, best_quality