When I was building PIrateRF, I had two options. Option one: shell out to a pile of C binaries from rpitx every time I wanted to key up a transmitter, hand-build the arg slice by hand every single time, and pray nothing hangs. Option two: build the layer I actually wanted underneath it once, properly, and never touch raw os/exec arg-slicing again for this shit. I picked option two. That layer is gorpitx, and it’s the thing PIrateRF is actually standing on. Everyone who read the PIrateRF post saw the frontend. This is the engine room.
Shelling Out to rpitx by Hand
rpitx is a fantastic piece of RF wizardry and a genuinely unpleasant thing to drive from code. It’s a folder full of standalone C binaries — pifmrds, tune, morse, pocsag, and so on — each with its own argument order, its own quirks, and zero shared validation. Want to broadcast FM with RDS? Build a -freq/-audio/-pi/-ps/-rt arg slice by hand and hope you didn’t typo the PI code. Want to send a pager message? Better remember that POCSAG doesn’t take the message on the command line at all — it wants address:message piped into stdin. Every module is its own little snowflake of undocumented behavior, and one wrong frequency crashes straight into the Pi’s actual RF hardware limits with a binary that just dies.
On top of that: you’re now managing a long-running child process from Go. Does it need a timeout? Does it need graceful shutdown before you SIGKILL it? Can two of these run at once and stomp on the same GPIO pin? None of that is rpitx’s problem to solve, and it doesn’t. That’s what gorpitx exists to fix.
Singleton In, Process Out
gorpitx is a singleton. Call gorpitx.GetInstance() and a sync.Once spins up exactly one RPITX struct for the lifetime of the process — one piece of global RF hardware, one thing that’s allowed to touch it. That struct holds a map[ModuleName]Module registry, and every module implements one interface:
type Module interface {
ParseArgs(json.RawMessage) ([]string, io.Reader, error)
}You hand it raw JSON, it hands back a validated []string of CLI args and, if the module needs it (POCSAG, FSK), an io.Reader for stdin. That’s the entire extension point — adding module #13 someday means writing a struct with json tags, a validate(), a buildArgs(), and dropping it into the map in newRPITX(). Nothing else in the codebase has to know it exists.
RPITX.Exec(ctx, name, argsJSON, timeout) is the actual entry point, and it’s an atomic.Bool guarded critical section — try to run a second module while one’s already executing and you get ErrExecuting straight back, no queueing, no surprise concurrent transmissions fighting over the same antenna. Under the hood it resolves the module, calls ParseArgs, and hands the resulting command off to commander — yeah, my own process-management package — for the part that actually starts, tracks, streams, and kills the underlying binary. If you gave it a timeout, gorpitx races the process against a timer and does a graceful stop (SIGTERM, three-second grace window) before it gets murdered outright. StreamOutputs and StreamOutputsAsync pipe stdout/stderr into channels you own, so you can watch a transmission live instead of guessing whether it’s still alive.
The dev/prod switch is dead simple and lives in gonfiguration-parsed config plus goenv. Unless you explicitly export ENV=dev, gorpitx assumes production, checks os.Geteuid() != 0, and flat-out panic()s if you’re not root — because GPIO-level RF transmission needs root, full stop, no gentle degradation. In production it builds the real command: stdbuf -oL <path-to-binary> <args>, where the binary path is $GORPITX_PATH/<module-name> (default $HOME/rpitx, overridable via the GORPITX_PATH env var). In dev mode it never touches the real binaries at all — it builds a shell loop that echoes "mocking execution of <module> <args>..." once a second forever, so you can develop and test the entire execution lifecycle — timeouts, streaming, stop — without ever keying an actual transmitter. Every error you’ll actually catch is a plain sentinel from errors.go wrapped with ctxerrors: ErrUnknownModule, ErrExecuting, ErrNotExecuting, ErrFreqOutOfRange, ErrFreqPrecision, ErrPIInvalidHex, ErrPSTooLong. No magic strings to grep for, just errors.Is like it’s supposed to work.
Quick Start
go get github.com/psyb0t/gorpitxrpitx := gorpitx.GetInstance()
args := map[string]any{
"freq": 107.9,
"audio": "/path/to/audio.wav",
"pi": "1234",
"ps": "BADASS",
"rt": "Broadcasting from Go!",
}
argsJSON, _ := json.Marshal(args)
err := rpitx.Exec(context.Background(),
gorpitx.ModuleNamePIFMRDS, argsJSON, 5*time.Minute)You’ll need the actual rpitx binaries installed on the Pi separately (that part’s not gorpitx’s job — it just execs whatever’s sitting in GORPITX_PATH), plus minimodem/sox for the FSK module and socat for AudioSock broadcast, since those two run as embedded shell scripts rather than compiled binaries.
The Twelve Modules
This is the actual, source-verified list registered in newRPITX() — twelve entries, twelve structs, twelve ParseArgs implementations. Every frequency field except pifmrds’s is raw Hz, and every one of them gets range-checked against rpitx’s real hardware limits: 5 kHz to 1500 MHz (that’s the actual enforced constant in gorpitx.go — some of the per-file doc comments still say 50 kHz, but the code that runs checks against 5, so trust the code, not the comment, same as always).
- pifmrds — FM broadcast with RDS. The one module that takes frequency in MHz instead of Hz, and demands 0.1 MHz precision. Sets a PI station code (4 hex digits), a PS station name (8 chars max), scrolling RT radiotext (64 chars max), and optionally a named-pipe control channel so you can push new PS/RT at runtime with
mkfifo+echo. - tune — the dumbest module and the most useful for testing: just keys up a bare carrier at a given frequency, optional exit-immediately flag and PPM correction.
- morse — Morse code out. Frequency, dits-per-minute rate, message string. That’s it.
- pichirp — a frequency sweep generator. Center frequency, bandwidth, and sweep duration in seconds — a chirp signal for propagation testing.
- pocsag — pager protocol transmission. Baud rate (512/1200/2400), function bits (0-3), numeric mode, repeat count, polarity invert — and the messages themselves don’t go on the command line, they get piped into stdin as
address:messagelines, one per pager you’re paging. - spectrumpaint — paints an image into the RF spectrum from a raw picture file (320 bytes per row), with an optional frequency excursion.
- pift8 — FT8 digital mode. Frequency, message, PPM, a frequency offset clamped to 0-2500 Hz, a time-slot selector (0, 1, or “always”), and a repeat-every-15-seconds flag.
- pisstv — Slow Scan TV. Takes a 320-pixel-wide
.rgbpicture file and a frequency, transmits it as an SSTV image. - pirtty — RTTY teletype. Frequency, an optional space-frequency shift (default 170 Hz, mark = space + 170), and the message text.
- fsk — FSK transmission via an embedded shell script (written to
/tmp/fsk.shat init time viago:embed) that pipes through minimodem/sox. Takes either a file or raw text as input, baud rate (default 50, the cleanest rate in testing), and frequency. - audiosock-broadcast — streams live audio in from a Unix socket (fed by
socat) and modulates it as AM, DSB, USB, LSB, FM, or RAW, with configurable sample rate and gain. Also an embedded script under the hood. - sendiq — raw I/Q sample transmission, straight from a file or stdin. Configurable sample rate (10 kHz to 2 MHz, auto-decimated above 200 kHz native), harmonic multiplier, IQ sample format (i16/u8/float/double), power level (silently clamped to 0.0-7.0, not rejected), and an optional shared-memory token that hands you runtime control over a running transmission — set it and IQ type gets force-switched to float whether you asked for that or not.
Argument Marshalling, or: Your JSON Will Get Rejected and That’s the Point
Every module follows the exact same three-step dance inside ParseArgs: json.Unmarshal into the struct, run validate(), run buildArgs(). Required fields use bare Go types and get checked against zero-values or commonerrors.ErrRequiredFieldNotSet; optional fields are pointers (*int, *bool, *float64) so the module can tell the difference between “you didn’t set this” and “you explicitly set it to zero.” Every single module runs its frequency through the same isValidFreqHz range check and most run it through file-existence checks too — pifmrds’s audio file, pisstv’s picture file, spectrumpaint’s raw data file, sendiq’s input file all get an os.Stat before anything executes, so you find out your path is wrong before a process spins up, not after. POCSAG and FSK are the two oddballs that don’t return a plain arg list — POCSAG hands back an io.Reader full of pager messages for stdin, FSK hands back either a file handle or a text string wrapped as a reader, because that’s literally how the underlying binaries expect their input.
The Part Where I Cover My Ass
Actually transmitting RF is not a toy. Keying up a real antenna on frequencies you don’t hold a license for, at power levels you’re not authorized for, is illegal in basically every country on Earth and can step on emergency services, aviation, or your neighbor’s baby monitor. gorpitx’s dev mode exists specifically so you can build and test this entire pipeline — timeouts, streaming, module registry, the works — without ever touching real hardware. Use it. When you flip to production mode and it demands root, that’s not a suggestion, that’s the last speed bump before you’re actually radiating. Know your local RF regulations, get licensed where required, and don’t be the reason a ham radio operator files a complaint with a government agency. I’m not your lawyer and this post isn’t legal advice — it’s a “don’t be an idiot” from someone who builds this stuff for fun.
The Load-Bearing Boring Part
gorpitx is the boring, load-bearing part nobody screenshots — a singleton, a module registry, twelve structs that turn JSON into validated CLI args, and a process manager that doesn’t leave zombies behind. That’s exactly what it’s supposed to be. If you want the flashy frontend built on top of it, go read the PIrateRF post. If you just want to drive rpitx from Go without hand-rolling arg slices and process babysitting for the fourth time in your career, grab it off GitHub. It’ll save you the arg-slicing, and probably a zombie process or two.