Skip to content

Instantly share code, notes, and snippets.

@peterbmarks
Created March 30, 2026 20:26
Show Gist options
  • Select an option

  • Save peterbmarks/fe0e952224f71883d43e6eaf52b2728a to your computer and use it in GitHub Desktop.

Select an option

Save peterbmarks/fe0e952224f71883d43e6eaf52b2728a to your computer and use it in GitHub Desktop.
Convert HackRF Mayhem Portapack capture to a format playable with hackrf_transfer
// 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