Ctrl K
ComunidadGuías…

ft4_rx_decoder.py

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

Archivo original ↗Preguntar a KiBot
"""
FT4 RX Decoder - full pipeline: audio (~7.5s, 12000Hz, mono float) -> list
of decoded messages.

Stages (each verified independently):
  1. sync_ft4.find_candidates      - coarse position search (time, freq),
                                      uses 4 different Costas patterns (not 1x3)
  2. demod_ft4.extract_tone_power   - demodulation directly from the
                                      find_candidates position (WITHOUT
                                      refine_sync — see the rationale in
                                      decode_window below: profiling showed
                                      refine_sync was the bottleneck at
                                      ~8s/candidate for marginal quality gain)
  3. demod_ft4.extract_llr174       - soft LLRs for the 174 LDPC bits (2bit/symbol)
  4. ldpc_decode.bp_decode          - belief propagation (REUSED 1:1 from FT8 —
                                      the same Nm/Mn parity-check matrix)
  5. CRC-14 check                   - on the bits BEFORE de-scrambling (since
                                      the encoder computes the CRC on scrambled77,
                                      see ft4_encoder.py::encode_message_ft4)
  6. DESCRAMBLING                   - XOR with RVEC, the only FT4-specific
                                      step with no FT8 counterpart
  7. unpack.unpack77                - bits -> readable text (REUSED 1:1 from
                                      FT8 — operates on the "clean" 77 bits,
                                      identical regardless of the protocol)

Verified against a synthetic signal:
  - sync_ft4.find_candidates: detects a generated signal accurately to
    0.0Hz / 4ms on the raw grid, with no false positives
  - Full pipeline (WITHOUT refine_sync): 103/103 symbols recovered
    perfectly (100% match) on a clean signal; quality 0.94-1.00 and
    correct LDPC decode in tests with noise (std 0.0-0.5) and 8 simultaneous
    signals in the band (all 8/8 decoded correctly)
  - Performance: decode_window for 8 simultaneous signals = ~0.05s (vs 18.4s
    BEFORE removing refine_sync) — fits comfortably within the 7.5s window
  - Full round-trip (this file): TX->RX for various message types (CQ,
    report, R-report, RRR, 73, RR73) with correct output text

NOT YET VERIFIED against a real FT4 decoder or a real radio signal —
needs a live test, same as FT8 (see ft8_rx_decoder.py's docstring about
the 210703_133430.wav recording).

Usage:
    from ft4_rx_decoder import decode_window
    messages = decode_window(audio_75s_float_array)
    for m in messages:
        print(m['freq_hz'], m['message'], m['snr_db'])
"""
import numpy as np

import ft8_encoder as fe  # _crc14 is protocol-independent, reused directly
from params_ft4 import RVEC
from sync_ft4 import find_candidates
from demod_ft4 import extract_tone_power, costas_sync_quality, extract_llr174
from ldpc_decode import bp_decode
from unpack import unpack77, format_message


def _descramble77(bits77):
    """XOR with RVEC — its own inverse, the same operation as
    ft4_encoder._scramble77 (the same XOR works both ways)."""
    a = np.array(bits77, dtype=np.int32)
    r = np.array(RVEC, dtype=np.int32)
    return list(np.mod(a + r, 2))


def _compute_window_noise_floor(audio, sample_rate=12000):
    """Spectral PSD + noise floor for the whole FT4 window, computed once.
    See ft8_rx_decoder for the rationale — same approach, FT4 uses a shorter
    (7.5s) window but the SNR reference is identical: signal vs background."""
    nfft = 2048
    step = nfft // 2
    win = np.hanning(nfft)
    powers = []
    for i in range(0, len(audio) - nfft, step):
        powers.append(np.abs(np.fft.rfft(audio[i:i+nfft] * win)) ** 2)
    if not powers:
        return None, None, 1e-12
    psd = np.mean(powers, axis=0)
    freqs = np.fft.rfftfreq(nfft, 1.0 / sample_rate)
    band = (freqs >= 200) & (freqs <= 3000)
    nf = float(np.percentile(psd[band], 10)) if band.any() else 1e-12
    return psd, freqs, nf


# Same calibration as FT8 (measured against reference FT8 decodes).
_SNR_SCALE = 1.09
_SNR_OFFSET = -20.2

def _estimate_snr_db(psd, freqs, noise_floor, freq_hz):
    """Real SNR: signal power at its frequency vs the window's spectral noise
    floor, mapped to a familiar dB scale. Replaces the old tone-purity method
    that gave every station nearly the same value."""
    if psd is None or freqs is None:
        return 0.0
    b = (freqs >= freq_hz - 50) & (freqs <= freq_hz + 50)
    sig_power = float(np.max(psd[b])) if b.any() else 1e-12
    raw = 10.0 * np.log10(sig_power / (noise_floor + 1e-12))
    return float(np.clip(_SNR_SCALE * raw + _SNR_OFFSET, -28, 20))


def decode_window(audio, sample_rate=12000, min_score=0.15, max_candidates=60,
                   ldpc_max_iters=50, dedup_freq_hz=8.0, dedup_time_s=0.1):
    """
    Decodes one audio window (typically 7.5s, but works with any length
    >= roughly 6s) and returns a list of decoded messages.

    Returns: a list of dicts, sorted by freq_hz:
        {freq_hz, time_offset_s, message, call_to, call_de, report_or_grid,
         snr_db, ldpc_iters, sync_quality}
    """
    if sample_rate != 12000:
        raise ValueError(f"decode_window expects sample_rate=12000, got {sample_rate}")

    candidates = find_candidates(audio, max_candidates=max_candidates, min_score=min_score)
    _psd, _freqs, _noise_floor = _compute_window_noise_floor(audio, sample_rate)

    decoded = []
    for c in candidates:
        # NOTE: refine_sync is DELIBERATELY skipped here — profiling showed
        # it was the dominant bottleneck (~8s/candidate with its freq x
        # time search loop), while find_candidates' default oversampling
        # grid (time_osr=2, freq_osr=2) already gives sync_quality
        # 0.94-1.00 and flawless LDPC decoding in tests with 1-8
        # simultaneous signals and noise levels 0.0-0.5. Removing this step
        # sped up decode_window from 18.4s to ~0.05s for 8 signals,
        # necessary to fit within FT4's 7.5s window. The refine_sync
        # function still exists in demod_ft4.py (available for
        # debugging/future tuning), it's just not used in the default pipeline.
        power = extract_tone_power(audio, c['freq_hz'], c['time_offset_s'])
        quality = costas_sync_quality(power)
        rfreq, rtime = c['freq_hz'], c['time_offset_s']

        llr174 = extract_llr174(power)
        hard, success, n_iters = bp_decode(llr174, max_iters=ldpc_max_iters)
        if not success:
            continue

        bits91 = hard[:91]
        scrambled77 = bits91[:77]
        crc_received = bits91[77:91]
        # CRC is computed on the SCRAMBLED bits (matching the encoder — see
        # ft4_encoder.encode_message_ft4, where crc = _crc14(scrambled77+pad))
        padded82 = scrambled77 + [0, 0, 0, 0, 0]
        crc_check = fe._crc14(padded82)
        if crc_received != crc_check:
            continue

        # Descrambling happens ONLY AFTER CRC verification — recovers the
        # original 77 bits, structurally identical to what FT8's pack77
        # produces, so unpack77/format_message work without any changes.
        data77 = _descramble77(scrambled77)

        parsed = unpack77(data77)
        msg = format_message(parsed)
        snr_db = _estimate_snr_db(_psd, _freqs, _noise_floor, c['freq_hz'])

        decoded.append({
            'freq_hz': rfreq,
            'time_offset_s': rtime,
            'message': msg,
            'call_to': parsed['call_to'],
            'call_de': parsed['call_de'],
            'report_or_grid': parsed['report_or_grid'],
            'snr_db': round(snr_db, 1),
            'ldpc_iters': n_iters,
            'sync_quality': round(quality, 3),
        })

    decoded = _dedup(decoded, dedup_freq_hz, dedup_time_s)
    decoded.sort(key=lambda d: d['freq_hz'])
    return decoded


def _dedup(decoded, freq_tol_hz, time_tol_s):
    """Identical logic to FT8's _dedup."""
    if not decoded:
        return decoded
    decoded_sorted = sorted(decoded, key=lambda d: -d['snr_db'])
    kept = []
    for d in decoded_sorted:
        is_dup = False
        for k in kept:
            if (d['message'] == k['message'] and
                    abs(d['freq_hz'] - k['freq_hz']) <= freq_tol_hz and
                    abs(d['time_offset_s'] - k['time_offset_s']) <= time_tol_s):
                is_dup = True
                break
        if not is_dup:
            kept.append(d)
    return kept