Ctrl K
ComunidadGuías…

demod_ft4.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
Stage 2 (FT4): Demodulation - from audio + a candidate position (freq_hz,
time_offset_s) we extract 103 symbols (hard decision) and 174 soft LLRs
(for full LDPC belief-propagation later).

Bit<->symbol mapping: the 103 symbols contain 4x4=16 Costas sync symbols
(ignored when decoding data) + 87 data symbols. Each data symbol encodes 2
Gray-mapped bits from the 174-bit LDPC(174,91) code. Matches our encoder
(ft4_encoder.py): _GRAYMAP_FT4 and the 4x Costas placement at positions
[0:4], [33:37], [66:70], [99:103].
"""
import numpy as np
from params_ft4 import (SAMPLE_RATE, SAMPLES_PER_SYMBOL, N_TONES,
                         COSTAS_PATTERNS, COSTAS_POS, N_SYM, TONE_SPACING,
                         GRAYMAP, INV_GRAYMAP)

# Positions of data symbols in the 103-symbol frame (after removing the
# 4x4=16 Costas symbols):
# symbols103 = C1[0:4] + data[0:29] + C2[33:37] + data[29:58] + C3[66:70] +
#              data[58:87] + C4[99:103]
DATA_SYM_INDICES = (list(range(4, 33)) + list(range(37, 66)) + list(range(70, 99)))
assert len(DATA_SYM_INDICES) == 87


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 [103 symbols x 4
    tones] via correlation with pure tones (FFT on each symbol window).

    VECTORIZED (unlike FT8's demod.py, which uses a plain Python loop with
    a separate FFT per symbol): for FT4 this function is called ~1500x
    within a single refine_sync() (the freq x time grid), and the
    non-vectorized loop version took ~8s per decode_window call — too slow
    relative to FT4's 7.5s window. Here we build the matrix of all 103
    segments at once and do ONE batched FFT (axis=1), instead of 103
    separate np.fft.rfft calls. Result is numerically identical to the
    loop-based version.
    """
    start_sample = int(round(time_offset_s * SAMPLE_RATE))
    n = SAMPLES_PER_SYMBOL
    nfft = n * 4
    window = np.hanning(n)

    # Build the matrix of segments [N_SYM x n], zero-filled for symbols
    # falling outside the available audio (same behavior as the loop
    # version: power[sym,:]=0 for such positions)
    segs = np.zeros((N_SYM, n), dtype=np.float64)
    valid = np.ones(N_SYM, dtype=bool)
    for sym in range(N_SYM):
        s0 = start_sample + sym * n
        s1 = s0 + n
        if s0 < 0 or s1 > len(audio):
            valid[sym] = False
            continue
        segs[sym] = audio[s0:s1]

    segs *= window[None, :]  # Hann window applied to every row at once

    # ONE batched FFT instead of 103 separate calls
    spec = np.fft.rfft(segs, n=nfft, axis=1)  # (N_SYM, nfft//2+1)
    freqs = np.fft.rfftfreq(nfft, d=1.0 / SAMPLE_RATE)

    # Bin indices for the 4 tones are the same for every symbol (they only
    # depend on freq_hz), so compute them once, not in the loop
    tone_idx = np.array([np.argmin(np.abs(freqs - (freq_hz + tone * TONE_SPACING)))
                          for tone in range(N_TONES)])

    power = np.abs(spec[:, tone_idx]) ** 2  # (N_SYM, N_TONES)
    power[~valid, :] = 0
    return power


def costas_sync_quality(power):
    """Measures how well the symbols at the Costas positions match EACH
    block's OWN pattern (different patterns at different positions, unlike
    FT8 where it's the same pattern everywhere)."""
    correct = 0
    total = 0
    for offset, pattern in zip(COSTAS_POS, COSTAS_PATTERNS):
        for k, expected_tone in enumerate(pattern):
            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 103 tone values (0-3) via a hard decision (argmax)."""
    return np.argmax(power, axis=1)


def extract_bits174(power):
    """
    From the power matrix [103 x 4], extracts 174 hard LDPC code bits
    (BEFORE de-scrambling), using EXACTLY the same Gray table as the
    encoder (inverted, 2 bits/symbol instead of 3).
    """
    tones = hard_decode_symbols(power)
    bits = []
    for sym_idx in DATA_SYM_INDICES:
        tone = tones[sym_idx]
        idx2 = INV_GRAYMAP[tone]
        bits.append((idx2 >> 1) & 1)
        bits.append(idx2 & 1)
    assert len(bits) == 174
    return bits


def extract_llr174(power):
    """
    From the power matrix [103 x 4], computes soft LLRs for the 174 LDPC
    bits (BEFORE de-scrambling — the scrambling is only undone AFTER LDPC
    decode, at the plaintext-bits level, mirroring ft4_encoder.py where
    scrambling is applied BEFORE the CRC/LDPC encode).

    Same max-log-MAP method as FT8, adapted for 2 bits/symbol (4 tones)
    instead of 3 bits/symbol (8 tones).
    """
    llrs = []
    tone_to_bits = {}
    for idx2 in range(4):
        tone = GRAYMAP[idx2]
        b0 = (idx2 >> 1) & 1
        b1 = idx2 & 1
        tone_to_bits[tone] = (b0, b1)

    eps = 1e-12
    for sym_idx in DATA_SYM_INDICES:
        p = power[sym_idx] + eps
        logp = np.log(p)
        for bit_pos in range(2):
            tones_0 = [t for t in range(4) if tone_to_bits[t][bit_pos] == 0]
            tones_1 = [t for t in range(4) 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=12.0, freq_step_hz=3.0,
                 time_search_s=0.03, time_step_s=0.006):
    """
    Refines a candidate's position via a local search, maximizing the
    quality of the 4x Costas match.

    The default ranges are DELIBERATELY NARROW: limited to roughly +-1
    oversampling grid step from find_candidates() (freq_osr=2 -> ~10.4Hz
    step, time_osr=2 -> ~24ms step), because sync_ft4.find_candidates
    already runs on an oversampled grid and typically lands very close to
    the true position. Testing showed: this narrow grid (99 iterations
    instead of 1558 for a wider, FT8-style grid) still gives
    sync_quality=1.0 and >=100/103 correct symbols even with added noise —
    the remaining up to 3 errors get corrected by LDPC belief propagation
    anyway. This ~15x speedup is NECESSARY for decode_window to fit within
    FT4's 7.5s window with many simultaneous candidates in the band
    (unlike FT8, where the 15s window gives more time margin).
    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