/** * (C) 2025 Allan Bazinet - All Rights Reserved **/ #include "JS8.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // A C++ conversion of the Fortran JS8 encoding and decoder function. // Some notes on the conversion: // // 1. Names of variables and functions as much as possible match those // of the Fortran routines, for ease in cross-referencing during the // debug comparison phase of testing. You don't have to like them; I // don't like them either, frankly, but it's the reasonable approach // to the problem as of this writing; we can make 'em pretty later. // // 2. The BP decoder should be a faithful reproduction of the Fortran // version, albeit modified for the column-major vs. row-major // differences between the two languages. // // 3. In contrast, the OSD Fortran decoder is particularly complicated, // so our version here should in theory work the same, but that's by // no means guaranteed at the moment. // // 4. The Fortran version didn't compute the 40% rank consistently in // syncjs8(); this version does. It wasn't typically off by much, but // it was reliably not going to be at 40%. Hopefully, this change will // result in more predictable first-pass candidate selection. // // 5. The Fortran version was very subject to Runge's phenomenon when // computing the baseline in baselinejs8(), and was using a ton of // data points below the 10% threshold for the polynomial determination. // Neither of these seemed to be helpful, so in contrast we're using // a number of Chebyshev nodes proportional to the desired polynomial // terms. // // 6. Translating aray indices from the world of Fortran to that of C++ // is no one's fun task. If you see things that aren't behaving as // expected, look at the Fortran code and compare the array indexing; // would not be surprised in the least to have off-by-one errors here. /******************************************************************************/ // Here be Dragons /******************************************************************************/ // Avoiding namespace pollution issues due to things like #define NSPS // in the mainline for the moment, while testing this in parallel. XXX extern std::mutex fftw_mutex; extern struct dec_data { short int d2[720000]; // sample frame buffer for sample collection struct { int nutc; // UTC as integer, HHMM bool ndiskdat; // true ==> data read from *.wav file int nfqso; // User-selected QSO freq (kHz) bool newdat; // true ==> new data, must do long FFT int npts8; // npts for c0() array int nfa; // Low decode limit (Hz) (filter min) int nfb; // High decode limit (Hz) (filter max) bool syncStats; // only compute sync candidates int kin; // number of frames written to d2 int kposA; // starting position of decode for submode A int kposB; // starting position of decode for submode B int kposC; // starting position of decode for submode C int kposE; // starting position of decode for submode E int kposI; // starting position of decode for submode I int kszA; // number of frames for decode for submode A int kszB; // number of frames for decode for submode B int kszC; // number of frames for decode for submode C int kszE; // number of frames for decode for submode E int kszI; // number of frames for decode for submode I int nsubmode; // which submode to decode (-1 if using nsubmodes) int nsubmodes; // which submodes to decode bool nagain; int ndepth; int napwid; int nranera; } params; } dec_data; /******************************************************************************/ // Compilation Utilities /******************************************************************************/ namespace { // Full-range cosine function using symmetries of cos(x). std::cos // isn't constexpr until C++20, and we're targeting C++17 at the // moment. We only use this function during compilation; std::cos // is the better choice at runtime. Once we move to requiring a // C++20 compiler, we can just use std::cos. constexpr auto cos(double x) { constexpr auto RAD_360 = M_PI * 2; constexpr auto RAD_180 = M_PI; constexpr auto RAD_90 = M_PI_2; // Polynomial approximation of cos(x) for x in [0, RAD_90], // Accuracy here in theory is 1e-18, but double precision // itself is only 1-e16, so within the domain of doubles, // this should be extremely accurate. constexpr auto cos = [](double const x) { constexpr std::array coefficients = { 1.0, // Coefficient for x^0 -0.49999999999999994, // Coefficient for x^2 0.041666666666666664, // Coefficient for x^4 -0.001388888888888889, // Coefficient for x^6 0.000024801587301587, // Coefficient for x^8 -0.00000027557319223986, // Coefficient for x^10 0.00000000208767569878681, // Coefficient for x^12 -0.00000000001147074513875176, // Coefficient for x^14 0.0000000000000477947733238733 // Coefficient for x^16 }; auto const x2 = x * x; auto const x4 = x2 * x2; auto const x6 = x4 * x2; auto const x8 = x4 * x4; auto const x10 = x8 * x2; auto const x12 = x8 * x4; auto const x14 = x12 * x2; auto const x16 = x8 * x8; return coefficients[0] + coefficients[1] * x2 + coefficients[2] * x4 + coefficients[3] * x6 + coefficients[4] * x8 + coefficients[5] * x10 + coefficients[6] * x12 + coefficients[7] * x14 + coefficients[8] * x16; }; // Reduce x to [0, RAD_360) x -= static_cast(x / RAD_360) * RAD_360; // Map x to [0, RAD_180] if (x > RAD_180) x = RAD_360 - x; // Map x to [0, RAD_90] and evaluate the polynomial; // flip the sign for angles in the second quadrant. return x > RAD_90 ? -cos(RAD_180 - x) : cos(x); }; } /******************************************************************************/ // Constants /******************************************************************************/ namespace { /* COMMON PARAMETERS */ // !Common // // parameter (KK=87) !Information bits (75 + CRC12) // parameter (ND=58) !Data symbols // parameter (NS=21) !Sync symbols (3 @ Costas 7x7) // parameter (NN=NS+ND) !Total channel symbols (79) // parameter (ASYNCMIN=1.5) !Minimum Sync // parameter (NFSRCH=5) !Search frequency range in Hz (i.e., +/- 2.5 Hz) // parameter (NMAXCAND=300) !Maximum number of candidate signals // Parameter Value Description // KK 87 Number of information bits (75 message bits + 12 CRC bits). // ND 58 Number of data symbols in the JS8 transmission. // NS 21 Number of synchronization symbols (3 Costas arrays of size 7). // NN 79 Total number of channel symbols (NN = NS + ND). // ASYNCMIN 1.5 Minimum sync value for successful decoding. // NFSRCH 5 Search frequency range in Hz (±2.5 Hz). // NMAXCAND 300 Maximum number of candidate signals. constexpr int N = 174; // Total bits constexpr int K = 87; // Message bits constexpr int M = N - K; // Check bits constexpr int KK = 87; // Information bits (75 + CRC12) constexpr int ND = 58; // Data symbols constexpr int NS = 21; // Sync symbols (3 @ Costas 7x7) constexpr int NN = NS + ND; // Total channel symbols (79) constexpr float ASYNCMIN = 1.5f; // Minimum sync constexpr int NFSRCH = 5; // Search frequency range in Hz (i.e., +/- 2.5 Hz) constexpr std::size_t NMAXCAND = 300; // Maxiumum number of candidate signals constexpr int NFILT = 1400; // Filter length constexpr int NTMAX = 60; constexpr int NROWS = 8; constexpr int NFOS = 2; constexpr int NSSY = 4; constexpr int NP = 3200; constexpr int NP2 = 2812; constexpr float TAU = 2.0f * M_PI; constexpr auto ZERO = std::complex{0.0f, 0.0f}; constexpr int MAX_BUFFER_SIZE = NTMAX * 12000; // XXX // Key for the constants that follow: // // NSUBMODE - ID of the submode // NCOSTAS - Which JS8 Costas Arrays to use // NSPS - Number of samples per second // NTXDUR - Duration of the transmission in seconds. // NDOWNSPS - Number of samples per symbol after downsampling. // NDD - Parameter used in waveform tapering and related calculations. XXX // JZ - Range of symbol offsets considered during decoding. // ASTART - Start delay in seconds for decoding. // BASESUB - XXX // NMAX - Samples in input wave // NSTEP - Rough time-sync step size // NHSYM - Number of symbol spectra (1/4-sym steps) // NDOW - Downsample factor to 32 samples per symbol // NQSYMBOL - Downsample factor of a quarter symbol /* A MODE DECODER */ struct ModeA { // Static constants inline static constexpr int NSUBMODE = 0; inline static constexpr auto NCOSTAS = JS8::Costas::Type::ORIGINAL; inline static constexpr int NSPS = 1920; inline static constexpr int NTXDUR = 15; inline static constexpr int NDOWNSPS = 32; inline static constexpr int NDD = 100; inline static constexpr int JZ = 62; inline static constexpr float ASTART = 0.5f; inline static constexpr float BASESUB = 40.0f; // Derived parameters inline static constexpr float AZ = (12000.0f / NSPS) * 0.64f; inline static constexpr int NMAX = NTXDUR * 12000; inline static constexpr int NFFT1 = NSPS * NFOS; inline static constexpr int NSTEP = NSPS / NSSY; inline static constexpr int NHSYM = NMAX / NSTEP - 3; inline static constexpr int NDOWN = NSPS / NDOWNSPS; inline static constexpr int NQSYMBOL = NDOWNSPS / 4; inline static constexpr int NDFFT1 = NSPS * NDD; inline static constexpr int NDFFT2 = NDFFT1 / NDOWN; inline static constexpr int NP2 = NN * NDOWNSPS; inline static constexpr float TSTEP = NSTEP / 12000.0f; inline static constexpr int JSTRT = ASTART / TSTEP; inline static constexpr float DF = 12000.0f / NFFT1; }; /* B MODE DECODER */ struct ModeB { // Static constants inline static constexpr int NSUBMODE = 1; inline static constexpr auto NCOSTAS = JS8::Costas::Type::MODIFIED; inline static constexpr int NSPS = 1200; inline static constexpr int NTXDUR = 10; inline static constexpr int NDOWNSPS = 20; inline static constexpr int NDD = 100; inline static constexpr int JZ = 144; inline static constexpr float ASTART = 0.2f; inline static constexpr float BASESUB = 39.0f; // Derived parameters inline static constexpr float AZ = (12000.0f / NSPS) * 0.8f; inline static constexpr int NMAX = NTXDUR * 12000; inline static constexpr int NFFT1 = NSPS * NFOS; inline static constexpr int NSTEP = NSPS / NSSY; inline static constexpr int NHSYM = NMAX / NSTEP - 3; inline static constexpr int NDOWN = NSPS / NDOWNSPS; inline static constexpr int NQSYMBOL = NDOWNSPS / 4; inline static constexpr int NDFFT1 = NSPS * NDD; inline static constexpr int NDFFT2 = NDFFT1 / NDOWN; inline static constexpr int NP2 = NN * NDOWNSPS; inline static constexpr float TSTEP = NSTEP / 12000.0f; inline static constexpr int JSTRT = ASTART / TSTEP; inline static constexpr float DF = 12000.0f / NFFT1; }; /* C MODE DECODER */ struct ModeC { // Static constants inline static constexpr int NSUBMODE = 2; inline static constexpr auto NCOSTAS = JS8::Costas::Type::MODIFIED; inline static constexpr int NSPS = 600; inline static constexpr int NTXDUR = 6; inline static constexpr int NDOWNSPS = 12; inline static constexpr int NDD = 120; inline static constexpr int JZ = 172; inline static constexpr float ASTART = 0.1f; inline static constexpr float BASESUB = 38.0f; // Derived parameters inline static constexpr float AZ = (12000.0f / NSPS) * 0.6f; inline static constexpr int NMAX = NTXDUR * 12000; inline static constexpr int NFFT1 = NSPS * NFOS; inline static constexpr int NSTEP = NSPS / NSSY; inline static constexpr int NHSYM = NMAX / NSTEP - 3; inline static constexpr int NDOWN = NSPS / NDOWNSPS; inline static constexpr int NQSYMBOL = NDOWNSPS / 4; inline static constexpr int NDFFT1 = NSPS * NDD; inline static constexpr int NDFFT2 = NDFFT1 / NDOWN; inline static constexpr int NP2 = NN * NDOWNSPS; inline static constexpr float TSTEP = NSTEP / 12000.0f; inline static constexpr int JSTRT = ASTART / TSTEP; inline static constexpr float DF = 12000.0f / NFFT1; }; /* E MODE DECODER */ // Note that the original used 28 for NTXDUR and 90 for NDD, but the // corresponding C++ mainline side used 30 for NTXDUR, so for the // moment, we're matching that here, which seems logical at present. struct ModeE { // Static constants inline static constexpr int NSUBMODE = 4; inline static constexpr auto NCOSTAS = JS8::Costas::Type::MODIFIED; inline static constexpr int NSPS = 3840; inline static constexpr int NTXDUR = 30; // XXX 28 inline static constexpr int NDOWNSPS = 32; inline static constexpr int NDD = 94; // XXX 90 inline static constexpr int JZ = 32; inline static constexpr float ASTART = 0.5f; inline static constexpr float BASESUB = 42.0f; // Derived parameters inline static constexpr float AZ = (12000.0f / NSPS) * 0.64f; inline static constexpr int NMAX = NTXDUR * 12000; inline static constexpr int NFFT1 = NSPS * NFOS; inline static constexpr int NSTEP = NSPS / NSSY; inline static constexpr int NHSYM = NMAX / NSTEP - 3; inline static constexpr int NDOWN = NSPS / NDOWNSPS; inline static constexpr int NQSYMBOL = NDOWNSPS / 4; inline static constexpr int NDFFT1 = NSPS * NDD; inline static constexpr int NDFFT2 = NDFFT1 / NDOWN; inline static constexpr int NP2 = NN * NDOWNSPS; inline static constexpr float TSTEP = NSTEP / 12000.0f; inline static constexpr int JSTRT = ASTART / TSTEP; inline static constexpr float DF = 12000.0f / NFFT1; }; /* I MODE DECODER */ struct ModeI { // Static constants inline static constexpr int NSUBMODE = 8; inline static constexpr auto NCOSTAS = JS8::Costas::Type::MODIFIED; inline static constexpr int NSPS = 384; inline static constexpr int NTXDUR = 4; inline static constexpr int NDOWNSPS = 12; inline static constexpr int NDD = 125; inline static constexpr int JZ = 250; inline static constexpr float ASTART = 0.1f; inline static constexpr float BASESUB = 36.0f; // Derived parameters inline static constexpr float AZ = (12000.0f / NSPS) * 0.64f; inline static constexpr int NMAX = NTXDUR * 12000; inline static constexpr int NFFT1 = NSPS * NFOS; inline static constexpr int NSTEP = NSPS / NSSY; inline static constexpr int NHSYM = NMAX / NSTEP - 3; inline static constexpr int NDOWN = NSPS / NDOWNSPS; inline static constexpr int NQSYMBOL = NDOWNSPS / 4; inline static constexpr int NDFFT1 = NSPS * NDD; inline static constexpr int NDFFT2 = NDFFT1 / NDOWN; inline static constexpr int NP2 = NN * NDOWNSPS; inline static constexpr float TSTEP = NSTEP / 12000.0f; inline static constexpr int JSTRT = ASTART / TSTEP; inline static constexpr float DF = 12000.0f / NFFT1; }; // Tunable settings; degree of the polynomial used for the baseline // curve fit, and the percentile of the span at which to sample. In // general, a 5th degree polynomial and the 10th percentile should // be optimal. constexpr auto BASELINE_DEGREE = 5; constexpr auto BASELINE_SAMPLE = 10; // We're going to do a pairwise Estrin's evaluation of the polynomial // coefficients, so it's critical that the degree of the polynomial is // odd, resulting in an even number of coefficients. static_assert(BASELINE_DEGREE & 1, "Degree must be odd"); static_assert(BASELINE_SAMPLE >= 0 && BASELINE_SAMPLE <= 100, "Sample must be a percentage"); // Since we know the degree of the polynomial, and thus the number of // nodes that we're going to use, we can do all the trigonometry work // required to calculate the Chebyshev nodes in advance, by computing // them over the range [0, 1]; we can then scale these at runtime to // a span of any size by simple multiplication. // // Downside to this with C++17 is that std::cos() is not yet constexpr, // as it is in C++20, so we must provide our own implementation until // then. constexpr auto BASELINE_NODES = []() { // Down to the actual business of generating Chebyshev nodes // suitable for scaling; once we move to C++20 as the minimum // compiler, we can remove the cos() function above and instead // call std::cos() here, as it's required to be constexpr in // C++20 and above, and presumably it'll be of high quality. auto nodes = std::array{}; constexpr auto slice = M_PI / (2.0 * nodes.size()); for (std::size_t i = 0; i < nodes.size(); ++i) { nodes[i] = 0.5 * (1.0 - cos(slice * (2.0 * i + 1))); } return nodes; }(); } /******************************************************************************/ // Local Types /******************************************************************************/ namespace { // Support storage of arbitrary types, indexed by an enum class. template class EnumArray { static_assert(std::is_enum_v, "E must be an enum type"); std::array(E::count)> array; public: // Constructors constexpr EnumArray() = default; constexpr EnumArray(std::initializer_list init) { if (init.size() > array.size()) throw std::out_of_range("Too many elements in initializer_list"); std::size_t i = 0; for (const auto& value : init) { array[i++] = value; } // Zero-fill remaining elements if initializer list is smaller than the array for (; i < array.size(); ++i) { array[i] = T{}; } } // Subscript operators constexpr T& operator[](E e) { return array[static_cast>(e)]; } constexpr const T& operator[](E e) const { return array[static_cast>(e)]; } // Iteration support constexpr auto begin() noexcept { return array.begin(); } constexpr auto end() noexcept { return array.end(); } constexpr auto begin() const noexcept { return array.begin(); } constexpr auto end() const noexcept { return array.end(); } }; // Encapsulates the first-order search results provided by syncjs8(). struct Sync { float freq; float step; float sync; // Constructor for convenience. Sync(float const freq, float const step, float const sync) : freq(freq) , step(step) , sync(sync) {} }; // Tag structs so that we can refer to multi index container indices // by a descriptive tag instead of by the index of the index. These // don't need to be anything but a name. namespace Tag { struct Freq {}; struct Rank {}; struct Sync {}; } // Container indexing Sync objects in useful ways, used by syncjs8(). namespace MI = boost::multi_index; using SyncIndex = MI::multi_index_container < Sync, MI::indexed_by < MI::ordered_non_unique< MI::tag, MI::key<&Sync::freq> >, MI::ranked_non_unique< MI::tag, MI::key<&Sync::sync> >, MI::ordered_non_unique< MI::tag, MI::key<&Sync::sync>, std::greater<> > > >; // Represents a decoded message, i.e., the 3-bit message type // and the 12 bytes that result from decoding a message. class Decode { public: int type; std::string data; Decode(int type, std::string data) : type(type) , data(std::move(data)) {} bool operator == (Decode const & other) const noexcept { return type == other.type && data == other.data; } struct Hash { std::size_t operator()(Decode const & decode) const noexcept { std::size_t const h1 = std::hash{}(decode.type); std::size_t const h2 = std::hash{}(decode.data); return h1 ^ (h2 << 1); } }; using Map = std::unordered_map; }; } /******************************************************************************/ // Belief Propagation Decoder /******************************************************************************/ namespace { constexpr int BP_MAX_ROWS = 7; // Max rows per column in Nm constexpr int BP_MAX_CHECKS = 3; // Max checks per bit in Mn constexpr int BP_MAX_ITERATIONS = 30; // Max iterations in BP decoder constexpr std::array, N> Mn = {{ {0, 24, 68}, {1, 4, 72}, {2, 31, 67}, {3, 50, 60}, {5, 62, 69}, {6, 32, 78}, {7, 49, 85}, {8, 36, 42}, {9, 40, 64}, {10, 13, 63}, {11, 74, 76}, {12, 22, 80}, {14, 15, 81}, {16, 55, 65}, {17, 52, 59}, {18, 30, 51}, {19, 66, 83}, {20, 28, 71}, {21, 23, 43}, {25, 34, 75}, {26, 35, 37}, {27, 39, 41}, {29, 53, 54}, {33, 48, 86}, {38, 56, 57}, {44, 73, 82}, {45, 61, 79}, {46, 47, 84}, {58, 70, 77}, {0, 49, 52}, {1, 46, 83}, {2, 24, 78}, {3, 5, 13}, {4, 6, 79}, {7, 33, 54}, {8, 35, 68}, {9, 42, 82}, {10, 22, 73}, {11, 16, 43}, {12, 56, 75}, {14, 26, 55}, {15, 27, 28}, {17, 18, 58}, {19, 39, 62}, {20, 34, 51}, {21, 53, 63}, {23, 61, 77}, {25, 31, 76}, {29, 71, 84}, {30, 64, 86}, {32, 38, 50}, {36, 47, 74}, {37, 69, 70}, {40, 41, 67}, {44, 66, 85}, {45, 80, 81}, {48, 65, 72}, {57, 59, 65}, {60, 64, 84}, {0, 13, 20}, {1, 12, 58}, {2, 66, 81}, {3, 31, 72}, {4, 35, 53}, {5, 42, 45}, {6, 27, 74}, {7, 32, 70}, {8, 48, 75}, {9, 57, 63}, {10, 47, 67}, {11, 18, 44}, {14, 49, 60}, {15, 21, 25}, {16, 71, 79}, {17, 39, 54}, {19, 34, 50}, {22, 24, 33}, {23, 62, 86}, {26, 38, 73}, {28, 77, 82}, {29, 69, 76}, {30, 68, 83}, {21, 36, 85}, {37, 40, 80}, {41, 43, 56}, {46, 52, 61}, {51, 55, 78}, {59, 74, 80}, {0, 38, 76}, {1, 15, 40}, {2, 30, 53}, {3, 35, 77}, {4, 44, 64}, {5, 56, 84}, {6, 13, 48}, {7, 20, 45}, {8, 14, 71}, {9, 19, 61}, {10, 16, 70}, {11, 33, 46}, {12, 67, 85}, {17, 22, 42}, {18, 63, 72}, {23, 47, 78}, {24, 69, 82}, {25, 79, 86}, {26, 31, 39}, {27, 55, 68}, {28, 62, 65}, {29, 41, 49}, {32, 36, 81}, {34, 59, 73}, {37, 54, 83}, {43, 51, 60}, {50, 52, 71}, {57, 58, 66}, {46, 55, 75}, {0, 18, 36}, {1, 60, 74}, {2, 7, 65}, {3, 59, 83}, {4, 33, 38}, {5, 25, 52}, {6, 31, 56}, {8, 51, 66}, {9, 11, 14}, {10, 50, 68}, {12, 13, 64}, {15, 30, 42}, {16, 19, 35}, {17, 79, 85}, {20, 47, 58}, {21, 39, 45}, {22, 32, 61}, {23, 29, 73}, {24, 41, 63}, {26, 48, 84}, {27, 37, 72}, {28, 43, 80}, {34, 67, 69}, {40, 62, 75}, {44, 48, 70}, {49, 57, 86}, {47, 53, 82}, {12, 54, 78}, {76, 77, 81}, {0, 1, 23}, {2, 5, 74}, {3, 55, 86}, {4, 43, 52}, {6, 49, 82}, {7, 9, 27}, {8, 54, 61}, {10, 28, 66}, {11, 32, 39}, {13, 15, 19}, {14, 34, 72}, {16, 30, 38}, {17, 35, 56}, {18, 45, 75}, {20, 41, 83}, {21, 33, 58}, {22, 25, 60}, {24, 59, 64}, {26, 63, 79}, {29, 36, 65}, {31, 44, 71}, {37, 50, 85}, {40, 76, 78}, {42, 55, 67}, {46, 73, 81}, {39, 51, 77}, {53, 60, 70}, {45, 57, 68}, }}; struct CheckNode { int valid_neighbors; std::array neighbors; }; constexpr std::array Nm = {{ {6, {0, 29, 59, 88, 117, 146, 0}}, {6, {1, 30, 60, 89, 118, 146, 0}}, {6, {2, 31, 61, 90, 119, 147, 0}}, {6, {3, 32, 62, 91, 120, 148, 0}}, {6, {1, 33, 63, 92, 121, 149, 0}}, {6, {4, 32, 64, 93, 122, 147, 0}}, {6, {5, 33, 65, 94, 123, 150, 0}}, {6, {6, 34, 66, 95, 119, 151, 0}}, {6, {7, 35, 67, 96, 124, 152, 0}}, {6, {8, 36, 68, 97, 125, 151, 0}}, {6, {9, 37, 69, 98, 126, 153, 0}}, {6, {10, 38, 70, 99, 125, 154, 0}}, {6, {11, 39, 60, 100, 127, 144, 0}}, {6, {9, 32, 59, 94, 127, 155, 0}}, {6, {12, 40, 71, 96, 125, 156, 0}}, {6, {12, 41, 72, 89, 128, 155, 0}}, {6, {13, 38, 73, 98, 129, 157, 0}}, {6, {14, 42, 74, 101, 130, 158, 0}}, {6, {15, 42, 70, 102, 117, 159, 0}}, {6, {16, 43, 75, 97, 129, 155, 0}}, {6, {17, 44, 59, 95, 131, 160, 0}}, {6, {18, 45, 72, 82, 132, 161, 0}}, {6, {11, 37, 76, 101, 133, 162, 0}}, {6, {18, 46, 77, 103, 134, 146, 0}}, {6, {0, 31, 76, 104, 135, 163, 0}}, {6, {19, 47, 72, 105, 122, 162, 0}}, {6, {20, 40, 78, 106, 136, 164, 0}}, {6, {21, 41, 65, 107, 137, 151, 0}}, {6, {17, 41, 79, 108, 138, 153, 0}}, {6, {22, 48, 80, 109, 134, 165, 0}}, {6, {15, 49, 81, 90, 128, 157, 0}}, {6, {2, 47, 62, 106, 123, 166, 0}}, {6, {5, 50, 66, 110, 133, 154, 0}}, {6, {23, 34, 76, 99, 121, 161, 0}}, {6, {19, 44, 75, 111, 139, 156, 0}}, {6, {20, 35, 63, 91, 129, 158, 0}}, {6, {7, 51, 82, 110, 117, 165, 0}}, {6, {20, 52, 83, 112, 137, 167, 0}}, {6, {24, 50, 78, 88, 121, 157, 0}}, {7, {21, 43, 74, 106, 132, 154, 171}}, {6, {8, 53, 83, 89, 140, 168, 0}}, {6, {21, 53, 84, 109, 135, 160, 0}}, {6, {7, 36, 64, 101, 128, 169, 0}}, {6, {18, 38, 84, 113, 138, 149, 0}}, {6, {25, 54, 70, 92, 141, 166, 0}}, {7, {26, 55, 64, 95, 132, 159, 173}}, {6, {27, 30, 85, 99, 116, 170, 0}}, {6, {27, 51, 69, 103, 131, 143, 0}}, {6, {23, 56, 67, 94, 136, 141, 0}}, {6, {6, 29, 71, 109, 142, 150, 0}}, {6, {3, 50, 75, 114, 126, 167, 0}}, {6, {15, 44, 86, 113, 124, 171, 0}}, {6, {14, 29, 85, 114, 122, 149, 0}}, {6, {22, 45, 63, 90, 143, 172, 0}}, {6, {22, 34, 74, 112, 144, 152, 0}}, {7, {13, 40, 86, 107, 116, 148, 169}}, {6, {24, 39, 84, 93, 123, 158, 0}}, {6, {24, 57, 68, 115, 142, 173, 0}}, {6, {28, 42, 60, 115, 131, 161, 0}}, {6, {14, 57, 87, 111, 120, 163, 0}}, {7, {3, 58, 71, 113, 118, 162, 172}}, {6, {26, 46, 85, 97, 133, 152, 0}}, {5, {4, 43, 77, 108, 140, 0, 0}}, {6, {9, 45, 68, 102, 135, 164, 0}}, {6, {8, 49, 58, 92, 127, 163, 0}}, {6, {13, 56, 57, 108, 119, 165, 0}}, {6, {16, 54, 61, 115, 124, 153, 0}}, {6, {2, 53, 69, 100, 139, 169, 0}}, {6, {0, 35, 81, 107, 126, 173, 0}}, {5, {4, 52, 80, 104, 139, 0, 0}}, {6, {28, 52, 66, 98, 141, 172, 0}}, {6, {17, 48, 73, 96, 114, 166, 0}}, {6, {1, 56, 62, 102, 137, 156, 0}}, {6, {25, 37, 78, 111, 134, 170, 0}}, {6, {10, 51, 65, 87, 118, 147, 0}}, {6, {19, 39, 67, 116, 140, 159, 0}}, {6, {10, 47, 80, 88, 145, 168, 0}}, {6, {28, 46, 79, 91, 145, 171, 0}}, {6, {5, 31, 86, 103, 144, 168, 0}}, {6, {26, 33, 73, 105, 130, 164, 0}}, {5, {11, 55, 83, 87, 138, 0, 0}}, {6, {12, 55, 61, 110, 145, 170, 0}}, {6, {25, 36, 79, 104, 143, 150, 0}}, {6, {16, 30, 81, 112, 120, 160, 0}}, {5, {27, 48, 58, 93, 136, 0, 0}}, {6, {6, 54, 82, 100, 130, 167, 0}}, {6, {23, 49, 77, 105, 142, 148, 0}}, }}; // Belief Propagation Decoder int bpdecode174(std::array const & llr, std::array & decoded, std::array & cw) { // Initialize messages and variables std::array, N> tov = {}; // Messages to variable nodes std::array, M> toc = {}; // Messages to check nodes std::array , M> tanhtoc = {}; // Tanh of messages std::array zn = {}; // Bit log likelihood ratios std::array synd = {}; // Syndrome for checks int ncnt = 0; int nclast = 0; // Initialize toc (messages from bits to checks) for (int i = 0; i < M; ++i) { for (int j = 0; j < Nm[i].valid_neighbors; ++j) { toc[i][j] = llr[Nm[i].neighbors[j]]; } } // Iterative decoding for (int iter = 0; iter <= BP_MAX_ITERATIONS; ++iter) { // Update bit log likelihood ratios for (int i = 0; i < N; ++i) { zn[i] = llr[i] + std::accumulate(tov[i].begin(), tov[i].begin() + BP_MAX_CHECKS, 0.0f); } // Check if we have a valid codeword for (int i = 0; i < N; ++i) cw[i] = zn[i] > 0 ? 1 : 0; int ncheck = 0; for (int i = 0; i < M; ++i) { synd[i] = 0; for (int j = 0; j < Nm[i].valid_neighbors; ++j) { synd[i] += cw[Nm[i].neighbors[j]]; } if (synd[i] % 2 != 0) ++ncheck; } if (ncheck == 0) { // Extract decoded bits (last N-M bits of codeword) std::copy(cw.begin() + M, cw.end(), decoded.begin()); // Count errors int nerr = 0; for (int i = 0; i < N; ++i) { if ((2 * cw[i] - 1) * llr[i] < 0.0f) { ++nerr; } } return nerr; } // Early stopping criterion if (iter > 0) { int nd = ncheck - nclast; ncnt = (nd < 0) ? 0 : ncnt + 1; if (ncnt >= 5 && iter >= 10 && ncheck > 15) { return -1; } } nclast = ncheck; // Send messages from bits to check nodes for (int i = 0; i < M; ++i) { for (int j = 0; j < Nm[i].valid_neighbors; ++j) { int ibj = Nm[i].neighbors[j]; toc[i][j] = zn[ibj]; for (int k = 0; k < BP_MAX_CHECKS; ++k) { if (Mn[ibj][k] == i) { toc[i][j] -= tov[ibj][k]; } } } } // Send messages from check nodes to variable nodes for (int i = 0; i < M; ++i) { for (int j = 0; j < 7; ++j) { // Fixed range [0, 7) to match Fortran's 1:7, could be nrw[j], or 7 logically tanhtoc[i][j] = std::tanh(-toc[i][j] / 2.0f); } } for (int i = 0; i < N; ++i) { for (int j = 0; j < BP_MAX_CHECKS; ++j) { int ichk = Mn[i][j]; if (ichk >= 0) { float Tmn = 1.0f; for (int k = 0; k < Nm[ichk].valid_neighbors; ++k) { if (Nm[ichk].neighbors[k] != i) { Tmn *= tanhtoc[ichk][k]; } } tov[i][j] = 2.0f * std::atanh(-Tmn); } } } } return -1; // Decoding failed } } /******************************************************************************/ // Local Routines /******************************************************************************/ namespace { constexpr std::string_view alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-+"; static_assert(alphabet.size() == 64); // Function that either translates valid JS8 message characters to their // corresponding 6-bit word value, or throws. This will end up doing a // direct index operation into a 256-byte table, the creation of which // must be constexpr under C++17. constexpr auto alphabetWord = []() { constexpr std::uint8_t invalid = 0xff; constexpr auto words = []() { std::array words{}; for (auto & word : words) word = invalid; for (std::size_t i = 0; i < alphabet.size(); ++i) { words[static_cast(alphabet[i])] = static_cast(i); } return words; }(); return [words](char const value) { if (auto const word = words[value]; word != invalid) { return word; } throw std::runtime_error("Invalid character in message"); }; }(); // Sanity check key bounds of the 6-bit encoding table. static_assert(alphabetWord('0') == 0); static_assert(alphabetWord('A') == 10); static_assert(alphabetWord('a') == 36); static_assert(alphabetWord('-') == 62); static_assert(alphabetWord('+') == 63); template std::uint16_t CRC12(T const & range) { return boost::augmented_crc<12, 0xc06>(range.data(), range.size()) ^ 42; } bool checkCRC12(std::array const & decoded) { std::array bits = {}; for (std::size_t i = 0; i < decoded.size(); ++i) { if (decoded[i]) bits[i / 8] |= (1 << (7 - (i % 8))); } // Extract the received CRC-12. uint16_t crc = (static_cast(bits[9] & 0x1F) << 7) | (static_cast(bits[10]) >> 1); // Clear bits that correspond to the CRC in the last bytes. bits[9] &= 0xE0; bits[10] = 0x00; // Compute CRC and indicate if we have a match. return crc == CRC12(bits); } int calculateNPass(int const ndepth) { switch (ndepth) { case 1: return 1; // No subtraction, 1 pass, belief propagation only case 2: return 3; // Subtraction, 3 passes, belief propagation only default: return 4; // Subtraction, 4 passes, belief propagation + OSD for ndepth >= 3 } } std::string extractmessage174(std::array const & decoded) { std::string message; // Ensure received CRC matches computed CRC. if (checkCRC12(decoded)) { message.reserve(12); // Decode the message from the 72 data bits std::array words; for (std::size_t i = 0; i < 12; ++i) { words[i] = (decoded[i * 6 + 0] << 5) | (decoded[i * 6 + 1] << 4) | (decoded[i * 6 + 2] << 3) | (decoded[i * 6 + 3] << 2) | (decoded[i * 6 + 4] << 1) | (decoded[i * 6 + 5] << 0); } // Map 6-bit words to the alphabet for (auto const word : words) message += alphabet[word]; } return message; } // Parity matrix for JS8 message generation. // // This should be 952 bytes in size; to store an 87x87 matrix of bits, // you need 7569 bits, which requires 119 64-bit values, or 952 bytes. // // Background here is that this is a low-density parity check code (LDPC), // generated using the PEG algorithm. In short, true values in a row i of // the matrix define which of the 87 message bits must be summed, modulo // 2, to produce the ith parity check bit. Decent references on this are: // // 1. https://wsjt.sourceforge.io/FT4_FT8_QEX.pdf // 2. https://inference.org.uk/mackay/PEG_ECC.html // 3. https://github.com/Lcrypto/classic-PEG- // // The data used was harvested from the original 'ldpc_174_87_params.f90', // but you'll note that the rows have been reordered here, because this // isn't Fortran; C++ is row-major, not column-major. constexpr auto parity = []() { constexpr std::size_t Rows = 87; constexpr std::size_t Cols = 87; using ElementType = std::uint64_t; constexpr std::size_t ElementSize = std::numeric_limits::digits; constexpr auto matrix = []() { constexpr std::array Data = { "23bba830e23b6b6f50982e", "1f8e55da218c5df3309052", "ca7b3217cd92bd59a5ae20", "56f78313537d0f4382964e", "6be396b5e2e819e373340c", "293548a138858328af4210", "cb6c6afcdc28bb3f7c6e86", "3f2a86f5c5bd225c961150", "849dd2d63673481860f62c", "56cdaec6e7ae14b43feeee", "04ef5cfa3766ba778f45a4", "c525ae4bd4f627320a3974", "41fd9520b2e4abeb2f989c", "7fb36c24085a34d8c1dbc4", "40fc3e44bb7d2bb2756e44", "d38ab0a1d2e52a8ec3bc76", "3d0f929ef3949bd84d4734", "45d3814f504064f80549ae", "f14dbf263825d0bd04b05e", "db714f8f64e8ac7af1a76e", "8d0274de71e7c1a8055eb0", "51f81573dd4049b082de14", "d8f937f31822e57c562370", "b6537f417e61d1a7085336", "ecbd7c73b9cd34c3720c8a", "3d188ea477f6fa41317a4e", "1ac4672b549cd6dba79bcc", "a377253773ea678367c3f6", "0dbd816fba1543f721dc72", "ca4186dd44c3121565cf5c", "29c29dba9c545e267762fe", "1616d78018d0b4745ca0f2", "fe37802941d66dde02b99c", "a9fa8e50bcb032c85e3304", "83f640f1a48a8ebc0443ea", "3776af54ccfbae916afde6", "a8fc906976c35669e79ce0", "f08a91fb2e1f78290619a8", "cc9da55fe046d0cb3a770c", "d36d662a69ae24b74dcbd8", "40907b01280f03c0323946", "d037db825175d851f3af00", "1bf1490607c54032660ede", "0af7723161ec223080be86", "eca9afa0f6b01d92305edc", "7a8dec79a51e8ac5388022", "9059dfa2bb20ef7ef73ad4", "6abb212d9739dfc02580f2", "f6ad4824b87c80ebfce466", "d747bfc5fd65ef70fbd9bc", "612f63acc025b6ab476f7c", "05209a0abb530b9e7e34b0", "45b7ab6242b77474d9f11a", "6c280d2a0523d9c4bc5946", "f1627701a2d692fd9449e6", "8d9071b7e7a6a2eed6965e", "bf4f56e073271f6ab4bf80", "c0fc3ec4fb7d2bb2756644", "57da6d13cb96a7689b2790", "a9fa2eefa6f8796a355772", "164cc861bdd803c547f2ac", "cc6de59755420925f90ed2", "a0c0033a52ab6299802fd2", "b274db8abd3c6f396ea356", "97d4169cb33e7435718d90", "81cfc6f18c35b1e1f17114", "481a2a0df8a23583f82d6c", "081c29a10d468ccdbcecb6", "2c4142bf42b01e71076acc", "a6573f3dc8b16c9d19f746", "c87af9a5d5206abca532a8", "012dee2198eba82b19a1da", "b1ca4ea2e3d173bad4379c", "b33ec97be83ce413f9acc8", "5b0f7742bca86b8012609a", "37d8e0af9258b9e8c5f9b2", "35ad3fb0faeb5f1b0c30dc", "6114e08483043fd3f38a8a", "cd921fdf59e882683763f6", "95e45ecd0135aca9d6e6ae", "2e547dd7a05f6597aac516", "14cd0f642fc0c5fe3a65ca", "3a0a1dfd7eee29c2e827e0", "c8b5dffc335095dcdcaf2a", "3dd01a59d86310743ec752", "8abdb889efbe39a510a118", "3f231f212055371cf3e2a2" }; constexpr std::size_t Total = (Rows * Cols + ElementSize - 1); constexpr std::size_t Count = Total / ElementSize; constexpr std::array Masks = {0x8, 0x4, 0x2, 0x1}; std::array data{}; for (std::size_t row = 0; row < Rows; ++row) { std::size_t col = 0; for (auto const c : Data[row]) { std::uint8_t const value = (c >= '0' && c <= '9') ? c - '0' : (c >= 'a' && c <= 'f') ? c - 'a' + 10 : (c >= 'A' && c <= 'F') ? c - 'A' + 10 : throw "Invalid hex"; for (auto const mask : Masks) { if (col >= Cols) break; if (value & mask) { auto const index = row * Cols + col; data[index / ElementSize] |= (ElementType(1) << (index % ElementSize)); } ++col; } } } return data; }(); return [matrix](std::size_t const row, std::size_t const col) { auto const index = row * Cols + col; return (matrix[index / ElementSize] >> (index % ElementSize)) & 1; }; }(); constexpr auto gen = []() { using GeneratorMatrix = std::array, K>; using HexStringArray = std::array; // Example parity bit definitions in hexadecimal format constexpr HexStringArray g = { "23bba830e23b6b6f50982e", "1f8e55da218c5df3309052", "ca7b3217cd92bd59a5ae20", "56f78313537d0f4382964e", "29c29dba9c545e267762fe", "6be396b5e2e819e373340c", "293548a138858328af4210", "cb6c6afcdc28bb3f7c6e86", "3f2a86f5c5bd225c961150", "849dd2d63673481860f62c", "56cdaec6e7ae14b43feeee", "04ef5cfa3766ba778f45a4", "c525ae4bd4f627320a3974", "fe37802941d66dde02b99c", "41fd9520b2e4abeb2f989c", "40907b01280f03c0323946", "7fb36c24085a34d8c1dbc4", "40fc3e44bb7d2bb2756e44", "d38ab0a1d2e52a8ec3bc76", "3d0f929ef3949bd84d4734", "45d3814f504064f80549ae", "f14dbf263825d0bd04b05e", "f08a91fb2e1f78290619a8", "7a8dec79a51e8ac5388022", "ca4186dd44c3121565cf5c", "db714f8f64e8ac7af1a76e", "8d0274de71e7c1a8055eb0", "51f81573dd4049b082de14", "d037db825175d851f3af00", "d8f937f31822e57c562370", "1bf1490607c54032660ede", "1616d78018d0b4745ca0f2", "a9fa8e50bcb032c85e3304", "83f640f1a48a8ebc0443ea", "eca9afa0f6b01d92305edc", "3776af54ccfbae916afde6", "6abb212d9739dfc02580f2", "05209a0abb530b9e7e34b0", "612f63acc025b6ab476f7c", "0af7723161ec223080be86", "a8fc906976c35669e79ce0", "45b7ab6242b77474d9f11a", "b274db8abd3c6f396ea356", "9059dfa2bb20ef7ef73ad4", "3d188ea477f6fa41317a4e", "8d9071b7e7a6a2eed6965e", "a377253773ea678367c3f6", "ecbd7c73b9cd34c3720c8a", "b6537f417e61d1a7085336", "6c280d2a0523d9c4bc5946", "d36d662a69ae24b74dcbd8", "d747bfc5fd65ef70fbd9bc", "a9fa2eefa6f8796a355772", "cc9da55fe046d0cb3a770c", "f6ad4824b87c80ebfce466", "cc6de59755420925f90ed2", "164cc861bdd803c547f2ac", "c0fc3ec4fb7d2bb2756644", "0dbd816fba1543f721dc72", "a0c0033a52ab6299802fd2", "bf4f56e073271f6ab4bf80", "57da6d13cb96a7689b2790", "81cfc6f18c35b1e1f17114", "481a2a0df8a23583f82d6c", "1ac4672b549cd6dba79bcc", "c87af9a5d5206abca532a8", "97d4169cb33e7435718d90", "a6573f3dc8b16c9d19f746", "2c4142bf42b01e71076acc", "081c29a10d468ccdbcecb6", "5b0f7742bca86b8012609a", "012dee2198eba82b19a1da", "f1627701a2d692fd9449e6", "35ad3fb0faeb5f1b0c30dc", "b1ca4ea2e3d173bad4379c", "37d8e0af9258b9e8c5f9b2", "cd921fdf59e882683763f6", "6114e08483043fd3f38a8a", "2e547dd7a05f6597aac516", "95e45ecd0135aca9d6e6ae", "b33ec97be83ce413f9acc8", "c8b5dffc335095dcdcaf2a", "3dd01a59d86310743ec752", "14cd0f642fc0c5fe3a65ca", "3a0a1dfd7eee29c2e827e0", "8abdb889efbe39a510a118", "3f231f212055371cf3e2a2" }; // Convert a hex string to a generator matrix row constexpr auto parse_hex_row = [](std::string_view hex, std::array& row) { for (size_t j = 0; j < hex.size(); ++j) { uint8_t const c = hex[j]; std::uint8_t const value = (c >= '0' && c <= '9') ? c - '0' : (c >= 'a' && c <= 'f') ? c - 'a' + 10 : (c >= 'A' && c <= 'F') ? c - 'A' + 10 : throw "Invalid hex"; for (int bit = 0; bit < 4; ++bit) { size_t col = j * 4 + bit; if (col < N && (value & (1 << (3 - bit)))) { row[col] = 1; } } } }; GeneratorMatrix gen = {}; // Initialize to zero for (int i = 0; i < M; ++i) { parse_hex_row(g[i], gen[i]); } // Add identity matrix for the systematic bits for (int i = 0; i < K; ++i) { gen[i][M + i] = 1; } return gen; }(); using GeneratorMatrix = std::array, K>; void mrbencode(std::array const & message, std::array & codeword, GeneratorMatrix const & g2) { codeword.fill(0); for (int i = 0; i < K; ++i) { if (message[i] == 1) { for (int j = 0; j < N; ++j) { codeword[j] ^= g2[i][j]; } } } } // XXX this is a Q&D interpretation of the intent of the // original Fortran, rather than a direct translation. int osd174(std::array const & rx, int const ndeep, std::array & decoded, std::array & cw, float & dmin) { // Hard decisions std::array hdec = {}; for (int i = 0; i < N; ++i) { hdec[i] = (rx[i] >= 0) ? 1 : 0; } // Compute absolute values for reliability std::array absrx = {}; std::transform(rx.begin(), rx.end(), absrx.begin(), [](float x) { return std::abs(x); }); // Indices sorted by reliability std::array indices = {}; std::iota(indices.begin(), indices.end(), 0); std::sort(indices.begin(), indices.end(), [&absrx](int a, int b) { return absrx[a] > absrx[b]; }); // Reorder generator matrix GeneratorMatrix genmrb = {}; for (int i = 0; i < K; ++i) { for (int j = 0; j < N; ++j) { genmrb[i][j] = gen[i][indices[j]]; } } std::array hdec_reordered = {}; for (int i = 0; i < N; ++i) { hdec_reordered[i] = hdec[indices[i]]; } // Gaussian elimination for (int id = 0; id < K; ++id) { int pivot_col = -1; for (int col = id; col < N; ++col) { if (genmrb[id][col] == 1) { pivot_col = col; break; } } if (pivot_col == -1) { continue; // No pivot found } if (pivot_col != id) { for (int row = 0; row < K; ++row) { std::swap(genmrb[row][id], genmrb[row][pivot_col]); } std::swap(indices[id], indices[pivot_col]); } for (int row = 0; row < K; ++row) { if (row != id && genmrb[row][id] == 1) { for (int col = 0; col < N; ++col) { genmrb[row][col] ^= genmrb[id][col]; } } } } // Initialize m0 and compute c0 std::array m0 = {}; for (int i = 0; i < K; ++i) { m0[i] = hdec_reordered[i]; } std::array c0 = {}; mrbencode(m0, c0, genmrb); // Compute initial Euclidean distance int nhardmin = std::inner_product(hdec_reordered.begin(), hdec_reordered.end(), c0.begin(), 0, std::plus<>(), [](int a, int b) { return a ^ b; }); dmin = std::inner_product(hdec_reordered.begin(), hdec_reordered.end(), c0.begin(), 0.0f, std::plus<>(), [&absrx](int a, int b) { return (a ^ b) * absrx[b]; }); cw = c0; // If no deeper decoding needed if (ndeep == 0) { std::copy(cw.begin() + M, cw.end(), decoded.begin()); return nhardmin; } // Perform deeper decoding for (int iorder = 1; iorder <= ndeep; ++iorder) { std::vector mi(K, 0); std::fill(mi.end() - iorder, mi.end(), 1); do { std::array me = {}; for (int i = 0; i < K; ++i) { me[i] = m0[i] ^ mi[i]; } std::array ce = {}; mrbencode(me, ce, genmrb); int nxor = std::inner_product(ce.begin(), ce.end(), hdec_reordered.begin(), 0, std::plus<>(), [](int a, int b) { return a ^ b; }); float dd = std::inner_product(ce.begin(), ce.end(), hdec_reordered.begin(), 0.0f, std::plus<>(), [&absrx](int a, int b) { return (a ^ b) * absrx[b]; }); if (dd < dmin) { dmin = dd; cw = ce; nhardmin = nxor; } } while (std::prev_permutation(mi.begin(), mi.end())); } // Reorder the codeword to original order std::array cw_reordered = {}; for (int i = 0; i < N; ++i) { cw_reordered[indices[i]] = cw[i]; } cw = cw_reordered; // Extract the decoded message std::copy(cw.begin() + M, cw.end(), decoded.begin()); return nhardmin; // Return the number of hard errors } } /******************************************************************************/ // DecodeMode Template Class /******************************************************************************/ // Mode-parameterized decode class. namespace { template class DecodeMode { // Plan types enum class Plan { DS, BB, CF, CB, SD, CS, count }; // Data members std::array nuttal; std::array, Mode::NDOWNSPS>, 7>, 3> csyncs; std::array, Mode::NMAX> filter; std::array, Mode::NMAX> cfilt; std::array, Mode::NDFFT1 / 2 + 1> ds_cx; std::array, Mode::NFFT1 / 2 + 1> sd; std::array dd; std::array, Mode::NDOWNSPS> csymb; std::array, Mode::NSPS> s; std::array savg; std::array sbase; std::array, NP> cd0; SyncIndex sync; EnumArray plans = {}; JS8::Event::Emitter emitEvent; static constexpr auto Costas = JS8::Costas::array(Mode::NCOSTAS); // Fore and aft tapers to reduce spectral leakage during the // downsampling process. We can compute these at compile time. static constexpr auto Taper = [] { std::array, 2> taper{}; for (size_t i = 0; i <= Mode::NDD; ++i) { float const value = 0.5f * (1.0f + cos(i * M_PI / Mode::NDD)); taper[1][ i] = value; // TailTaper (original taper) taper[0][Mode::NDD - i] = value; // HeadTaper (reversed taper) } return taper; }(); // Baseline computation support. using Points = Eigen::Matrix; using Vandermonde = Eigen::Matrix; using Coefficients = Eigen::Vector; Points p; Vandermonde V; Coefficients c; // Polynomial evaluation using Estrin's method, loop is unrolled at // compile time. A compiler should emit SIMD instructions from what // it sees here when the optimizer is involved, but even without it, // we'll likely see fused multiply-add instructions. template inline auto evaluate(std::size_t const i, std::integer_sequence) const { auto baseline = 0.0; auto exponent = 1.0; ((baseline += (c[I * 2] + c[I * 2 + 1] * i) * exponent, exponent *= i * i), ...); return static_cast(baseline); } // Driver for the loop unrolling above, since at present we're limited // to targeting C++17; when we can target C++20 or later, these can be // combined into one function. inline auto evaluate(std::size_t const i) const { return evaluate(i, std::make_integer_sequence{}); } // XXX still working on this one. std::optional js8dec(bool const syncStats, float const nfqso, int const ndepth, int const napwid, bool const nagain, bool const lsubtract, float & f1, float & xdt, int & nharderrors, float & dmin, float & xsnr) { constexpr float FR = 12000.0f / Mode::NFFT1; // Frequency resolution constexpr float FS2 = 12000.0f / Mode::NDOWN; constexpr float DT2 = 1.0f / FS2; auto const index = static_cast(std::round(f1 / FR)); // Closest index float const scaled_value = 0.1f * (sbase[index] - Mode::BASESUB); // Adjust and scale float const xbase = std::pow(10.0f, scaled_value); // Convert to linear scale float delfbest = 0.0f; int ibest = 0; // Downsample the signal and prepare for processing. js8_downsample(f1); // Initial guess for the start of the signal. int i0 = static_cast(std::round((xdt + Mode::ASTART) * FS2)); float smax = 0.0f; // Search for the best synchronization offset. for (int idt = i0 - Mode::NQSYMBOL; idt <= i0 + Mode::NQSYMBOL; ++idt) { float const sync = syncjs8d(idt, 0.0f); if (sync > smax) { smax = sync; ibest = idt; } } // Improved estimate for DT. float const xdt2 = ibest * DT2; // Fine frequency synchronization i0 = static_cast(std::round(xdt2 * FS2)); smax = 0.0f; for (int ifr = -NFSRCH; ifr <= NFSRCH; ++ifr) { float const delf = ifr * 0.5f; float const sync = syncjs8d(i0, delf); if (sync > smax) { smax = sync; delfbest = delf; } } // Frequency tweaking. float const dphi = -delfbest * ((2.0f * M_PI) / FS2); // Phase increment std::complex const wstep = std::polar(1.0f, dphi); // Step for phase rotation std::complex w = {1.0f, 0.0f}; // Cumlative phase for (int i = 0; i < NP2; ++i) { w *= wstep; // Update cumulative phase cd0[i] *= w; // Apply phase shift } // Adjust the frequency and time offset. xdt = xdt2; f1 += delfbest; float const sync = syncjs8d(i0, 0.0f); std::array, NROWS> s2; /* j=0 do k=1,NN i1=ibest+(k-1)*NDOWNSPS csymb=cmplx(0.0,0.0) if( i1.ge.0 .and. i1+(NDOWNSPS-1) .le. NP2-1 ) csymb=cd0(i1:i1+(NDOWNSPS-1)) call four2a(csymb,NDOWNSPS,1,-1,1) s2(0:7,k)=abs(csymb(1:8))/1e3 enddo */ for (int k = 0; k < NN; ++k) { // Calculate the starting index for the current symbol. int const i1 = ibest + k * Mode::NDOWNSPS; csymb.fill(ZERO); if (i1 >= 0 && i1 + Mode::NDOWNSPS <= NP2) { std::copy(cd0.begin() + i1, cd0.begin() + i1 + Mode::NDOWNSPS, csymb.begin()); } fftwf_execute(plans[Plan::CS]); // Normalize and take the magnitude of the first 8 points. for (int i = 0; i < NROWS; ++i) { s2[i][k] = std::abs(csymb[i]) / 1000.0f; } } // Sync quality check using Costas tone patterns. int nsync = 0; for (std::size_t costas = 0; costas < Costas.size(); ++costas) { auto const offset = costas * 36; for (std::size_t column = 0; column < 7; ++column) { // Find the row containing the maximum value in the // current column. auto const max_row = std::distance( s2.begin(), std::max_element(s2.begin(), s2.end(), [index = offset + column] (auto const & rowA, auto const & rowB) { return rowA[index] < rowB[index]; })); // Check if the max row matches the Costas pattern. if (Costas[costas][column] == max_row) ++nsync; } } // If the sync quality isn't at least 7, this one's a loser. if (nsync <= 6) return std::nullopt; if (syncStats) emitEvent(JS8::Event::SyncState{JS8::Event::SyncState::Type::CANDIDATE, Mode::NSUBMODE, f1, xdt, {.candidate = nsync}}); std::array, NROWS> s1; // Fill s1 from s2, excluding the Costas arrays. for (int row = 0; row < NROWS; ++row) { std::copy(s2[row].begin() + 7, s2[row].begin() + 36, s1[row].begin()); std::copy(s2[row].begin() + 43, s2[row].begin() + 72, s1[row].begin() + 29); } // Flatten s1 into a single 1D array and find the median. auto const median = [&s1]() { constexpr std::size_t size = NROWS * ND; constexpr std::size_t nth = size / 2 - 1; std::vector s1flat; s1flat.reserve(size); for (auto const & row : s1) { s1flat.insert(s1flat.end(), row.begin(), row.end()); } std::nth_element(s1flat.begin(), s1flat.begin() + nth, s1flat.end()); return s1flat[nth]; }(); // Normalize s1 by dividing each element by the median value. for (auto & row : s1) { for (auto & value : row) { value /= median; } } // Temporary variables for metrics std::array llr0 = {}; std::array llr1 = {}; // Compute metrics for each row in `s1` for (int j = 0; j < ND; ++j) { int const i1 = 3 * j; // First column (matches Fortran's i1) int const i2 = 3 * j + 1; // Second column (matches Fortran's i2) int const i4 = 3 * j + 2; // Third column (matches Fortran's i4) std::array ps; for (int i = 0; i < NROWS; ++i) ps[i] = s1[i][j]; // Assign to `bmeta` in column order, with correct values llr0[i1] = std::max({ps[4], ps[5], ps[6], ps[7]}) - std::max({ps[0], ps[1], ps[2], ps[3]}); // r4 llr0[i2] = std::max({ps[2], ps[3], ps[6], ps[7]}) - std::max({ps[0], ps[1], ps[4], ps[5]}); // r2 llr0[i4] = std::max({ps[1], ps[3], ps[5], ps[7]}) - std::max({ps[0], ps[2], ps[4], ps[6]}); // r1 for (auto & x : ps) x = std::log(x + 1e-32f); // Assign to `bmetb` in column order, with correct values llr1[i1] = std::max({ps[4], ps[5], ps[6], ps[7]}) - std::max({ps[0], ps[1], ps[2], ps[3]}); // r4 llr1[i2] = std::max({ps[2], ps[3], ps[6], ps[7]}) - std::max({ps[0], ps[1], ps[4], ps[5]}); // r2 llr1[i4] = std::max({ps[1], ps[3], ps[5], ps[7]}) - std::max({ps[0], ps[2], ps[4], ps[6]}); // r1 } auto const normalizeLLR = [](auto & llr) { float sum = 0.0f; float sum_of_squares = 0.0f; for (auto const value : llr) { sum += value; sum_of_squares += value * value; } float const llrav = sum / llr.size(); float const llr2av = sum_of_squares / llr.size(); float const variance = llr2av - llrav * llrav; float const llrsig = std::sqrt(variance > 0.0f ? variance : llr2av); for (float & val : llr) val = (val / llrsig) * 2.83f; }; // Normalize and process metrics normalizeLLR(llr0); normalizeLLR(llr1); std::array decoded; std::array cw; // Loop over decoding passes for (int ipass = 1; ipass <= 4; ++ipass) { // LLR 0 used on passes 1, 3, and 4; LLR 1 used on pass 2. auto const & llr = ipass == 2 ? llr1 : llr0; // Zero the first 24 bytes of LLR 0 on the third pass; // the first 48 bytes of LLR 0 on the fourth pass; if (ipass == 3) std::fill(llr0.begin(), llr0.begin() + 24, 0.0f); else if (ipass == 4) std::fill(llr0.begin() + 24, llr0.begin() + 48, 0.0f); // Decode using belief propagation. nharderrors = bpdecode174(llr, decoded, cw); dmin = 0.0f; if (ndepth >= 3 && nharderrors < 0) { int ndeep = 3; if (std::abs(nfqso - f1) <= napwid) { if ((ipass == 3 || ipass == 4) && !nagain) { ndeep = 3; } else { ndeep = 4; } } if (nagain) { ndeep = 5; } nharderrors = osd174(llr, ndeep, decoded, cw, dmin); } xsnr = -99.0f; // Check for all-zero codeword if (std::all_of(cw.begin(), cw.end(), [](int x) { return x == 0; })) { continue; } if (nharderrors >= 0 && nharderrors + dmin < 60.0f && !(sync < 2.0f && nharderrors > 35) && !(ipass > 2 && nharderrors > 39) && !(ipass == 4 && nharderrors > 30)) { if (checkCRC12(decoded)) { if (syncStats) emitEvent(JS8::Event::SyncState{JS8::Event::SyncState::Type::DECODED, Mode::NSUBMODE, f1, xdt2, {.decoded = sync}}); auto message = extractmessage174(decoded); int const i3bit = 4 * decoded[72] + 2 * decoded[73] + decoded[74]; std::array itone; JS8::encode(i3bit, Costas, message.data(), itone.data()); // Subtract signal if needed. if (lsubtract) subtractjs8(genjs8refsig(itone, f1), xdt2); float xsig = 0.0f; float xnoi = 0.0f; for (int i = 0; i < NN; ++i) { xsig += std::pow(s2[ itone[i] ][i], 2); // Signal power xnoi += std::pow(s2[(itone[i] + 4) % 7][i], 2); // Noise power } xsnr = 10.0f * std::log10((xnoi > 0.0f && xnoi < xsig) ? xsig / xnoi - 1.0f : 0.001f) - 27.0f; if (!nagain) { float const x = xsig / xbase - 1.0f; float const x2 = (x > 1.259e-10f) ? 10.0f * std::log10(x) : -99.0f; xsnr = x2 - 32.0f; } if (xsnr < -28.0f) xsnr = -28.0f; return std::make_optional(i3bit, message); } } else { nharderrors = -1; } } return std::nullopt; } // Compute noise baseline. Important to note that the Fortran version // used up to 1000 lower envelope points for the polynomial determination // here, which caused some oddities when the matrix was ill-conditioned. // // I'm trying an alternate approach based on Chebyshev nodes. // // Inputs: 1. savg // 2. Closed range of savg defined by [ia, ib] // // Outputs: 1. savg normalized to dB scale // 2. sbase void baselinejs8(int const ia, int const ib) { // Data referenced in savg is defined by the closed range [ia, ib]. auto const data = savg.begin() + ia; std::size_t const size = ib - ia + 1; // Convert savg from power scale to dB scale. std::transform(data, data + size, data, [](float const value) { return 10.0f * std::log10(value); }); // Loop invariants; sentinel one past the end of the range, and // the number of points in each of the arms on either side of a // node. auto const end = data + size; auto const arm = size / (2 * BASELINE_NODES.size()); // Collect lower envelope points; use Chebyshev node interpolants // to reduce Runge's phenomenon oscillations. for (std::size_t i = 0; i < BASELINE_NODES.size(); ++i) { auto const node = size * BASELINE_NODES[i]; auto const base = data + static_cast(std::round(node)); auto span = std::vector(std::clamp(base - arm, data, end), std::clamp(base + arm, data, end)); auto const n = span.size() * BASELINE_SAMPLE / 100; std::nth_element(span.begin(), span.begin() + n, span.end()); p.row(i) << node, span[n]; } // Extract x and y values from points and prepare the Vandermonde // matrix, initializing the first column with 1 (x^0); remaining // columns are filled with the Schur product. Eigen::VectorXd x = p.col(0); Eigen::VectorXd y = p.col(1); V.col(0).setOnes(); for (Eigen::Index i = 1; i < V.cols(); ++i) { V.col(i) = V.col(i - 1).cwiseProduct(x); } // Solve the least squares problem for polynomial coefficients; // evaluate the polynomial and create the baseline. c = V.colPivHouseholderQr().solve(y); sbase.fill(0.0f); for (std::size_t i = 0; i < size; ++i) sbase[ia + i] = evaluate(i) + 0.65f; } // Extracted from the downsampling process; this step is part of the // frequency-domain filtering process for downsampling the JS8 signal. // After the FFT, the resulting frequency-domain data (ds_cx) can be // manipulated (e.g., band-pass filtered or shifted). Subsequent inverse // FFT operations convert the filtered data back to the time domain at // a lower sample rate, achieving the desired downsampling. void computeBasebandFFT() { // ds_dx is an array of complex; we're going to do an in-place // FFT, so we'll interpret the first half of the array as if they were // floats, which they are. float * fftw_real = reinterpret_cast(ds_cx.data()); // Copy in data and zero-pad any remainder; not all modes will have // a remainder. std::copy(dd.begin(), dd.end(), fftw_real); std::fill(fftw_real + dd.size(), fftw_real + Mode::NDFFT1, 0.0f); fftwf_execute(plans[Plan::BB]); } // This function extracts a narrow frequency band around the target frequency f0, // applies tapering to reduce spectral artifacts, aligns the signal to the center // frequency, performs an inverse FFT to convert the data back into the time domain, // and normalizes the result for further processing in the JS8 decoding pipeline. void js8_downsample(float const f0) { // Frequency band extraction; identifies a narrow frequency band around the // target frequency (f0) based on a predefined range (8.5 baud above and 1.5 // baud below). The indices of this range in the frequency-domain representation // (ds_cx) are calculated (ib and it), and the relevant frequency-domain samples // are extracted into cd0. constexpr float DF = 12000.0f / Mode::NDFFT1; constexpr float BAUD = 12000.0f / Mode::NSPS; float const ft = f0 + 8.5f * BAUD; float const fb = f0 - 1.5f * BAUD; int const i0 = static_cast(std::round(f0 / DF)); int const it = std::min( static_cast(std::round(ft / DF)), Mode::NDFFT1 / 2); int const ib = std::max(0, static_cast(std::round(fb / DF))); std::size_t const NDD_SIZE = Mode::NDD + 1; std::size_t const RANGE_SIZE = it - ib + 1; std::fill_n(cd0.begin(), Mode::NDFFT2, ZERO); std::copy(ds_cx.begin() + ib, ds_cx.begin() + ib + RANGE_SIZE, cd0.begin()); // Tapering is applied to smooth the edges of the frequency band, reducing // spectral leakage during the inverse FFT. Reversed taper at the beginning, // normal taper at the end. auto const head = cd0.begin(); auto const tail = cd0.begin() + RANGE_SIZE; std::transform(head, head + NDD_SIZE, Taper[0].begin(), head, std::multiplies<>()); std::transform(tail - NDD_SIZE, tail, Taper[1].begin(), tail - NDD_SIZE, std::multiplies<>()); // The extracted frequency band is aligned to the center of the frequency domain // representation (i0 - ib) via a cyclic shift using std::rotate. This centers // the desired signal. std::rotate(cd0.begin(), cd0.begin() + (i0 - ib), cd0.begin() + Mode::NDFFT2); // An inverse FFT is performed on the frequency-domain data (cd0) to transform it // back into the time domain, effectively yielding a downsampled, time-domain signal // focused on the extracted narrow frequency band. fftwf_execute(plans[Plan::DS]); // The resulting time-domain samples are normalized by a factor derived from the // input and output FFT sizes (Mode::NDFFT1 and Mode::NDFFT2), ensuring consistency // in the signal’s amplitude. float const factor = 1.0f / std::sqrt(static_cast(Mode::NDFFT1) * Mode::NDFFT2); std::transform(cd0.begin(), cd0.end(), cd0.begin(), [factor](auto & value) { return value * factor; }); } // Evaluate the synchronization power of signal segments, ranks potential candidates, and // extracts the most promising ones for further decoding. // // Detailed Steps: // // 1. Compute Symbol Spectra: // // - The signal is processed in overlapping segments, with each segment multiplied by // a Nuttall window to reduce spectral leakage. // - An FFT is performed on each windowed segment to obtain the frequency-domain // representation. // - The power spectrum of each segment is computed, and the average spectrum is // accumulated across segments. // // 2. Filter Edge Adjustments: // // - Adjusts the frequency bounds (nfa and nfb) to ensure the analysis remains // within valid and meaningful regions of the signal. // // 3. Baseline Computation: // // - The average spectrum is converted to a dB scale. // - Baseline is computed to distinguish significant signal components from // background noise. // // 4. Synchronization Metric Calculation: // // - For each frequency bin in the specified range, evaluates synchronization // power using a Costas waveform. // - Sync metric is computed over the index range, considering all combinations // of Costas patterns. // - The maximum sync value and its corresponding offset are recorded for each // frequency bin. // // 5. Normalization: // // - The sync values are normalized to the 40th percentile value using a ranked // index. This ensures a consistent scaling across different signals and noise // levels. // // 6. Candidate Extraction: // // - Candidates with a strong sync metric (above a defined threshold) are extracted. // - Near-duplicate candidates of lesser synchronization power, based on frequency // proximity, are eliminated. // // 7. Output: // // - Returns a vector of the most promising signal candidates, sorted by their // synchronization power. It's expected that these will be re-sorted by the // caller into a desirable order, but synchronization power order facilitates // debugging this function. // // Note: The Fortran version of this routine would normalize `s` at the end of this // function, but I'm unsure why; nothing beyond this function references `s`, // so it was effectively a somewhat expensive dead store. It's been eliminated // in this version. std::vector syncjs8(int nfa, int nfb) { // Compute symbol spectra savg.fill(0.0f); for (int j = 0; j < Mode::NHSYM; ++j) { int const ia = j * Mode::NSTEP; int const ib = ia + Mode::NFFT1; if (ib > Mode::NMAX) break; std::transform(dd.begin() + ia, dd.begin() + ib, nuttal.begin(), reinterpret_cast(sd.data()), std::multiplies{}); fftwf_execute(plans[Plan::SD]); // Compute power spectrum for (int i = 0; i < Mode::NSPS; ++i) { auto const power = std::norm(sd[i]); s[i][j] = power; savg[i] += power; } } // Filter edge sanity measures int const nwin = nfb - nfa; if (nfa < 100) { nfa = 100; if (nwin < 100) nfb = nfa + nwin; } if (nfb > 4910) { nfb = 4910; if (nwin < 100) nfa = nfb - nwin; } auto const ia = std::max(0, static_cast(std::round(nfa / Mode::DF))); auto const ib = static_cast(std::round(nfb / Mode::DF)); // Convert average spectrum from power to db scale and compute // baseline. baselinejs8(ia, ib); // Compute and populate the sync index. sync.clear(); for (int i = ia; i <= ib; ++i) { float max_value = -std::numeric_limits::infinity(); int max_index = -Mode::JZ; for (int j = -Mode::JZ; j <= Mode::JZ; ++j) { std::array, 2> t{}; for (int p = 0; p < 3; ++p) { for (int n = 0; n < 7; ++n) { int const offset = j + Mode::JSTRT + NSSY * n + p * 36 * NSSY; if (offset >= 0 && offset < Mode::NHSYM) { // Accumulate Costas pattern contributions. t[0][p] += s[i + NFOS * Costas[p][n]][offset]; // Accumulate sum over all frequencies for this block. for (int freq = 0; freq < 7; ++freq) { t[1][p] += s[i + NFOS * freq][offset]; } } } } // Compute sync metric over the index range. We are at the moment // maintaining the Fortran summation methodology for compatibility // testing; there are more efficient ways to do this, but IEEE 754 // addition is a touchy thing, so we'll need to ensure that any // changes don't negatively affect result precision. auto const compute_sync = [&t](int start, int end) { float tx = 0.0f; float t0 = 0.0f; for (int i = start; i <= end; ++i) { tx += t[0][i]; t0 += t[1][i]; } return tx / ((t0 - tx) / 6.0f); }; if (auto const sync_value = std::max({ compute_sync(0, 2), compute_sync(0, 1), compute_sync(1, 2) }); sync_value > max_value) { max_value = sync_value; max_index = j; } } sync.emplace(Mode::DF * i, Mode::TSTEP * (max_index + 0.5f), max_value); } // If we found nothing, we're done here. if (sync.empty()) return {}; // Access the sync indices. auto & freqIndex = sync.get(); auto & rankIndex = sync.get(); auto & syncIndex = sync.get(); // Normalize to the 40th percentile using the frequency index, // which is stable under sync value mutation. One thing to note // here is that the Fortran version didn't seem to reliably // calculate the 40th percentile rank; sometimes high, other // times low, infrequently actually the 40th percentile value. // This method should be perfectly accurate in all cases. auto const normalize = [ sync = rankIndex.nth(rankIndex.size() * 4 / 10)->sync ] (Sync & entry) { entry.sync /= sync; }; for (auto it = freqIndex.begin(); it != freqIndex.end(); ++it) { freqIndex.modify(it, normalize); } // Extract candidates. std::vector candidates; for (auto it = syncIndex.begin(); it != syncIndex.end() && candidates.size() < NMAXCAND; it = syncIndex.begin()) { // Stop iteration if below threshold or invalid; as the // index is sorted by sync, any subsequent entries will // also be below the threshold or invalid. if (it->sync < ASYNCMIN || std::isnan(it->sync)) break; // Good value, relatively strong; save the candidate. candidates.push_back(*it); // Remove the candidate and any near-duplicates based // on frequency. This invalidates `it`, so we reset it // to the index begin in the loop increment condition. freqIndex.erase( freqIndex.lower_bound(it->freq - Mode::AZ), freqIndex.upper_bound(it->freq + Mode::AZ)); } return candidates; } // Returns the total synchronization power, which is a measure of how well // the signal aligns with the Costas sequence after accounting for the // frequency adjustment. Used to identify the best alignment for further // decoding. float syncjs8d(int const i0, float const delf) { constexpr float BASE_DPHI = TAU * (1.0f / (12000.0f / Mode::NDOWN)); // If delta frequency is non-zero, compute the frequency // adjustment array, otherwise, use what'll be an identity // transfrom when multiplied. std::array, Mode::NDOWNSPS> freqAdjust; if (delf != 0.0f) { float const dphi = BASE_DPHI * delf; float phi = 0.0f; // std::fmod() is almost like Fortran's mod(), but not quite; // Since delf can be negative, we must ensure that phi stays // within [0, TAU), which Fortran's mod() handles by itself. for (int i = 0; i < Mode::NDOWNSPS; ++i) { freqAdjust[i] = std::polar(1.0f, phi); if (phi = std::fmod(phi + dphi, TAU); phi < 0.0f) { phi += TAU; } } } else { freqAdjust.fill(std::complex{1.0f, 0.0f}); } // Compute sync power by looping over the Costas indices for // each of the 3 Costas blocks, accumulating as we go. float sync = 0.0f; for (int i = 0; i < 3; ++i) { for (int j = 0; j < 7; ++j) { if (auto const offset = 36 * i * Mode::NDOWNSPS + i0 + j * Mode::NDOWNSPS; offset >= 0 && offset + Mode::NDOWNSPS <= Mode::NP2) { sync += std::norm( std::transform_reduce( freqAdjust.begin(), // Range start freqAdjust.end(), // Range end cd0.begin() + offset, // Data start std::complex{}, // Initial reduction value std::plus<>{}, // Reduction by accumulation [&](auto const & fa, // Conjugate and multiply auto const & cd) { return cd * std::conj(fa * csyncs[i][j][&fa - &freqAdjust[0]]); } )); } } } return sync; } // Generate a reference signal, based on the provided tone sequence and // base frequency. The output is a vector of complex values representing // the signal in the time domain. std::vector> genjs8refsig(std::array const & itone, float const f0) { // Precompute the base frequency contribution; full circle in // radians, multipled by the base frequency, multiplied by the // sampling interval, i.e., the time step between samples, which // results in the base frequency phase increment. Start the // phase accumulator off at zero. float const BFPI = TAU * f0 * (1.0f / 12000.0f); auto phi = 0.0f; std::vector> cref; cref.reserve(NN * Mode::NSPS); for (int i = 0; i < NN; ++i) { // Compute phase increment for the tone; frequency offset is // determined by the tone value. float const dphi = BFPI + TAU * static_cast(itone[i]) / Mode::NSPS; // Iterate over the samples per symbol to generate the time // domain signal. for (std::size_t is = 0; is < Mode::NSPS; ++is) { cref.push_back(std::polar(1.0f, phi)); phi = std::fmod(phi + dphi, TAU); } } return cref; } // Subtract a JS8 signal // // Measured signal : dd(t) = a(t)cos(2*pi*f0*t+theta(t)) // Reference signal : cref(t) = exp( j*(2*pi*f0*t+phi(t)) ) // Complex amp : cfilt(t) = LPF[ dd(t)*CONJG(cref(t)) ] // Subtract : dd(t) = dd(t) - 2*REAL{cref*cfilt} // // Important to note that dt can be negative here. void subtractjs8(std::vector> const & cref, float const dt) { auto const nstart = static_cast(dt * 12000.0f); std::size_t const cref_start = (nstart < 0) ? static_cast(-nstart) : 0; std::size_t const dd_start = (nstart > 0) ? static_cast( nstart) : 0; auto const size = std::min(cref.size() - cref_start, dd.size() - dd_start); // Populate complex filter with the conjugate of the reference signal. for (std::size_t i = 0; i < size; ++i) { cfilt[i] = dd[dd_start + i] * std::conj(cref[cref_start + i]); } // Zero-fill the remainder, if any. std::fill(cfilt.begin() + size, cfilt.end(), ZERO); // FFT to the frequency domain. fftwf_execute(plans[Plan::CF]); // Apply the filter in the frequency domain. std::transform(cfilt.begin(), cfilt.end(), filter.begin(), cfilt.begin(), std::multiplies<>()); // Inverse FFT to return to the time domain. fftwf_execute(plans[Plan::CB]); // Subtract the reconstructed signal. for (std::size_t i = 0; i < size; ++i) { dd[dd_start + i] -= 2.0f * std::real(cfilt[i] * cref[cref_start + i]); } } public: // Constructor explicit DecodeMode(JS8::Event::Emitter emitter) : emitEvent(emitter) { // Intialize the Nuttal window. In theory, we can do this as a // constexpr function at compile time, but doing so yield results // slightly different than the Fortran version did, so for sanity // while testing, we'll opt for consistency. IEEE 754 is always a // bit brittle. constexpr float a0 = 0.3635819f; constexpr float a1 = -0.4891775f; constexpr float a2 = 0.1365995f; constexpr float a3 = -0.0106411f; // Computed Pi constant to match the Fortran version; we could // probably use M_PI here, but for the moment, matching Fortran // exactly. float const pi = 4.0f * std::atan(1.0f); float sum = 0.0f; for (std::size_t i = 0; i < nuttal.size(); ++i) { float value = a0; float value_c = 0.0f; // Compensation for lost low-order bits // Naive summation here will exhibit substantial precision loss // relative to the Fortran version; we use Kahan summation to // compensate, which should provide identical results to Fortran. auto const kahan_add = [&](float const term) { float const y = term - value_c; float const t = value + y; value_c = (t - value) - y; value = t; }; kahan_add(a1 * std::cos(2 * pi * i / nuttal.size())); kahan_add(a2 * std::cos(4 * pi * i / nuttal.size())); kahan_add(a3 * std::cos(6 * pi * i / nuttal.size())); nuttal[i] = value; sum += value; } // Normalize the Nuttal window. for (auto & value : nuttal) value = value / sum * nuttal.size() / 300.0f; // Initialize Costas waveforms. for (int i = 0; i < 7; ++i) { float const dphia = TAU * Costas[0][i] / Mode::NDOWNSPS; float const dphib = TAU * Costas[1][i] / Mode::NDOWNSPS; float const dphic = TAU * Costas[2][i] / Mode::NDOWNSPS; float phia = 0.0f; float phib = 0.0f; float phic = 0.0f; for (int j = 0; j < Mode::NDOWNSPS; ++j) { csyncs[0][i][j] = std::polar(1.0f, phia); csyncs[1][i][j] = std::polar(1.0f, phib); csyncs[2][i][j] = std::polar(1.0f, phic); phia = std::fmod(phia + dphia, TAU); phib = std::fmod(phib + dphib, TAU); phic = std::fmod(phic + dphic, TAU); } } // First-time filter creation std::vector window(NFILT + 1); sum = 0.0f; // Compute the Hann-like window for (int j = -NFILT / 2; j <= NFILT / 2; ++j) { float const value = std::pow(std::cos(M_PI * j / NFILT), 2); window[j + NFILT / 2] = value; sum += value; } // Normalize the window. std::transform(window.begin(), window.end(), window.begin(), [sum](float val) { return val / sum; }); // Populate the first and last portions of filter with the window. for (std::size_t i = 0; i < NFILT / 2; ++i) { filter[i] = std::complex{window[i ], 0.0f}; filter[i + Mode::NMAX - NFILT] = std::complex{window[i + NFILT / 2], 0.0f}; } // Explicitly zero the middle portion. std::fill(filter.begin() + NFILT / 2, filter.begin() + Mode::NMAX - NFILT / 2, ZERO); // Apply FFT to the filter coefficients. fftwf_plan fftw_plan; { std::lock_guard lock(fftw_mutex); fftw_plan = fftwf_plan_dft_1d(Mode::NMAX, reinterpret_cast(filter.data()), reinterpret_cast(filter.data()), FFTW_FORWARD, FFTW_ESTIMATE_PATIENT); if (!fftw_plan) { throw std::runtime_error("Failed to create FFT plan"); } } fftwf_execute(fftw_plan); { std::lock_guard lock(fftw_mutex); fftwf_destroy_plan(fftw_plan); } std::transform(filter.begin(), filter.end(), filter.begin(), [factor = 1.0f / Mode::NMAX](std::complex val) { return val * factor; }); // The rest of our FFT plans are always the same size and operate on the // same data, so we can reuse them as long as we're alive. std::lock_guard lock(fftw_mutex); plans[Plan::DS] = fftwf_plan_dft_1d(Mode::NDFFT2, reinterpret_cast(cd0.data()), reinterpret_cast(cd0.data()), FFTW_BACKWARD, FFTW_ESTIMATE_PATIENT); plans[Plan::BB] = fftwf_plan_dft_r2c_1d(Mode::NDFFT1, reinterpret_cast(ds_cx.data()), reinterpret_cast(ds_cx.data()), FFTW_ESTIMATE_PATIENT); plans[Plan::CF] = fftwf_plan_dft_1d(Mode::NMAX, reinterpret_cast(cfilt.data()), reinterpret_cast(cfilt.data()), FFTW_FORWARD, FFTW_ESTIMATE_PATIENT); plans[Plan::CB] = fftwf_plan_dft_1d(Mode::NMAX, reinterpret_cast(cfilt.data()), reinterpret_cast(cfilt.data()), FFTW_BACKWARD, FFTW_ESTIMATE_PATIENT); plans[Plan::SD] = fftwf_plan_dft_r2c_1d(Mode::NFFT1, reinterpret_cast(sd.data()), reinterpret_cast(sd.data()), FFTW_ESTIMATE_PATIENT); plans[Plan::CS] = fftwf_plan_dft_1d(Mode::NDOWNSPS, reinterpret_cast(csymb.data()), reinterpret_cast(csymb.data()), FFTW_FORWARD, FFTW_ESTIMATE_PATIENT); for (auto plan : plans) { if (!plan) throw std::runtime_error("Failed to create FFT plan"); } } // Ensure copies and moves can't be performed; having to deal with // transfer of our FFT plans would harsh our mellow; best avoided. DecodeMode (DecodeMode const &) = delete; DecodeMode & operator=(DecodeMode const &) = delete; DecodeMode (DecodeMode &&) noexcept = delete; DecodeMode & operator=(DecodeMode &&) noexcept = delete; // Destructor ~DecodeMode() { std::lock_guard lock(fftw_mutex); for (auto plan : plans) { if (plan) fftwf_destroy_plan(plan); } } // Decode entry point. int operator()(struct dec_data const & data, int const kpos, int const ksz) { // Copy the relevant frames for decoding auto const pos = std::max(0, kpos); auto const sz = std::max(0, ksz); assert(sz <= Mode::NMAX); if (data.params.syncStats) emitEvent(JS8::Event::SyncStart{pos, sz}); auto const ddCopy = [](auto const begin, auto const end, auto const to) { std::transform(begin, end, to, [](auto const value) { return static_cast(value); }); }; dd.fill(0.0f); if ((MAX_BUFFER_SIZE - pos) < sz) { // Wrap case; split into two parts. int const firstsize = MAX_BUFFER_SIZE - pos; int const secondsize = sz - firstsize; ddCopy(std::begin(data.d2) + pos, std::begin(data.d2) + pos + firstsize, dd.begin()); ddCopy(std::begin(data.d2), std::begin(data.d2) + secondsize, dd.begin() + firstsize); } else { // Non-wrapping case; copy directly. ddCopy(std::begin(data.d2) + pos, std::begin(data.d2) + pos + sz, dd.begin()); } int const ifa = data.params.nagain ? data.params.nfqso - 10 : data.params.nfa; int const ifb = data.params.nagain ? data.params.nfqso + 10 : data.params.nfb; int const npass = calculateNPass(data.params.ndepth); Decode::Map decodes; for (int ipass = 1; ipass <= npass; ++ipass) { // Determine if there's anything worth considering in the signal. // If not, then we can just bail completely; more passes will not // yield more results. If we do have some candidates, sort them // by frequency, but put any that are close to nfqso up front. auto candidates = syncjs8(ifa, ifb); if (candidates.empty()) break; std::sort(candidates.begin(), candidates.end(), [nfqso = data.params.nfqso](auto const & a, auto const & b) { auto const a_dist = std::abs(a.freq - nfqso); auto const b_dist = std::abs(b.freq - nfqso); if (a_dist < 10.0f && b_dist >= 10.0f) return true; if (b_dist < 10.0f && a_dist >= 10.0f) return false; return std::tie(a_dist, a.freq) < std::tie(b_dist, b.freq); }); // Recompute the baseband signal; subtraction during the last // pass might have changed the landscape. computeBasebandFFT(); bool const subtract = (ipass == 1 && data.params.ndepth != 1) || (ipass > 1 && ipass < 4); bool improved = false; for (auto [f1, xdt, sync] : candidates) { float xsnr = 0.0f; float dmin = 0.0f; int nharderrors = -1; if (auto decode = js8dec(data.params.syncStats, data.params.nfqso, data.params.ndepth, data.params.napwid, data.params.nagain, subtract, f1, xdt, nharderrors, dmin, xsnr)) { // We don't need to be emitting duplicate events for something // that's effectively the same SNR as a previous event. auto const snr = static_cast(std::round(xsnr)); // If this decode is new, or it's a duplicate with a better SNR // than what we had before, then our situation has improved and // we must announce that we've had some success. if (auto [it, inserted] = decodes.try_emplace(std::move(*decode), snr); inserted || it->second < snr) { improved = true; // Update the SNR if this is an improved decode. if (!inserted) it->second = snr; // Emit decoded events on new or improved decodes. emitEvent(JS8::Event::Decoded{data.params.nutc, snr, xdt - Mode::ASTART, f1, it->first.data, it->first.type, 1.0f - (nharderrors + dmin) / 60.0f, Mode::NSUBMODE}); } } } // If nothing from this pass improved our situation, there's no // point in trying any remaining passes. if (!improved) break; } // Let the caller know how many unique decodes we discovered, if any. return decodes.size(); } }; // Explicit template class instantiations; avoids compiler complaints // about unused variables. template class DecodeMode; template class DecodeMode; template class DecodeMode; template class DecodeMode; template class DecodeMode; } /******************************************************************************/ // Worker /******************************************************************************/ namespace JS8 { class Worker : public QObject { Q_OBJECT QSemaphore * m_semaphore; struct dec_data m_data; std::atomic m_quit; struct DecodeEntry { std::variant< DecodeMode, DecodeMode, DecodeMode, DecodeMode, DecodeMode > decode; int mode; int flag; int & kpos; int & ksz; template DecodeEntry(std::in_place_type_t, int mode, int flag, int & kpos, int & ksz, JS8::Event::Emitter emitter) : decode(std::in_place_type, std::move(emitter)) , mode (mode) , flag (flag) , kpos (kpos) , ksz (ksz) {} }; template DecodeEntry makeDecodeEntry(int mode, int flag, int & kpos, int & ksz, Worker * worker) { return DecodeEntry( std::in_place_type>, mode, flag, kpos, ksz, [worker](Event::Variant const& event) { worker->decodeEvent(event); } ); } std::array m_decodes = {{ makeDecodeEntry(8, 1 << 4, m_data.params.kposI, m_data.params.kszI, this), makeDecodeEntry(4, 1 << 3, m_data.params.kposE, m_data.params.kszE, this), makeDecodeEntry(2, 1 << 2, m_data.params.kposC, m_data.params.kszC, this), makeDecodeEntry(1, 1 << 1, m_data.params.kposB, m_data.params.kszB, this), makeDecodeEntry(0, 1 << 0, m_data.params.kposA, m_data.params.kszA, this) }}; public: explicit Worker(QSemaphore * semaphore, QObject * parent = nullptr) : QObject (parent) , m_semaphore(semaphore) , m_quit (false) {} void stop() { m_quit = true; } void copy() { m_data = dec_data; }; signals: void decodeEvent(Event::Variant const &); public slots: void run() { while (true) { m_semaphore->acquire(); if (m_quit) break; auto const one = m_data.params.nsubmode; auto const all = m_data.params.nsubmodes; int sum = 0; emit decodeEvent(JS8::Event::DecodeStarted{one, all}); for (auto & entry : m_decodes) { if ((one == entry.mode) || ((all & entry.flag) == entry.flag)) { std::visit([&](auto && decode) { sum += decode(m_data, entry.kpos, entry.ksz); }, entry.decode); } } emit decodeEvent(JS8::Event::DecodeFinished{sum}); } } }; } /******************************************************************************/ // Public Interface - Decoding /******************************************************************************/ #include "JS8.moc" namespace JS8 { Decoder::Decoder(QObject * parent) : QObject(parent) , m_semaphore(0) , m_worker(new Worker(&m_semaphore)) { m_worker->moveToThread(&m_thread); connect(&m_thread, &QThread::started, m_worker, &Worker::run); connect(&m_thread, &QThread::finished, m_worker, &QObject::deleteLater); connect(m_worker, &Worker::decodeEvent, this, &Decoder::decodeEvent); } void Decoder::start(QThread::Priority priority) { m_thread.start(priority); } void Decoder::quit() { m_worker->stop(); m_semaphore.release(); m_thread.quit(); m_thread.wait(); } void Decoder::decode() { m_worker->copy(); m_semaphore.release(); } } /******************************************************************************/ // Public Interface - Encoding /******************************************************************************/ namespace JS8 { // Port of the Fortran `genjs8` subroutine; from the 12 bytes of `message`, // construct an 87-bit JS8 message and encode it into tones. Costas array // to use supplied by the caller, as is the type of message, indicated by // the lower 3 bits of `type`. void encode(int const type, Costas::Array const & costas, const char * const message, int * const tones) { // Our initial goal here is an 87-bit message, for which a std::bitset // would be the obvious choice, but we've got to compute a checksum of // the first 75 bits; thus, an array instead. // // Message structure: // // +----------+----------+----------+ // | | | 72 bits | 12 6-bit words // | | +==========+ // | | 87 bits | 3 bits | Frame type // | 11 bytes | +==========+ // | | | 12 bits | 12-bit BE checksum // | |----------+==========+ // | | 1 bit | 1 bit | Leftover bit in array // +----------+----------+==========+ std::array bytes = {}; // Convert the 12 characters we've been handed to 6-bit words and pack // them into the byte array, 4 characters, 24 bits at a time, into the // 9 bytes [0,8], 72 bits total. Throws if handed an invalid character. for (int i = 0, j = 0; i < 12; i += 4, j += 3) { std::uint32_t words = (alphabetWord(message[i ]) << 18) | (alphabetWord(message[i + 1]) << 12) | (alphabetWord(message[i + 2]) << 6) | alphabetWord(message[i + 3]); bytes[j ] = words >> 16; bytes[j + 1] = words >> 8; bytes[j + 2] = words; } // The bottom 3 bits of type are the frame type; these go into the // next 3 bits in the byte array, i.e., the first 3 bits of byte 9, // after which we'll be at 75 bits in total. bytes[9] = (type & 0b111) << 5; // We now need to compute the augmented CRC-12 of the complete // byte array, including the trailing zero bits that we've not // set yet. auto const crc = CRC12(bytes); // That CRC needs to occupy the next 12 bits of the array, i.e., // the final 5 bits of byte 9, and the first 7 bits of byte 10. bytes[9] |= (crc >> 7) & 0x1F; bytes[10] = (crc & 0x7F) << 1; // That's it for our 87-bit message; we're now going to turn it // into two blocks of 29 3-bit words, which will in turn become // tones, the first block being parity for the second, bracketed // by the Costas arrays. // // Output structure: // // +----------+----------+ // | | 7 bytes | Costas array A // | +==========+ // | | 29 bytes | Parity data // | +==========+ // | 79 bytes | 7 bytes | Costas array B // | +==========+ // | | 29 bytes | Output data // | +==========+ // | | 7 bytes | Costas array C // +----------+==========+ auto costasData = tones; auto parityData = tones + 7; auto outputData = tones + 43; // Output the 3 Costas arrays at offsets 0, 36, and 72. for (auto const & array : costas) { std::copy(array.begin(), array.end(), costasData); costasData += 36; } // Our 87 bits are going to be morphed into two sets of 29 3-bit // words, the first one parity for the second; we're going to do // this in parallel. std::size_t outputBits = 0; std::size_t outputByte = 0; std::uint8_t outputMask = 0x80; std::uint8_t outputWord = 0; std::uint8_t parityWord = 0; for (std::size_t i = 0; i < 87; ++i) { // Compute parity for the current bit; inputs for parity computation // are the corresponding parity matrix row and each bit in the message; // the parity matrix row, referenced by `i`, contains 87 boolean values. // Each `true` value defines a message bit that must be summed, modulo // 2, to produce the parity check bit for the bit we're working on now. // // In short, if the parity matrix bit `(i, j)` and the message bit `j` // are both set, then we add 1 to the parity bits accumulator. If, after // processing all message bits the accumulated result is odd, then the // parity bit should be set for the current bit. std::size_t parityBits = 0; std::size_t parityByte = 0; std::uint8_t parityMask = 0x80; for (std::size_t j = 0; j < 87; ++j) { parityBits += parity(i, j) && (bytes[parityByte] & parityMask); parityMask = (parityMask == 1) ? (++parityByte, 0x80) : (parityMask >> 1); } // Accumulate the parity and output bits; this is the point at which // we perform the modulo 2 operation on the summed parity bits. parityWord = (parityWord << 1) | (parityBits & 1); outputWord = (outputWord << 1) | ((bytes[outputByte] & outputMask) != 0); outputMask = (outputMask == 1) ? (++outputByte, 0x80) : (outputMask >> 1); // If we're at a 3-bit boundary, output the words and reset. if (++outputBits == 3) { *parityData++ = parityWord; *outputData++ = outputWord; parityWord = 0; outputWord = 0; outputBits = 0; } } } } /******************************************************************************/