Created
February 15, 2026 03:34
-
-
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
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
| #!/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() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Here's an example: ./wav2c16.py -f 7100000 -r 500000 -m lsb FDV_FromRadio_20260112-171113.wav -o fdv