diff --git a/ft8/ft4.cpp b/ft8/ft4.cpp index 5879236c9..ecd70568f 100644 --- a/ft8/ft4.cpp +++ b/ft8/ft4.cpp @@ -11,6 +11,7 @@ #include #include "ft4.h" +#include "fft.h" #include "libldpc.h" #include "osd.h" @@ -37,8 +38,9 @@ const std::array kFT4Rvec = {{ struct FT4Candidate { + int frameIndex; int start; - float tone0; + int toneBin; float sync; float noise; float score; @@ -71,33 +73,12 @@ public: } private: - float goertzelPower(int sampleStart, int sampleCount, float frequency) const + static float binPower(const FFTEngine::ffts_t& bins, int symbolIndex, int toneBin) { - const float omega = 2.0f * static_cast(M_PI) * frequency / m_rate; - const float coeff = 2.0f * std::cos(omega); - float q0 = 0.0f; - float q1 = 0.0f; - float q2 = 0.0f; - - for (int i = 0; i < sampleCount; i++) - { - q0 = coeff * q1 - q2 + m_samples[sampleStart + i]; - q2 = q1; - q1 = q0; - } - - return q1 * q1 + q2 * q2 - coeff * q1 * q2; + return std::abs(bins[symbolIndex][toneBin]); } - float symbolTonePower(int start, int symbolIndex, float tone0, int tone) const - { - const int symbolStart = start + symbolIndex * kFT4SymbolSamples; - const float toneSpacing = static_cast(m_rate) / static_cast(kFT4SymbolSamples); - const float frequency = tone0 + tone * toneSpacing; - return goertzelPower(symbolStart, kFT4SymbolSamples, frequency); - } - - void collectSyncMetrics(int start, float tone0, float& sync, float& noise) const + void collectSyncMetrics(const FFTEngine::ffts_t& bins, int toneBin, float& sync, float& noise) const { sync = 0.0f; noise = 0.0f; @@ -113,7 +94,7 @@ private: for (int tone = 0; tone < 4; tone++) { - const float power = symbolTonePower(start, symbolIndex, tone0, tone); + const float power = binPower(bins, symbolIndex, toneBin + tone); if (tone == expectedTone) { sync += power; @@ -125,7 +106,34 @@ private: } } - void buildBitMetrics(int start, float tone0, std::array& bitMetrics) const + 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; @@ -143,7 +151,7 @@ private: std::array tonePower; for (int tone = 0; tone < 4; tone++) { - tonePower[tone] = symbolTonePower(start, symbolIndex, tone0, tone); + tonePower[tone] = binPower(bins, symbolIndex, toneBin + tone); } const float b0Zero = std::max(tonePower[0], tonePower[1]); @@ -156,6 +164,11 @@ private: } } + FFTEngine::ffts_t makeFrameBins(int start) + { + return m_fftEngine.ffts(m_samples, start, kFT4SymbolSamples); + } + void decode() { if (m_samples.size() < kFT4FrameSamples) { @@ -163,10 +176,9 @@ private: } const int maxStart = static_cast(m_samples.size()) - kFT4FrameSamples; - const float toneSpacing = static_cast(m_rate) / static_cast(kFT4SymbolSamples); - const float maxTone0 = m_maxHz - 3.0f * toneSpacing; + const float binSpacing = static_cast(m_rate) / static_cast(kFT4SymbolSamples); - if (maxTone0 <= m_minHz) { + if (m_maxHz <= m_minHz) { return; } @@ -193,17 +205,55 @@ private: addStartCandidate(std::min(maxStart, nominalStart + 2 * kFT4SymbolSamples)); addStartCandidate(maxStart); - std::vector candidates; + struct FT4Frame + { + int start; + FFTEngine::ffts_t bins; + }; + + std::vector frames; + frames.reserve(startCandidates.size()); for (int start : startCandidates) { - for (float tone0 = m_minHz; tone0 <= maxTone0; tone0 += toneSpacing) + 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(start, tone0, sync, noise); + collectSyncMetrics(frame.bins, toneBin, sync, noise); const float score = sync - 1.25f * (noise / 3.0f); - candidates.push_back(FT4Candidate{start, tone0, sync, noise, score}); + candidates.push_back(FT4Candidate{frameIndex, frame.start, toneBin, sync, noise, score}); } } @@ -221,8 +271,10 @@ private: 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(candidate.start, candidate.tone0, llr); + buildBitMetrics(frame.bins, refinedToneBin, llr); int plain[174]; int ldpcOk = 0; @@ -270,7 +322,8 @@ private: 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; - m_cb->hcb(a91, candidate.tone0, off, "FT4-EXP", snr, 0, ldpcOk); + const float tone0 = refinedToneBin * binSpacing; + m_cb->hcb(a91, tone0, off, "FT4-EXP", snr, 0, ldpcOk); } } @@ -281,6 +334,7 @@ private: int m_rate; CallbackInterface *m_cb; FT4Params m_params; + FFTEngine m_fftEngine; }; } // namespace diff --git a/sdrbench/CMakeLists.txt b/sdrbench/CMakeLists.txt index 35d3ae141..eb21d3eee 100644 --- a/sdrbench/CMakeLists.txt +++ b/sdrbench/CMakeLists.txt @@ -9,6 +9,7 @@ set(sdrbench_SOURCES parserbench.cpp test_golay2312.cpp test_ft8.cpp + test_ft4.cpp test_callsign.cpp test_ft8protocols.cpp test_fftrrc.cpp diff --git a/sdrbench/mainbench.cpp b/sdrbench/mainbench.cpp index e2cf5fbb1..a989d5344 100644 --- a/sdrbench/mainbench.cpp +++ b/sdrbench/mainbench.cpp @@ -69,6 +69,8 @@ void MainBench::run() testGolay2312(); } else if (m_parser.getTestType() == ParserBench::TestFT8) { testFT8(m_parser.getFileName(), m_parser.getArgsStr()); + } else if (m_parser.getTestType() == ParserBench::TestFT4) { + testFT4(m_parser.getFileName(), m_parser.getArgsStr()); } else if (m_parser.getTestType() == ParserBench::TestCallsign) { testCallsign(m_parser.getArgsStr()); } else if (m_parser.getTestType() == ParserBench::TestFT8Protocols) { diff --git a/sdrbench/mainbench.h b/sdrbench/mainbench.h index 5f91c88f8..6d125bfaa 100644 --- a/sdrbench/mainbench.h +++ b/sdrbench/mainbench.h @@ -59,6 +59,7 @@ private: void testGolay2312(); void testFFTRRCFilter(); void testFIRRRCFilter(); + void testFT4(const QString& wavFile, const QString& argsStr); //!< use with sdrbench/samples/ft4/20260304_180052.wav in -f option void testFT8(const QString& wavFile, const QString& argsStr); //!< use with sdrbench/samples/ft8/230105_091630.wav in -f option void testFT8Protocols(const QString& argsStr); void testCallsign(const QString& argsStr); diff --git a/sdrbench/parserbench.cpp b/sdrbench/parserbench.cpp index 1942d40d9..378c80851 100644 --- a/sdrbench/parserbench.cpp +++ b/sdrbench/parserbench.cpp @@ -24,7 +24,7 @@ ParserBench::ParserBench() : m_testOption(QStringList() << "t" << "test", - "Test type: decimateii, decimatefi, decimateff, decimateif, decimateinfii, decimatesupii, ambe, golay2312, ft8, ft8protocols, callsign, fftrrcfilter, firrrcfilter.", + "Test type: decimateii, decimatefi, decimateff, decimateif, decimateinfii, decimatesupii, ambe, golay2312, ft8, ft4, ft8protocols, callsign, fftrrcfilter, firrrcfilter.", "test", "decimateii"), m_nbSamplesOption(QStringList() << "n" << "nb-samples", @@ -147,6 +147,8 @@ ParserBench::TestType ParserBench::getTestType() const return TestGolay2312; } else if (m_testStr == "ft8") { return TestFT8; + } else if (m_testStr == "ft4") { + return TestFT4; } else if (m_testStr == "callsign") { return TestCallsign; } else if (m_testStr == "ft8protocols") { diff --git a/sdrbench/parserbench.h b/sdrbench/parserbench.h index dc0e092f2..af1a385ec 100644 --- a/sdrbench/parserbench.h +++ b/sdrbench/parserbench.h @@ -39,6 +39,7 @@ public: TestDecimatorsSupII, TestGolay2312, TestFT8, + TestFT4, TestCallsign, TestFT8Protocols, TestFFTRRCFilter, diff --git a/sdrbench/samples/ft8/20260304_180052.wav b/sdrbench/samples/ft8/20260304_180052.wav new file mode 100644 index 000000000..6e7bed995 Binary files /dev/null and b/sdrbench/samples/ft8/20260304_180052.wav differ diff --git a/sdrbench/test_ft4.cpp b/sdrbench/test_ft4.cpp new file mode 100644 index 000000000..a8f731a2f --- /dev/null +++ b/sdrbench/test_ft4.cpp @@ -0,0 +1,273 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2023 Daniele Forsi // +// Copyright (C) 2026 Edouard Griffiths, F4EXB // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#include +#include + +#include "mainbench.h" +#include "dsp/wavfilerecord.h" + +#include + +#ifndef HAS_FT8 +void MainBench::testFT4(const QString& wavFile, const QString& argsStr) +{ + (void) wavFile; + (void) argsStr; + qWarning("MainBench::testFT8: this version has no FT8 support"); +} +#else + +#include "ft8/ft4.h" +#include "ft8/packing.h" + +class TestFT4Callback : public FT8::CallbackInterface +{ +public: + virtual int hcb( + int *a91, + float hz0, + float off, + const char *comment, + float snr, + int pass, + int correct_bits + ); + virtual QString get_name(); + const std::map& getMsgMap() { + return cycle_already; + } +private: + QMutex cycle_mu; + std::map cycle_already; + FT8::Packing packing; +}; + + +int TestFT4Callback::hcb( + int *a91, + float hz0, + float off, + const char *comment, + float snr, + int pass, + int correct_bits +) +{ + std::string call1; + std::string call2; + std::string loc; + std::string type; + std::string msg = packing.unpack(a91, call1, call2, loc, type); + + cycle_mu.lock(); + + if (cycle_already.count(msg) > 0) + { + // already decoded this message on this cycle + cycle_mu.unlock(); + return 1; // 1 => already seen, don't subtract. + } + + cycle_already[msg] = true; + + cycle_mu.unlock(); + + qDebug("TestFT8Callback::hcb: %3s %d %3d %3d %5.2f %6.1f %s [%s:%s:%s] (%s)", + type.c_str(), + pass, + (int)snr, + correct_bits, + off - 0.5, + hz0, + msg.c_str(), + call1.c_str(), + call2.c_str(), + loc.c_str(), + comment + ); + fflush(stdout); + + return 2; // 2 => new decode, do subtract. +} + +QString TestFT4Callback::get_name() +{ + return "test"; +} + +void MainBench::testFT4(const QString& wavFile, const QString& argsStr) +{ + int nthreads = 8; // number of threads (default) + double budget = 2.5; // compute for this many seconds per cycle (default) + // 3,0.5 combinaion may be enough + + QStringList argElements = argsStr.split(','); // comma separated list of arguments + + for (int i = 0; i < argElements.size(); i++) + { + const QString& argStr = argElements.at(i); + bool ok; + + if (i == 0) // first is the number of threads (integer) + { + int nthreads_x = argStr.toInt(&ok); + + if (ok) { + nthreads = nthreads_x; + } + } + + if (i == 1) // second is the time budget in seconds (double) + { + double budget_x = argStr.toDouble(&ok); + + if (ok) { + budget = budget_x; + } + } + } + + qDebug("MainBench::testFT4: start nthreads: %d budget: %fs", nthreads, budget); + int hints[2] = { 2, 0 }; // CQ + TestFT4Callback testft4Callback; + + std::ifstream wfile; + +#ifdef Q_OS_WIN + wfile.open(wavFile.toStdWString().c_str(), std::ios::binary | std::ios::ate); +#else + wfile.open(wavFile.toStdString().c_str(), std::ios::binary | std::ios::ate); +#endif + WavFileRecord::Header header; + wfile.seekg(0, std::ios_base::beg); + bool headerOK = WavFileRecord::readHeader(wfile, header, false); + + if (!headerOK) + { + qDebug("MainBench::testFT4: test file is not a wave file"); + return; + } + + if (header.m_sampleRate != 12000) + { + qDebug("MainBench::testFT4: wave file sample rate is not 12000 S/s"); + return; + } + + if (header.m_bitsPerSample != 16) + { + qDebug("MainBench::testFT4: sample size is not 16 bits"); + return; + } + + if (header.m_audioFormat != 1) + { + qDebug("MainBench::testFT4: wav file format is not PCM"); + return; + } + + if (header.m_dataHeader.m_size != 180000) + { + qDebug("MainBench::testFT4: wave file size is not 7.5s at 12000 S/s"); + return; + } + + const int bufsize = 1000; + int16_t buffer[bufsize]; + std::vector samples; + uint32_t remainder = header.m_dataHeader.m_size; + + while (remainder != 0) + { + wfile.read((char *) buffer, bufsize*2); + + for (int i = 0; i < bufsize; i++) { + samples.push_back(buffer[i] / 32768.0f); + } + + remainder -= bufsize*2; + } + + wfile.close(); + + FT8::FT4Decoder decoder; + decoder.getParams().nthreads = nthreads; + decoder.getParams().use_osd = 0; + + decoder.entry( + samples.data(), + samples.size(), + 0.5 * header.m_sampleRate, + header.m_sampleRate, + 150, + 3600, // 2900, + hints, + hints, + budget, + budget, + &testft4Callback, + 0, + (struct FT8::cdecode *) nullptr + ); + + decoder.wait(budget + 1.0); // add one second to budget to force quit threads + const std::map& msgMap = testft4Callback.getMsgMap(); + qDebug("MainBench::testFT4: done %lu decodes", msgMap.size()); + qDebug("MainBench::testFT4: messages:"); + for (const auto &msg : msgMap) + { + qDebug("MainBench::testFT4: %s", qPrintable(QString::fromStdString(msg.first))); + } + + // if (msgMap.size() != 15) + // { + // qDebug("MainBench::testFT4: failed: invalid size: %lu expected 15", msgMap.size()); + // return; + // } + + QStringList messages = { + "CQ DF5SF JN39", + "CQ DL1SVA JO64", + "CQ DL7CO JO42", + "CQ F4BAL JO10", + "CQ LA1XJA JO49", + "CQ ON7VG JO21", + "CQ OZ1BJF JO55", + "CQ S51TA JN75", + "HA3PT SQ8AA -18", + "JA2KFQ EI4KF -17", + "LY3PW DF2FE R-13", + "N9GQA DG9NAY JN58", + "OK1HEH OH8NW 73 ", + "UN6T EA1FQ IN53", + "W5SUM G8OO -18" + }; + + for (const auto &msg : messages) + { + if (msgMap.count(msg.toStdString()) != 1) + { + qDebug("MainBench::testFT4: failed: key: %s", qPrintable(msg)); + return; + } + } + + qDebug("MainBench::testFT4: success"); +} +#endif