Operational communication for live production teams.
Patch is a lightweight, cross-platform real-time messaging system built for AV, broadcast, theatre, and touring crews. It uses OSC (Open Sound Control) over UDP as its primary transport — the same protocol your show-control gear already speaks.
It is not a chat app. It is a coordination layer for live environments.
Existing options (Slack, WhatsApp, walkie-talkie apps) are either too slow, too noisy, or don't integrate with the show network. Patch is designed to sit alongside QLab, Companion, and your console, receive OSC triggers, and get the right message to the right department in under a second.
Inspired by Wavetool chat and SideChain's TheaterChat.
- Single binary, all platforms — Rust engine is linked into the Flutter app as a
cdylib/staticlibviaflutter_rust_bridge. One process, one icon, no sidecar - OSC-native transport — messages travel as OSC packets; unicast to known peers, broadcast only for presence/discovery
- Logical channels — FOH, MON, RF, LIGHTING, VIDEO, STAGE, PRODUCTION — create and delete at runtime
- Multi-channel view — tap any channel tab to toggle it in or out of the view; combined feed sorted by timestamp with per-message channel colour dots; at least one channel always remains selected
- ALL channel — a pinned tab showing every channel's traffic combined, plus a one-tap crew-wide broadcast (📢) that reaches every peer in every feed regardless of which channels they have — for "HOLD ALL", "LUNCH", show-stop calls
- Direct messages — private 1:1 messages with any peer (💬 button in the peers panel); DM threads appear in the sidebar with an unread indicator; unicast only to that peer, never broadcast
- Flash / page — one-button high-priority alert per channel; channel tab pulses + message box border lights up in the channel colour; auto-flash configurable globally (Settings → Behavior) and per-channel (Settings → channel editor)
- Configurable flash count — set how many times the channel pulses per flash event (3–7 in Settings → Behavior; per-channel override in channel editor; default 4)
- Macros — per-channel one-tap buttons for common callouts (HOLD, CLEAR, BATTERY LOW…), optionally bound to F-keys or a MIDI note/CC (fire from a footswitch, pad, or keyboard, hands-free); drag to reorder in Settings; shown in a vertical side panel (keyboard icon toggle) with 1–3 columns (set in Settings → Behavior); all macros always visible — no scroll, no accidental fires on touch
- Show files — save and restore named channel layouts; accessible from the folder icon in the sidebar; import/export
.tomlfiles to share layouts across machines - Hybrid discovery — mDNS/Bonjour auto-discovery + OSC beacon broadcast every 7s + manual static IP for AP-isolated or VLAN-segmented show networks
- Static peer management — add/remove known peers by IP from the Settings screen; static peers are always contacted regardless of discovery state, bypassing AP isolation
- Peer presence at a glance — the peers panel shows who's online with a name, role badge, and green/amber/grey status dot (amber = a heartbeat or more missed); a peer leaving goes grey promptly but stays in the list so you can see who was connected
- Channel layout sharing — pull another peer's full channel set (names, colours, macros) over the network in one tap (Settings → Channels & Macros → import from a peer); it merges in only the channels you're missing, so a newcomer matches the crew's layout instantly
- Configurable identity — set your display name and network interface from the settings screen; NIC picker filters out virtual/tunnel interfaces and shows only real NICs with IPv4 addresses; changes persist immediately
- Priority levels — info / warning / critical; critical messages are visually distinct and require ACK
- Clear inactive peers — remove grey-dot dynamic peers from the panel via the 👤 button in the peers panel header; static/manual peers are never removed; cleared peers reappear automatically when they come back online
- Clear message history — clear the buffer for the current channel(s) via the 🗑 button in the message area; confirm dialog prevents accidental clears; runtime only, nothing stored to disk
- Export message history — export the current channel's messages (or all selected channels) to a CSV file via the ↓ button in the message area; columns: timestamp, sender, priority, message (channel column added for multi-channel exports)
- Message deduplication — UUID per message; never see the same message twice
- Dark, high-contrast UI — readable from a stage desk at 2m
- Keyboard-first — Enter to send, F1–F12 for macros (binding configurable per macro; fires from any focus state)
- Cross-platform — macOS, Windows, Linux, iOS, iPad (Flutter UI + Rust engine, single binary)
| Layer | Technology |
|---|---|
| UI | Flutter |
| Engine | Rust (tokio) |
| UI ↔ engine | flutter_rust_bridge v2 (typed FFI, in-process) |
| Build glue | cargokit (Flutter FFI plugin in patch_app/rust_builder/) |
| OSC transport | UDP via rosc |
| Discovery | mdns-sd + OSC beacon + static IP |
The Rust engine compiles into a static/dynamic library and is linked directly into the Flutter app at build time. There is no IPC layer — Dart calls Rust functions via FRB-generated bindings. cargokit runs cargo automatically during flutter build for each target platform.
- Rust via
rustup(not Homebrew Rust). Cargokit needs rustup to manage cross-compile targets.# If brew rust is installed, unlink it first so rustup wins on PATH: brew unlink rust curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
- Flutter 3.x
- CocoaPods for macOS/iOS builds:
brew install cocoapods - Linux only: ALSA dev headers for MIDI input —
sudo apt-get install libasound2-dev(macOS/Windows use CoreMIDI/WinMM, no extra packages)
git clone https://github.com/vk0eppel/Patch.git
cd Patch/patch_app
# macOS
flutter run -d macos
# iOS / iPad (device or simulator)
flutter run -d <device-id>
# Windows / Linux — generate the platform folder first if missing
flutter create --platforms=windows . # or linux
flutter run -d windows # or linuxThe first build is slow because Cargokit cross-compiles the Rust engine for the target. Subsequent runs are fast.
The engine generates a patch.toml config file on first run in the platform data directory:
- macOS:
~/Library/Application Support/Patch/(sandboxed under~/Library/Containers/<bundle-id>/…for the Flutter app) - Windows:
%APPDATA%\Patch\ - Linux:
~/.local/share/Patch/ - iOS: app sandbox
Sensible defaults are written (OSC port 9000, all interfaces, 7s heartbeat). Edit patch.toml to set a static NIC, add known peers, or change your display name — or use the Settings screen inside the app.
patch/
├── patch-core/ Rust engine — OSC, transport, discovery, reliability, FFI API (api.rs)
├── patch_app/ Flutter UI — channels, messages, peers, settings, show file management
│ ├── lib/src/rust/ Generated Dart bindings (do not edit; regenerated by codegen)
│ └── rust_builder/ Local FFI plugin that builds/links patch-core via cargokit
└── flutter_rust_bridge.yaml Codegen config
After editing patch-core/src/api.rs, regenerate the bindings:
flutter_rust_bridge_codegen generate
# Then, if any freezed type changed:
cd patch_app && dart run build_runner buildCI (.github/workflows/ci.yml) enforces all of these on every PR and push to main:
# Rust engine
cargo fmt -p patch_core --check
cargo clippy -p patch_core --all-targets -- -D warnings
cargo test -p patch_core
# Flutter app
cd patch_app && flutter analyze && flutter testSee CLAUDE.md for the full architecture, FFI API reference, OSC namespace, and development notes.
For end-user documentation (getting started, networking, OSC integration, troubleshooting), see the docs/ folder.
Patch listens on UDP port 9000 (configurable).
Post a message — the easy way (QLab, Companion, scripts):
/patch/channel/rf/say "Battery low — swap now" 3
└ message text (string) └ priority (int, optional; default 1/info)
Patch fills in the sender, a fresh message id, and the timestamp for you — so you just send the text (and optionally a priority 0–3). The node that receives it posts the message to the whole crew. No UUIDs, no per-send bookkeeping; fire it as often as you like.
Full message form (Patch-to-Patch, or scripts that can generate UUIDs):
/patch/channel/rf/message <sender_id> <sender_name> <message_id> <timestamp_ms> <priority> <payload>
The channel is identified by the address path — no need to repeat it in the args. priority is an integer: 0=debug, 1=info, 2=warning, 3=critical. The channel id must be a slug ([a-z0-9_-], ≤64 chars) and payloads larger than 4 KB are rejected on receive.
Flash/page a channel from any OSC source:
/patch/channel/rf/flash <sender_id> <sender_name>
client_id = "..." # UUID — generated once, persists across restarts
client_name = "FOH Engineer" # Editable from the Settings screen
role = "FOH" # Optional self-assigned role (free text); shown next to your name on peers
osc_port = 9000
network_interface = "en0" # Pins Patch to one NIC (mandatory — see Settings → Network); omit/blank means unresolved: dynamic discovery is inert until one is chosen
flash_on_critical = true # Auto-flash on priority-3 messages (Settings → Behavior)
flash_on_message = false # Auto-flash on every message (Settings → Behavior)
flash_count = 4 # Flash pulse count per event, 3–7 (Settings → Behavior)
macros_columns = 1 # Macros panel column count, 1–3 (Settings → Behavior)
audible_alert = false # Play a sound on flash — critical/page/broadcast; off by default (Settings → Behavior)
heartbeat_interval_secs = 7
[[static_peers]]
address = "192.168.1.50"
port = 9000
label = "Monitor World"- OSC transport — unicast to known peers, broadcast for presence/discovery
- Hybrid peer discovery (mDNS + OSC beacon + static IP)
- Peer address resolution from UDP source + mDNS
- Channel model with per-channel shortcuts — create, edit, delete at runtime
- Flash / page per channel (local + remote)
- Priority levels with visual differentiation
- Message deduplication by UUID
- Reliable critical delivery — receivers ACK
priority=3messages; sender retransmits unacked ones (bounded retries) - Single-binary build via
flutter_rust_bridge+ cargokit (no more TCP bridge) - Settings screen — display name, NIC picker, macros, channel management
- Show files — save / load / delete named channel layouts; sidebar folder icon (not Settings); import/export
.tomlfiles - Multi-channel view — tap to toggle channels; combined feed sorted by timestamp
- ALL channel — combined view + crew-wide broadcast send (reserved
__all__id, shown in every feed) - Direct messages — private 1:1 peer-to-peer messages (
/patch/dm), DM threads in the sidebar with unread badges - Peer identification — channel-membership colour dots + optional self-assigned role label per peer
- Channel layout sharing over the network — pull a peer's channels and merge-adopt the missing ones
- Flash animation — channel tab pulse + message box border/tint (configurable pulse count)
- Auto-flash on critical messages — global default in Settings → Behavior
- Per-channel flash settings — override "flash on message", "flash on critical", and pulse count per channel
- Configurable flash pulse count — 3–7 globally (default 4), per-channel override
- F-key bindings for macros (F1–F12, fires from any focus state)
- MIDI-triggered macros — bind a macro to a Note/CC (footswitch/pad), fired hands-free; per-channel macros fire on their own channel, global on the selected channel (desktop)
- Virtual MIDI port — Patch appears as a "Patch" MIDI destination so other software (DAW, Companion…) can trigger macros with no hardware (macOS/Linux)
- OSC macros — a macro can also fire an OSC message to external gear (QLab/Companion/vMix) when pressed, alongside the crew message (dual action)
- Platform data directory (
~/Library/Application Support/Patch/,%APPDATA%\Patch\, etc.) with legacy./patch.tomlmigration - iOS / macOS Local Network permission strings in Info.plist (
NSLocalNetworkUsageDescription+NSBonjourServices) - Settings screen — static peer management via UI (add/remove peers by address + port + label)
- Heartbeat broadcast wired through transport (presence sent every 7s)
- Peer display — correct name from mDNS TXT record, transport-resolved IP preserved across heartbeats, self-discovery filtered out
- Clear message history — per-channel or all channels, confirm dialog, runtime only
- Export message history — CSV via file picker, per-channel or multi-channel
- Graceful shutdown —
/patch/byeso peers drop promptly instead of waiting out the timeout - Test suite + CI — Rust engine + Flutter unit/widget tests, enforced by GitHub Actions (fmt · clippy · test · analyze) See TODO.md for pending work and known issues.
TBD — private for now.