Skip to content

Instantly share code, notes, and snippets.

@peterbmarks
Created February 15, 2026 03:34
Show Gist options
  • Select an option

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

Select an option

Save peterbmarks/8fb4148e40ed7bd0a6cee2097372bd69 to your computer and use it in GitHub Desktop.
Script to convert WAV file to IQ for transmitting on a HackRF Portapack Mayhem
#!/usr/bin/env python3
"""Convert a WAV file to C16 (raw 16-bit complex IQ) with a companion TXT metadata file.
Copy the resulting name.C16 and name.TXT files to the capture folder on the SD card
in a HackRF Mayhem Portapack to transmit using the Replay feature.
"""
import argparse
import sys
import wave
from pathlib import Path
import numpy as np
from scipy.signal import hilbert, resample_poly
from math import gcd
def main():
parser = argparse.ArgumentParser(
description="Convert a WAV file to C16 (raw 16-bit complex IQ) format with metadata TXT file."
)
parser.add_argument("input", help="Input WAV file path")
parser.add_argument("-o", "--output", help="Output base name (without extension). Defaults to input name.")
parser.add_argument("-f", "--center-frequency", type=int, required=True, help="Center frequency in Hz")
parser.add_argument("-r", "--sample-rate", type=int, default=None,
help="Sample rate in Hz (defaults to WAV file sample rate)")
parser.add_argument("-m", "--mode", choices=["usb", "lsb", "raw"], default="raw",
help="Sideband mode: usb (upper), lsb (lower), or raw (passthrough, default)")
args = parser.parse_args()
input_path = Path(args.input)
if not input_path.exists():
print(f"Error: {input_path} not found", file=sys.stderr)
sys.exit(1)
if args.output:
out_base = Path(args.output)
else:
out_base = input_path.with_suffix("")
try:
with wave.open(str(input_path), "rb") as wf:
n_channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
wav_rate = wf.getframerate()
n_frames = wf.getnframes()
raw = wf.readframes(n_frames)
except wave.Error as e:
print(f"Error reading WAV: {e}", file=sys.stderr)
sys.exit(1)
sample_rate = args.sample_rate if args.sample_rate is not None else wav_rate
if n_channels not in (1, 2):
print(f"Error: unsupported channel count {n_channels} (expected 1 or 2)", file=sys.stderr)
sys.exit(1)
if sampwidth not in (1, 2, 4):
print(f"Error: unsupported sample width {sampwidth} bytes", file=sys.stderr)
sys.exit(1)
# Decode and scale samples to signed 16-bit range
if sampwidth == 1:
# 8-bit WAV is unsigned
scaled = (np.frombuffer(raw, dtype=np.uint8).astype(np.int16) - 128) * 256
elif sampwidth == 2:
scaled = np.frombuffer(raw, dtype=np.dtype("<i2"))
elif sampwidth == 4:
scaled = (np.frombuffer(raw, dtype=np.dtype("<i4")) >> 16).astype(np.int16)
# Build separate I and Q channels
if n_channels == 2:
i_chan = np.array(scaled[0::2], dtype=np.float64)
q_chan = np.array(scaled[1::2], dtype=np.float64)
else:
i_chan = np.array(scaled, dtype=np.float64)
q_chan = np.zeros(len(scaled), dtype=np.float64)
# Apply sideband mode via Hilbert transform
if args.mode in ("usb", "lsb"):
analytic = hilbert(i_chan)
i_chan = np.real(analytic)
q_chan = np.imag(analytic)
if args.mode == "lsb":
q_chan = -q_chan
# Resample if target sample rate differs from WAV rate
if sample_rate != wav_rate:
g = gcd(sample_rate, wav_rate)
up = sample_rate // g
down = wav_rate // g
print(f"Resampling from {wav_rate} Hz to {sample_rate} Hz (up={up}, down={down})")
i_chan = resample_poly(i_chan, up, down)
q_chan = resample_poly(q_chan, up, down)
# Interleave and clamp to int16 range
iq = np.empty(len(i_chan) + len(q_chan), dtype=np.float64)
iq[0::2] = i_chan
iq[1::2] = q_chan
iq_clamped = np.clip(np.round(iq), -32768, 32767).astype(np.int16)
c16_path = out_base.with_suffix(".C16")
with open(c16_path, "wb") as f:
f.write(iq_clamped.tobytes())
txt_path = out_base.with_suffix(".TXT")
with open(txt_path, "w") as f:
f.write(f"center_frequency={args.center_frequency}\n")
f.write(f"sample_rate={sample_rate}\n")
print(f"Written: {c16_path} ({len(iq_clamped) // 2} IQ samples)")
print(f"Written: {txt_path}")
if __name__ == "__main__":
main()
@peterbmarks
Copy link
Author

Here's an example: ./wav2c16.py -f 7100000 -r 500000 -m lsb FDV_FromRadio_20260112-171113.wav -o fdv

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment