Warning
ZPTTLink is under active development and not yet stable, features and config may still change. Give the repo a Star and Watch it to get notified when a new release lands.
An open-source, all-in-one linking bridge
ZPTTLink is an open-source, all-in-one linking bridge. Deterministic DTR/RTS/CM108 hardware PTT or a network Asterisk (USRP) backend, with pynput/ydotool/ADB key injection for BlueStacks, Waydroid, and docker-android. Compatible radio interfaces include the AIOC (All-In-One Cable), CM108/CM119-based USB sound fobs, DigiRig, and other USB serial/audio radio cables — see Requirements.
Both the radio side and the Zello side are configurable independently: pick a hardware backend or Asterisk (USRP) for the radio side, and BlueStacks/Waydroid/docker-android for where Zello runs — see Android Runtime Targets.
At its core, ZPTTLink is just a bridge for audio — TX, RX, or both — between Zello and a radio-side target. The Asterisk (USRP) backend has no physical hardware to be near, which means that side of the bridge can run anywhere with network reach to Asterisk: a Raspberry Pi at a site, or a commodity cloud VPS with no local hardware at all.
This tool is ideal for GMRS and ham radio operators, emergency communications volunteers, and hobbyists who want to build a software-based radio gateway.
Zello is the most common app ZPTTLink is used with, but nothing about ZPTTLink is Zello-specific. The bridge mechanism — a keyboard hotkey (pynput/ydotool), a screen tap (adb), or a hardware PTT line, plus an audio input/output — doesn't care which app is on the other end. If an app has some kind of push-to-talk trigger and produces/consumes audio, ZPTTLink can bridge it to a radio or an Asterisk (USRP) endpoint.
Popular PTT/walkie-talkie-style apps people have asked about or used with ZPTTLink include:
- Zello — the most tested target, with a dedicated PTT hotkey and a Toggle mode
- CB Talk — no built-in VOX, only an on-screen PTT button; a good fit for the floating PTT overlay button
- Voxer
- TeamSpeak
- Discord (its own push-to-talk keybind)
- ...and more — any app with a PTT-style trigger is a candidate
What actually determines compatibility isn't the app, it's two things:
- Trigger: does the app support a hotkey (for
pynput/ydotool), Toggle mode rather than Hold (required foradb, since ADB key taps are edge-triggered — see Android Runtime Targets), or does it only have an on-screen PTT button you can park the floating PTT overlay button over instead? - Audio: can the app's mic input be pointed at a virtual audio device (VB-Cable/BlackHole/ALSA Loopback) so ZPTTLink's audio bridge can feed it?
Only Zello has been tested end-to-end so far; the others are expected to work based on how ZPTTLink's bridge actually operates (generic key/tap injection plus audio routing, not a per-app integration) but haven't all been verified by the maintainer. If you get one working — or hit a snag — open an issue and let us know.
This is also why ZPTTLink on a Podroid-hosted Android phone uses a native Linux PTT app instead of Zello — Podroid boots Alpine Linux, not Android, so an Android-only app can't run inside it, but any Linux-native PTT client is a perfectly normal target for the exact same trigger-plus-audio-path logic described above.
┌──────────────┐
│ Zello │
│ (BlueStacks /│
│ Waydroid) │
└──────┬───────┘
│
TX Audio │ RX Audio
│
┌──────▼───────┐
│ ZPTTLink │
│ (PC / Host) │
│ │
│ • Audio I/O │
│ • PTT Ctrl │
└──────┬───────┘
│
USB Audio │ USB PTT
│
┌──────▼───────┐
│ AIOC │
│ (or equiv.) │
└──────┬───────┘
│
RF TX / RX
│
┌──────▼───────┐
│ Radio │
└──────────────┘
- Works with any PTT app, not just Zello — CB Talk, Voxer, TeamSpeak, Discord, and more, as long as it has a PTT trigger and an audio path
- Compatible with AIOC, CM108/CM119-based, DigiRig, and other USB serial/audio radio cables
- Detects PTT signals via USB serial (DigiRig DTR/RTS) or USB HID GPIO (CM108/CM119)
- Multiple ways to trigger PTT in the Zello target, selectable via
injection_mode:pynput— standard host keyboard injection (X11 / macOS / Windows)ydotool— Linux/dev/uinputinjection; used automatically on a detected Wayland session, where host key injection is normally blockedadb— sendsinput keyeventdirectly into an Android target over ADB; the mechanism for docker-android targets, since they have no host window to inject into at allauto(default) — picks pynput or ydotool automatically based on the detected session
- Direct hardware PTT via serial DTR/RTS or CM108 GPIO, independent of key injection entirely — the most reliable option when the radio, not the app, is the thing you need to key
- Full duplex audio: Zello → radio (TX) plus an optional, independent radio → Zello (RX) path for the hardware backends, and always-duplex for the Asterisk (USRP) backend, since USRP is bidirectional by design
- Cross-platform support for Windows, macOS, and Linux (including Raspberry Pi)
- Two operating modes:
- Terminal (CLI) mode for lightweight deployments and automation
- Graphical user interface (GUI) for easy configuration and monitoring
- Built-in GUI features include:
- Serial device auto-refresh
- Audio device selector
- PTT indicator light
- Radio-style push-to-talk button
- Runtime start/stop controls
- Config editor with save button
- Android target / injection mode selector, with an ADB serial field for docker-android targets
- RX audio device selectors and VOX threshold, with a one-click enable/disable toggle
- Floating PTT overlay button — a small always-on-top, draggable button you can position over any other app's window
- Audio routing via VB-Cable (Windows), BlackHole (macOS), or ALSA Loopback (Linux)
- Simulation Mode: a fake radio/Asterisk peer for dev/testing/demos, with no real hardware or network traffic — a periodic synthetic tone burst by default, or a scripted, timed scenario file
- AIOC, CM108/CM119, or compatible USB PTT/audio interface
- Python 3.8 or newer
- Zello installed inside BlueStacks, Waydroid, or a docker-android container
- For
injection_mode: adbonly: Android platform-tools (adb) on the host - For Wayland hosts only: ydotool + a running
ydotoold, used automatically as the key-injection fallback
Install all dependencies with:
pip install -r requirements.txt
- Core: pyserial, pynput, sounddevice, numpy, loguru, platformdirs, pyusb, PySide6
- Windows: pycaw
- macOS: pyobjc
- Linux: pulsectl
- ZPTTLink works with both ALSA and PulseAudio.
- If your system uses PipeWire, make sure the PulseAudio compatibility layer is enabled so
pulsectlcan function correctly. - ALSA Loopback must be enabled for audio routing. See: ALSA Loopback Device.
Deploying to an unattended Raspberry Pi (e.g. boxed up at a remote site)? See the dedicated Raspberry Pi deployment tutorial — hardware, headless OS setup, Waydroid + Zello, systemd autostart with a watchdog, and outdoor/unattended hardening.
Deploying the Asterisk backend with no local hardware at all? See the dedicated VPS deployment tutorial — running ZPTTLink and Zello (via docker-android) on a commodity cloud server, with no radio-side hardware or physical site involved.
Have a spare Android phone instead? See the dedicated Podroid deployment tutorial — Podroid boots a real, rootless Alpine Linux VM on the phone, which ZPTTLink and a native Linux PTT app (not Zello — see Works With Any PTT App) can run inside directly, with optional USB passthrough for a physical radio interface. The steps below are the general/manual install; all three tutorials build on them.
- Install Python:
- Windows
- macOS: Use Homebrew:
brew install python - Linux: Use your package manager (example for Debian/Ubuntu):
sudo apt install python3 python3-venv
- Install virtual audio driver (choose your OS above).
- Clone the repository:
git clone https://github.com/maxhayim/ZPTTLink.git cd ZPTTLink - Create and activate a virtual environment:
- Windows:
python -m venv venv venv\Scripts\activate - macOS/Linux:
python3 -m venv venv source venv/bin/activate
- Windows:
- Install dependencies:
pip install -r requirements.txt
- Activate your virtual environment:
source venv/bin/activate - Run ZPTTLink in terminal mode:
python -m zpttlink - Available commands:
help— Displays available commands and usage infoqorquit— Safely exits the program
ZPTTLink also includes a cross-platform graphical interface.
python -m zpttlink --guiThe GUI provides:
- Automatic detection of serial devices
- Audio input/output device selection
- PTT status indicator (Idle / RX / TX)
- Large radio-style PTT button
- Start/Stop runtime control
- Configuration editor with save functionality
The GUI works on:
- Windows
- macOS
- Linux
- Raspberry Pi
Click Show PTT Overlay in the GUI and a small, frameless, semi-transparent, always-on-top PTT button appears — one you can drag anywhere on screen and position over the top of any other window, not just ZPTTLink's own. This exists for cases like a third-party PTT app (e.g. one with no VOX, only its own on-screen PTT button) where you want to trigger ZPTTLink's PTT without switching focus away from that app at all.
- Left-click and hold the button to talk; release to stop — the same press-and-hold gesture as the GUI's main PTT button.
- Right-click and drag to reposition it — kept deliberately separate from the talk gesture so dragging can never misfire a PTT. Its position is saved automatically and restored next time.
- The small × in the corner closes it.
The overlay is a widget in the GUI process, but the actual PTT logic (VOX, hotkey injection, hardware lines) runs in a separate core process that the GUI launches — so the overlay talks to it over a tiny control channel bound to 127.0.0.1 only (never exposed on the network), started automatically whenever the GUI starts the runtime. The overlay button only actually triggers PTT once you've clicked Start — before that, or if the core isn't running, clicking it does nothing (logged once, not on every click).
{
"overlay": {
"control_port": 8765,
"x": 1200,
"y": 80,
"size": 90,
"opacity": 0.55
}
}
x/y are written automatically when you drag the overlay — no need to edit them by hand. size/opacity can be tuned directly in config.json if you want a bigger/smaller or more/less transparent button. This same control channel also now backs the GUI's main PTT button, which — before this — only updated its own on-screen indicator and didn't actually reach the running core process either.
Zello (or whichever PTT app you're bridging) can run in four different places relative to ZPTTLink. Which one you're using determines how PTT actually reaches it — set injection_mode (and adb_serial, if applicable) accordingly. The examples below use Zello since it's the most common case, but the same setup applies to any other PTT app running in the same place.
injection_mode: pynput(default on macOS)- Install BlueStacks, install Zello inside it, and set Zello's PTT hotkey to match
ptt_hotkeyin your config (defaultF9) - Route audio through BlackHole: BlueStacks' input device set to the BlackHole loopback, ZPTTLink's
audio_output_indexpointed at the same BlackHole device - macOS requires granting Accessibility permission to the terminal/app running ZPTTLink for pynput key injection to work at all (System Settings → Privacy & Security → Accessibility)
- On an X11 session:
injection_mode: pynputworks the same as BlueStacks - On a Wayland session (common on modern distros):
injection_mode: autoswitches toydotoolautomatically — installydotooland runydotooldas a service first - Known limitation: Waydroid runs Android inside its own container with its own input stack. Even when
ydotoolsuccessfully injects a key on the host, Waydroid does not reliably forward that synthetic event into the Android session — this is a Waydroid input-isolation limitation, not something ZPTTLink controls. If key injection doesn't reach Zello inside Waydroid, ADB is usually more reliable (Waydroid exposes an ADB target oncewaydroid shell settings put global adb_enabled 1or equivalent is configured — see Waydroid's own docs) — setinjection_mode: adband pointadb_serialat it, same as the docker-android targets below - Route audio through ALSA Loopback or PulseAudio's
module-loopback, since Waydroid can be configured to use the host's PulseAudio/PipeWire server directly
Both budtmo/docker-android and HQarroum/docker-android run a real Android emulator inside a container, controlled over ADB (budtmo also adds a noVNC web UI). ZPTTLink talks to Zello inside either one the same way: over ADB.
# budtmo/docker-android — noVNC on 6080, ADB on 5555
docker run -d -p 6080:6080 -p 5555:5555 \
-e EMULATOR_DEVICE="Samsung Galaxy S10" -e WEB_VNC=true \
budtmo/docker-android
# HQarroum/docker-android — ADB on 5555
docker run -d -p 5555:5555 hqarroum/docker-android
- Connect and install Zello inside the emulator (once, per container):
adb connect 127.0.0.1:5555 adb install /path/to/zello.apk - Open Zello inside the emulator (via budtmo's noVNC at
http://localhost:6080, orscrcpyagainst the ADB target) and set Zello's PTT hotkey mode to Toggle, not Hold — see below for why. - Configure ZPTTLink:
or via the CLI:
{ "injection_mode": "adb", "adb_serial": "127.0.0.1:5555", "ptt_hotkey": "F9" }python -m zpttlink --injection-mode adb --adb-serial 127.0.0.1:5555, or in the GUI's "Android Target" panel.
Why Toggle, not Hold: Android's adb shell input keyevent dispatches a key press and release together as a single, instantaneous event — there is no way to hold a key down over ADB the way a real keyboard (or DTR/RTS) can. ZPTTLink's ADB mode sends one tap when the radio's PTT goes down, and deliberately does nothing when it goes back up. This matches Zello's Toggle hotkey mode (tap once to start transmitting, tap again to stop) but will not work correctly with Zello's Hold mode, since there's no way to release a key ZPTTLink never truly held.
Audio is not bridged for either docker-android project. Both run headless by default — HQarroum's image explicitly starts the emulator with -no-audio, and budtmo's does not document any audio passthrough at all. That means ZPTTLink's audio bridge (mic-in → radio-out) has nothing to connect to inside a stock container: there is no virtual sound device reachable from the host. Getting audio into the emulator requires routing PulseAudio over the network into the container yourself — for example, enabling module-native-protocol-tcp on the host's PulseAudio server and pointing the emulator's own -audio-backend/EXTRA_FLAGS at it (see each project's EXTRA_FLAGS environment variable). Exact flags depend on the emulator/QEMU version bundled in the image, so treat this as a starting point, not a copy-paste recipe — consult the specific image's own issues/docs for the audio flags it currently supports. Until that's wired up, these two targets are useful for testing the ADB PTT trigger path with Zello's Toggle mode, not for a working end-to-end audio bridge.
Set radio_type: "asterisk" and ZPTTLink connects to a local or remote Asterisk instance over the network instead of a physical radio interface — using the USRP protocol, the UDP audio+PTT wire format Asterisk's app_rpt module (chan_usrp) uses to let an external program act as a "radio" node without being a compiled Asterisk channel driver. No AIOC/CM108/DigiRig hardware is needed for this backend at all.
This is additive, not a replacement — the existing hardware backends are unchanged. ZPTTLink now supports two independent kinds of "radio side": physical hardware, or Asterisk over the network. Pick whichever matches your setup with --radio-type.
It runs equally well co-located on the same Pi/computer as Asterisk (point it at 127.0.0.1) or on a separate box talking to a remote Asterisk instance over LAN/VPN — both are the exact same code path, just a different asterisk.host.
{
"radio_type": "asterisk",
"force_serial_ptt": false,
"disable_hotkey": false,
"injection_mode": "auto",
"asterisk": {
"host": "127.0.0.1",
"port": 32001,
"local_port": 0
}
}Or via the CLI: python -m zpttlink --radio-type asterisk --asterisk-host 127.0.0.1 --asterisk-port 32001. In the GUI, pick "asterisk" from the Radio Backend dropdown in the Connection panel.
Important: unlike the hardware backends (which default to force_serial_ptt: true, disabling hotkey injection since a serial/GPIO line keys the radio directly), the Asterisk backend has no hardware to key — it needs hotkey injection enabled with a working injection_mode so that audio arriving from Asterisk actually gets relayed into Zello's network. Leaving force_serial_ptt: true with radio_type: asterisk logs a warning at startup and effectively means Asterisk-side audio reaches Zello's mic input but Zello never transmits it.
This is the one backend where full duplex is real:
- Zello → Asterisk: Zello's outgoing audio (
audio_input_index) is resampled to 8kHz/16-bit mono and sent as USRP voice frames. A local VOX gate decides the outboundkeyupflag frame-by-frame. - Asterisk → Zello: incoming USRP voice frames are resampled up to the device rate and written into
audio_output_index(point this at whatever virtual audio device feeds Zello's microphone input — the reverse roleaudio_output_indexplays for the hardware backends, where it feeds the radio's TX audio input instead). The incoming frame'skeyupfield drivesptt.down()/ptt.up(), which is what triggers the hotkey injection that makes Zello actually transmit the relayed audio.
Set radio_type: "simulate" and ZPTTLink runs against a fake radio/Asterisk peer instead of real hardware or a real Asterisk instance — no serial device, no USB device, no socket, no network traffic at all. It's the exact same full-duplex pipeline as the Asterisk backend (same 20ms frames, same local VOX, same keyup-driven ptt.down()/ptt.up() hotkey injection into Zello), just fed synthetic RX audio instead of a real UDP peer. Useful for developing/testing ZPTTLink itself, demoing the full pipeline with nothing real connected, or exercising the GUI end-to-end (device pickers, PTT indicator, config editor) with no hardware on hand at all.
{
"radio_type": "simulate",
"force_serial_ptt": false,
"disable_hotkey": false,
"injection_mode": "auto",
"simulate": {
"interval_s": 10.0,
"burst_s": 2.0,
"tone_hz": 440.0,
"amplitude": 0.3,
"script": null,
"loop_script": true
}
}
Or via the CLI: python -m zpttlink --radio-type simulate --simulate-interval 10 --simulate-tone-hz 440. In the GUI, pick "simulate" from the Radio Backend dropdown.
Same as the Asterisk backend, this needs force_serial_ptt: false and a working injection_mode — there's no hardware line to key, so hotkey injection is what actually relays the synthetic RX audio into Zello.
- Periodic tone burst (default, no
scriptset): everyinterval_sseconds, a syntheticburst_s-second tone attone_hzis queued as RX audio withkeyuptoggling on/off around it — enough to exercise the RX relay path repeatedly without any setup. - Scripted scenario (
scriptset to a JSON file path): a deterministic, timed sequence of RX events instead of random/periodic traffic — for reproducing a specific test case or demoing a fixed scenario. Seeexamples/simulate-scenario.json:Each event's{ "events": [ { "start_s": 2, "duration_s": 3, "tone_hz": 440, "amplitude": 0.3 }, { "start_s": 8, "duration_s": 1.5, "tone_hz": 880, "amplitude": 0.2 } ] }start_sis seconds from when ZPTTLink started (or from the start of the loop, ifloop_scriptis true — the default).tone_hz/amplitudeare optional per-event overrides of the top-levelsimulateconfig. Setloop_script: false(or--simulate-no-loop) to play the scenario once and then go quiet.
Simulation Mode never opens a socket and never touches a serial/USB device — send_audio() on this backend is always a no-op, regardless of the outbound keyup state. The only real side effect is whatever injection_mode is configured to do (a real keypress/ADB tap into your actual Zello target), which is the point — it lets you verify the injection path is wired correctly without needing the radio/Asterisk side to be real too.
The DigiRig/CM108/Signalink backends now support a second, independent audio path: the radio's received audio relayed back into Zello. This is off by default (matching earlier versions) — set both rx_audio_input_index and rx_audio_output_index to enable it, or use the GUI's Enable RX (radio → Zello) checkbox in the Audio panel.
{
"rx_audio_input_index": 3,
"rx_audio_output_index": 4,
"rx_vox": {
"enabled": true,
"threshold": 0.01,
"attack_ms": 20,
"release_ms": 150,
"hang_ms": 200
},
"audio": {
"rx_gain": 0.5
}
}This runs as a second, independent audio stream alongside the existing TX one: rx_audio_input_index captures the radio's received audio, an RX-side VOX gate (separately tunable from the TX vox block — receive-audio levels are rarely close to Zello's loopback level) decides when there's real traffic, and while active the audio is written into rx_audio_output_index (point this at whatever virtual device feeds Zello's microphone input) while ptt.down()/ptt.up() fires so hotkey injection actually relays it into Zello's network — not just silently played into its mic input.
Important wiring note: triggering RX also calls the configured backend's ptt_on()/ptt_off() — the same call TX-side VOX uses to key the radio. That's correct and intentional for a proper repeater topology with separate RX and TX radios (RX radio's audio feeds rx_audio_input_index; the backend's DTR/RTS/GPIO line keys the separate TX radio). It is very likely wrong for a single-transceiver setup, where keying the same radio's PTT while its own RX audio is mid-relay will cut off the very audio you're trying to relay (most transceivers mute RX while transmitting). If you only have one radio, either leave RX disabled, or set ptt_output: "none" so the backend's PTT line is never asserted and RX only drives hotkey injection into Zello.
ZPTTLink listens for a PTT signal from your radio interface — either a serial control line (DigiRig DTR/RTS) or a USB HID GPIO line (CM108/CM119). When it fires, ZPTTLink does two things in parallel:
- Triggers Zello's PTT, using whichever
injection_modethe target needs: a simulated keypress (pynput on X11/macOS/Windows, ydotool on Wayland), or an ADBinput keyeventtap for a docker-android/ADB-reachable target. See Android Runtime Targets for which one applies to your setup. - Keys the radio directly via serial DTR/RTS or CM108 GPIO, independent of whether the key injection actually reached Zello — this is the deterministic path the project name refers to.
Microphone/Zello audio is routed to the radio's audio output via a virtual audio driver, creating the Zello-to-RF link. If RX audio is configured, the reverse leg (radio's received audio → Zello) runs as a second, independent stream, closing the loop into a full duplex bridge.
The Asterisk (USRP) backend works differently from all of the above — audio flows over the network rather than to/from local devices; see that section for how it actually works in that mode. Simulation Mode runs that same code path against a fake peer instead of a real one — no hardware or network involved at all.
- Hardware-backend RX assumes a separate RX radio if PTT output is enabled. See the wiring note in RX Audio — on a single transceiver, keying its own PTT during RX relay will cut off the audio being relayed. Use
ptt_output: "none"for single-radio RX. - ADB PTT is edge-triggered, not press-and-hold. See Android Runtime Targets — it requires Zello's hotkey mode set to Toggle, not Hold.
- No audio bridge into docker-android by default. Both supported docker-android images run headless with no audio passthrough out of the box; wiring that up is a manual PulseAudio-over-network step outside ZPTTLink's control.
- Waydroid input isolation. Synthetic key events (ydotool or otherwise) injected on the host are not guaranteed to reach an app running inside Waydroid's container; ADB is the more reliable fallback there too.
This project is licensed under the MIT License.
See the LICENSE file for details.
Full license text: https://opensource.org/licenses/MIT
Pull requests are welcome. Open an issue first to discuss ideas or report bugs.
- Asterisk
- AIOC – All-In-One Cable
- BlackHole (macOS)
- ALSA Utils / Loopback (Linux)
- BlueStacks
- Waydroid
- budtmo/docker-android
- HQarroum/docker-android
- ydotool
- VB-Audio (VB-Cable)
Portions of this project are based on or inspired by the AIOC (All-in-one-Cable).
Zello® for Android is a trademark of Zello Inc., Android™ is a trademark of Google LLC, and both are used here solely for interoperability purposes.
All other trademarks are the property of their respective owners.
