/////////////////////////////////////////////////////////////////////////////////// // Copyright (C) 2026 // // // // Experimental FT4 decoder scaffold derived from FT8 decoder architecture. // /////////////////////////////////////////////////////////////////////////////////// #include #include #include #include #include "ft4.h" #include "fft.h" #include "libldpc.h" #include "osd.h" namespace FT8 { namespace { constexpr int kFT4SymbolSamples = 576; constexpr int kFT4TotalSymbols = 103; constexpr int kFT4FrameSamples = kFT4TotalSymbols * kFT4SymbolSamples; const std::array, 4> kFT4SyncTones = {{ {{0, 1, 3, 2}}, {{1, 0, 2, 3}}, {{2, 3, 1, 0}}, {{3, 2, 0, 1}} }}; const std::array kFT4Rvec = {{ 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 }}; struct FT4Candidate { int frameIndex; int start; int toneBin; float sync; float noise; float score; }; class FT4Worker : public QObject { public: FT4Worker( const std::vector& samples, float minHz, float maxHz, int start, int rate, CallbackInterface *cb, const FT4Params& params ) : m_samples(samples), m_minHz(minHz), m_maxHz(maxHz), m_start(start), m_rate(rate), m_cb(cb), m_params(params) {} void start_work() { decode(); } private: static float binPower(const FFTEngine::ffts_t& bins, int symbolIndex, int toneBin) { return std::abs(bins[symbolIndex][toneBin]); } void collectSyncMetrics(const FFTEngine::ffts_t& bins, int toneBin, float& sync, float& noise) const { sync = 0.0f; noise = 0.0f; for (int block = 0; block < 4; block++) { const int symbolOffset = block * 33; for (int index = 0; index < 4; index++) { const int symbolIndex = symbolOffset + index; const int expectedTone = kFT4SyncTones[block][index]; for (int tone = 0; tone < 4; tone++) { const float power = binPower(bins, symbolIndex, toneBin + tone); if (tone == expectedTone) { sync += power; } else { noise += power; } } } } } int refineToneBin(const FFTEngine::ffts_t& bins, int toneBin) const { float bestSync = -1.0f; int bestToneBin = toneBin; for (int delta = -1; delta <= 1; delta++) { int candidateToneBin = toneBin + delta; if (candidateToneBin < 0) { continue; } float sync = 0.0f; float noise = 0.0f; collectSyncMetrics(bins, candidateToneBin, sync, noise); if (sync > bestSync) { bestSync = sync; bestToneBin = candidateToneBin; } } return bestToneBin; } void buildBitMetrics(const FFTEngine::ffts_t& bins, int toneBin, std::array& bitMetrics) const { int bitIndex = 0; for (int symbolIndex = 0; symbolIndex < kFT4TotalSymbols; symbolIndex++) { const bool inSync = (symbolIndex < 4) || (symbolIndex >= 33 && symbolIndex < 37) || (symbolIndex >= 66 && symbolIndex < 70) || (symbolIndex >= 99); if (inSync) { continue; } std::array tonePower; for (int tone = 0; tone < 4; tone++) { tonePower[tone] = binPower(bins, symbolIndex, toneBin + tone); } const float b0Zero = std::max(tonePower[0], tonePower[1]); const float b0One = std::max(tonePower[2], tonePower[3]); const float b1Zero = std::max(tonePower[0], tonePower[3]); const float b1One = std::max(tonePower[1], tonePower[2]); bitMetrics[bitIndex++] = b0Zero - b0One; bitMetrics[bitIndex++] = b1Zero - b1One; } } FFTEngine::ffts_t makeFrameBins(int start) { return m_fftEngine.ffts(m_samples, start, kFT4SymbolSamples); } void decode() { if (m_samples.size() < kFT4FrameSamples) { return; } const int maxStart = static_cast(m_samples.size()) - kFT4FrameSamples; const float binSpacing = static_cast(m_rate) / static_cast(kFT4SymbolSamples); if (m_maxHz <= m_minHz) { return; } std::vector startCandidates; auto addStartCandidate = [&](int start) { start = std::max(0, std::min(start, maxStart)); if (std::find(startCandidates.begin(), startCandidates.end(), start) == startCandidates.end()) { startCandidates.push_back(start); } }; for (int start = 0; start <= std::min(maxStart, 14000); start += kFT4SymbolSamples) { addStartCandidate(start); } const int nominalStart = maxStart / 2; addStartCandidate(std::max(0, nominalStart - 2 * kFT4SymbolSamples)); addStartCandidate(std::max(0, nominalStart - kFT4SymbolSamples)); addStartCandidate(nominalStart); addStartCandidate(std::min(maxStart, nominalStart + kFT4SymbolSamples)); addStartCandidate(std::min(maxStart, nominalStart + 2 * kFT4SymbolSamples)); addStartCandidate(maxStart); struct FT4Frame { int start; FFTEngine::ffts_t bins; }; std::vector frames; frames.reserve(startCandidates.size()); for (int start : startCandidates) { FFTEngine::ffts_t bins = makeFrameBins(start); if (bins.size() < kFT4TotalSymbols) { continue; } if (bins[0].size() < 8) { continue; } frames.push_back(FT4Frame{start, std::move(bins)}); } if (frames.empty()) { return; } const int minToneBin = std::max(0, static_cast(std::ceil(m_minHz / binSpacing))); const int maxToneBinByHz = static_cast(std::floor(m_maxHz / binSpacing)) - 3; if (maxToneBinByHz < minToneBin) { return; } std::vector candidates; for (int frameIndex = 0; frameIndex < static_cast(frames.size()); frameIndex++) { const FT4Frame& frame = frames[frameIndex]; const int maxToneBin = std::min(maxToneBinByHz, static_cast(frame.bins[0].size()) - 4); for (int toneBin = minToneBin; toneBin <= maxToneBin; toneBin++) { float sync = 0.0f; float noise = 0.0f; collectSyncMetrics(frame.bins, toneBin, sync, noise); const float score = sync - 1.25f * (noise / 3.0f); candidates.push_back(FT4Candidate{frameIndex, frame.start, toneBin, sync, noise, score}); } } if (candidates.empty()) { return; } std::sort(candidates.begin(), candidates.end(), [](const FT4Candidate& lhs, const FT4Candidate& rhs) { return lhs.score > rhs.score; }); const int maxDecodeCandidates = std::min(m_params.max_candidates, static_cast(candidates.size())); const int ldpcThreshold = std::max(48, m_params.osd_ldpc_thresh - 18); for (int candidateIndex = 0; candidateIndex < maxDecodeCandidates; candidateIndex++) { const FT4Candidate& candidate = candidates[candidateIndex]; const FT4Frame& frame = frames[candidate.frameIndex]; const int refinedToneBin = refineToneBin(frame.bins, candidate.toneBin); std::array llr; buildBitMetrics(frame.bins, refinedToneBin, llr); int plain[174]; int ldpcOk = 0; LDPC::ldpc_decode(llr.data(), m_params.ldpc_iters, plain, &ldpcOk); if (ldpcOk < ldpcThreshold) { continue; } int a174[174]; for (int i = 0; i < 174; i++) { a174[i] = plain[i]; } bool crcOk = OSD::check_crc(a174); if (!crcOk && m_params.use_osd) { int oplain[91]; int depth = -1; std::array llrCopy = llr; if (OSD::osd_decode(llrCopy.data(), m_params.osd_depth, oplain, &depth)) { OSD::ldpc_encode(oplain, a174); crcOk = OSD::check_crc(a174); } } if (!crcOk) { continue; } int a91[91]; for (int i = 0; i < 91; i++) { a91[i] = a174[i]; } for (int i = 0; i < 77; i++) { a91[i] ^= kFT4Rvec[i]; } const float snrLinear = (candidate.sync + 1.0e-9f) / ((candidate.noise / 3.0f) + 1.0e-9f); const float snr = 10.0f * std::log10(snrLinear) - 12.0f; const float off = static_cast(m_start) / m_rate + static_cast(candidate.start) / m_rate; const float tone0 = refinedToneBin * binSpacing; m_cb->hcb(a91, tone0, off, "FT4-EXP", snr, 0, ldpcOk); } } std::vector m_samples; float m_minHz; float m_maxHz; int m_start; int m_rate; CallbackInterface *m_cb; FT4Params m_params; FFTEngine m_fftEngine; }; } // namespace FT4Decoder::~FT4Decoder() { forceQuit(); } void FT4Decoder::entry( float xsamples[], int nsamples, int start, int rate, float min_hz, float max_hz, int hints1[], int hints2[], double time_left, double total_time_left, CallbackInterface *cb, int nprevdecs, struct cdecode *xprevdecs ) { (void) hints1; (void) hints2; (void) time_left; (void) total_time_left; (void) nprevdecs; (void) xprevdecs; std::vector samples(nsamples); for (int i = 0; i < nsamples; i++) { samples[i] = xsamples[i]; } if (min_hz < 0) { min_hz = 0; } if (max_hz > rate / 2) { max_hz = rate / 2; } const float per = (max_hz - min_hz) / params.nthreads; for (int i = 0; i < params.nthreads; i++) { float hz0 = min_hz + i * per; float hz1 = min_hz + (i + 1) * per; hz0 = std::max(hz0, 0.0f); hz1 = std::min(hz1, (rate / 2.0f) - 50.0f); FT4Worker *ft4 = new FT4Worker(samples, hz0, hz1, start, rate, cb, params); QThread *th = new QThread(); th->setObjectName(QString("ft4:%1:%2").arg(cb->get_name()).arg(i)); threads.push_back(th); ft4->moveToThread(th); QObject::connect(th, &QThread::started, ft4, [ft4, th]() { ft4->start_work(); th->quit(); }); QObject::connect(th, &QThread::finished, ft4, &QObject::deleteLater); QObject::connect(th, &QThread::finished, th, &QObject::deleteLater); th->start(); } } void FT4Decoder::wait(double time_left) { unsigned long thread_timeout = time_left * 1000; while (!threads.empty()) { bool success = threads.front()->wait(thread_timeout); if (!success) { qDebug("FT8::FT4Decoder::wait: thread timed out"); thread_timeout = 50; } threads.erase(threads.begin()); } } void FT4Decoder::forceQuit() { while (!threads.empty()) { threads.front()->quit(); threads.front()->wait(); threads.erase(threads.begin()); } } } // namespace FT8