Created
March 30, 2026 20:26
-
-
Save peterbmarks/fe0e952224f71883d43e6eaf52b2728a to your computer and use it in GitHub Desktop.
Convert HackRF Mayhem Portapack capture to a format playable with hackrf_transfer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // c16_to_iq8.cpp | |
| // Convert a HackRF Mayhem Portapack recording (name.TXT + name.C16) | |
| // to a raw 8-bit signed IQ file at 10 Msps. | |
| // | |
| // Compile: | |
| // g++ -O2 -std=c++17 -o c16_to_iq8 c16_to_iq8.cpp | |
| // | |
| // Usage: | |
| // ./c16_to_iq8 <basename> [output.iq8] | |
| // ./c16_to_iq8 40mssb # reads 40mssb.TXT + 40mssb.C16, writes 40mssb.iq8 | |
| // ./c16_to_iq8 40mssb out.iq8 | |
| // | |
| // Transmit the output like this: | |
| // hackrf_transfer -t 40mssb.iq8 -f 7100000 -x 1 -R | |
| #include <algorithm> | |
| #include <cassert> | |
| #include <cmath> | |
| #include <cstdint> | |
| #include <fstream> | |
| #include <iostream> | |
| #include <numeric> | |
| #include <stdexcept> | |
| #include <string> | |
| #include <vector> | |
| static constexpr double PI = 3.14159265358979323846; | |
| static constexpr int DST_RATE = 10'000'000; // target sample rate (10 Msps) | |
| static constexpr int K = 32; // polyphase taps per phase | |
| static constexpr size_t CHUNK = 65536; // input samples per I/O chunk | |
| // --------------------------------------------------------------------------- | |
| // Metadata | |
| struct Metadata { | |
| long long center_frequency = 0; | |
| int sample_rate = 0; | |
| }; | |
| static Metadata parse_metadata(const std::string& path) { | |
| Metadata m; | |
| std::ifstream f(path); | |
| if (!f) throw std::runtime_error("Cannot open: " + path); | |
| std::string line; | |
| while (std::getline(f, line)) { | |
| // Strip CR/LF/spaces from end | |
| while (!line.empty() && (line.back() == '\r' || line.back() == '\n' || line.back() == ' ')) | |
| line.pop_back(); | |
| auto eq = line.find('='); | |
| if (eq == std::string::npos) continue; | |
| std::string key = line.substr(0, eq); | |
| std::string val = line.substr(eq + 1); | |
| if (key == "center_frequency") m.center_frequency = std::stoll(val); | |
| else if (key == "sample_rate") m.sample_rate = std::stoi(val); | |
| } | |
| if (m.sample_rate == 0) | |
| throw std::runtime_error("sample_rate not found in " + path); | |
| return m; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Polyphase resampler | |
| // | |
| // Rational resampling by L/M: upsample by L, lowpass filter, downsample by M. | |
| // | |
| // Prototype filter (length L*K) is a windowed sinc with cutoff at | |
| // fc = 1 / max(L,M) (normalised to upsampled-rate Nyquist, 0..1) | |
| // and passband gain = L so upsampling does not attenuate the signal. | |
| // | |
| // Polyphase decomposition: poly[p][k] = proto[p + k*L], p=0..L-1, k=0..K-1. | |
| // | |
| // For output sample n: | |
| // n_center = floor(n * M / L) -- centre input sample | |
| // phase = (n * M) % L -- which polyphase sub-filter | |
| // y[n] = sum_{k=0}^{K-1} poly[phase][k] * x[n_center - k] | |
| static std::vector<double> make_prototype(int L, int M) { | |
| int N = L * K; | |
| double fc = 1.0 / static_cast<double>(std::max(L, M)); | |
| std::vector<double> h(N); | |
| int center = N / 2; | |
| for (int i = 0; i < N; i++) { | |
| int n = i - center; | |
| double sinc = (n == 0) ? (2.0 * fc) | |
| : (std::sin(2.0 * PI * fc * n) / (PI * n)); | |
| double win = 0.5 * (1.0 - std::cos(2.0 * PI * i / (N - 1))); // Hann | |
| h[i] = sinc * win; | |
| } | |
| // Scale so sum = L (preserve amplitude after the upsample-by-L step) | |
| double sum = 0; | |
| for (double v : h) sum += v; | |
| for (double& v : h) v *= static_cast<double>(L) / sum; | |
| return h; | |
| } | |
| // --------------------------------------------------------------------------- | |
| int main(int argc, char* argv[]) { | |
| if (argc < 2) { | |
| std::cerr << "Usage: " << argv[0] << " <basename> [output.iq8]\n" | |
| << " Reads <basename>.TXT and <basename>.C16\n" | |
| << " Resamples to " << DST_RATE << " sps, writes 8-bit signed IQ\n"; | |
| return 1; | |
| } | |
| const std::string base = argv[1]; | |
| const std::string txt_path = base + ".TXT"; | |
| const std::string c16_path = base + ".C16"; | |
| const std::string out_path = (argc >= 3) ? argv[2] : base + ".iq8"; | |
| // -- Parse metadata ------------------------------------------------------ | |
| Metadata meta; | |
| try { | |
| meta = parse_metadata(txt_path); | |
| } catch (const std::exception& e) { | |
| std::cerr << "Error: " << e.what() << "\n"; | |
| return 1; | |
| } | |
| // -- Compute rational resample ratio ------------------------------------- | |
| int g = std::gcd(meta.sample_rate, DST_RATE); | |
| int L = DST_RATE / g; // upsample factor | |
| int M = meta.sample_rate / g; // downsample factor | |
| std::cout << "Center frequency : " << meta.center_frequency << " Hz\n" | |
| << "Source rate : " << meta.sample_rate << " Hz\n" | |
| << "Target rate : " << DST_RATE << " Hz\n" | |
| << "Resample ratio : up=" << L << " down=" << M << "\n" | |
| << "Prototype taps : " << L * K << "\n"; | |
| // -- Build polyphase filter bank ----------------------------------------- | |
| std::vector<double> proto = make_prototype(L, M); | |
| // poly[p][k] = proto[p + k*L] | |
| std::vector<std::vector<double>> poly(L, std::vector<double>(K)); | |
| for (int p = 0; p < L; p++) | |
| for (int k = 0; k < K; k++) | |
| poly[p][k] = proto[p + k * L]; | |
| // -- Open files ---------------------------------------------------------- | |
| std::ifstream fin(c16_path, std::ios::binary); | |
| if (!fin) { | |
| std::cerr << "Error: cannot open " << c16_path << "\n"; | |
| return 1; | |
| } | |
| // Get file size for progress reporting | |
| fin.seekg(0, std::ios::end); | |
| const long long file_bytes = static_cast<long long>(fin.tellg()); | |
| fin.seekg(0, std::ios::beg); | |
| const long long total_in = file_bytes / 4; // IQ pairs | |
| std::ofstream fout(out_path, std::ios::binary); | |
| if (!fout) { | |
| std::cerr << "Error: cannot write " << out_path << "\n"; | |
| return 1; | |
| } | |
| // -- Streaming polyphase resampling -------------------------------------- | |
| // History buffer: K past input samples (initialised to zero = silence) | |
| std::vector<double> hist_i(K, 0.0), hist_q(K, 0.0); | |
| // Raw read buffer | |
| std::vector<int16_t> raw(CHUNK * 2); | |
| // Extended buffer = [history (K) | current chunk (got)] | |
| std::vector<double> ci, cq; | |
| long long in_offset = 0; // absolute index of first sample in current chunk | |
| long long out_cursor = 0; // next output sample index to produce | |
| long long total_out = 0; | |
| // We also process a final flush pass with got=0 to drain the filter delay. | |
| bool eof = false; | |
| while (!eof) { | |
| size_t got; | |
| // Read next chunk (or flush with zeros after EOF) | |
| fin.read(reinterpret_cast<char*>(raw.data()), CHUNK * 4); | |
| got = static_cast<size_t>(fin.gcount() / 4); | |
| if (got == 0) { | |
| // Flush: pad with K zeros so the filter drains properly | |
| got = static_cast<size_t>(K); | |
| std::fill(raw.begin(), raw.begin() + K * 2, int16_t{0}); | |
| eof = true; | |
| } | |
| // Build extended buffer [history | current chunk] | |
| ci.resize(K + got); | |
| cq.resize(K + got); | |
| for (int k = 0; k < K; k++) { ci[k] = hist_i[k]; cq[k] = hist_q[k]; } | |
| for (size_t j = 0; j < got; j++) { | |
| ci[K + j] = raw[j * 2] / 32768.0; | |
| cq[K + j] = raw[j * 2 + 1] / 32768.0; | |
| } | |
| // Produce all output samples whose centre input index falls within | |
| // [in_offset, in_offset + got). | |
| std::vector<int8_t> out_buf; | |
| out_buf.reserve(static_cast<size_t>(got) * L / M * 2 + 4); | |
| while (true) { | |
| long long n_center = (out_cursor * static_cast<long long>(M)) / L; | |
| if (n_center >= in_offset + static_cast<long long>(got)) break; | |
| int phase = static_cast<int>((out_cursor * static_cast<long long>(M)) % L); | |
| int local_nc = static_cast<int>(n_center - in_offset + K); | |
| double ai = 0.0, aq = 0.0; | |
| for (int k = 0; k < K; k++) { | |
| int idx = local_nc - k; | |
| // idx ranges from local_nc down to local_nc-(K-1); | |
| // boundary check handles file start (idx<0) and any edge case | |
| if (idx >= 0 && idx < static_cast<int>(K + got)) { | |
| ai += poly[phase][k] * ci[idx]; | |
| aq += poly[phase][k] * cq[idx]; | |
| } | |
| } | |
| auto clamp8 = [](double v) -> int8_t { | |
| return static_cast<int8_t>( | |
| std::clamp(static_cast<int>(std::round(v * 127.0)), -127, 127)); | |
| }; | |
| out_buf.push_back(clamp8(ai)); | |
| out_buf.push_back(clamp8(aq)); | |
| ++out_cursor; | |
| } | |
| fout.write(reinterpret_cast<const char*>(out_buf.data()), | |
| static_cast<std::streamsize>(out_buf.size())); | |
| total_out += static_cast<long long>(out_buf.size() / 2); | |
| // Save last K input samples as history for the next chunk | |
| for (int k = 0; k < K; k++) { | |
| hist_i[k] = ci[got + k]; // ci[got .. got+K-1] | |
| hist_q[k] = cq[got + k]; | |
| } | |
| in_offset += static_cast<long long>(got); | |
| // Progress | |
| if (!eof && (in_offset % (1 << 20)) < static_cast<long long>(CHUNK)) { | |
| int pct = static_cast<int>(in_offset * 100 / std::max(total_in, 1LL)); | |
| std::cout << "\r" << pct << "% (" << in_offset << " / " << total_in | |
| << " samples) " << std::flush; | |
| } | |
| } | |
| std::cout << "\rWritten: " << out_path | |
| << " (" << total_out << " IQ samples at " << DST_RATE << " Hz)\n"; | |
| return 0; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment