A Bitwig Studio controller extension that streams the project's remote controls
to OSC over UDP — built for Chromatik / LX (default target
127.0.0.1:3030), but any OSC receiver works.
The core idea: anything in Bitwig that can reach a device's remote-controls page —
automation lanes, modulators, envelope/audio followers, Grid values/signals added
to remotes — can be bound to an OSC address and streamed. Bound knobs send their
modulatedValue() (modulation included, pushed by the host at control rate), so a
Grid LFO wobbling a remote knob reaches Chromatik, not just the knob's set position.
-
The extension observes a fixed bank tree: 32 tracks × 8 devices × 8 remote knobs per device (first/selected remote page), plus each track's own 8-knob remote-controls page (the track's root remotes, not scoped to a device).
-
Inventory: the "Print remotes" button (controller preferences) dumps every existing remote to the controller console with its coordinate:
t3.d1.k2 <track> <device> <knob name>. -
Binding: 16 per-project mapping slots in the Studio I/O panel (saved in the Bitwig project). Slot syntax:
t3.k1 /lx/output/brightness t3.d1.k2 /lx/modulation/Globals/macro2(1-based track[.device].knob coordinate, then a full OSC address —
t.kbinds a track-level remote,t.d.kbinds a device-level remote. Values are sent as normalized 0–1 floats — what LX's OSC handler expects for ranged parameters.)Mappings can alternatively be written to
~/.bitwig-osc-bridge/mappings.txt(one slot line per line,#comments allowed) — the file fully replaces all 16 slots, applied within ~1s, and is persisted into the project via the same Studio I/O slots, so external tooling (scripts, AI sessions) can create bindings without opening the Studio I/O panel. -
Stats: the "Print OSC stats" button logs per-binding update counts and rate (updates/sec) since the last dump — use it to measure the host's observer push rate against a fast modulator before relying on it for tight sync.
Requires JDK 21+ and Maven (the Bitwig extension API is fetched from
maven.bitwig.com).
mvn package # produces target/OscBridge.bwextension
mvn package -Pinstall # also copies it to ~/Documents/Bitwig Studio/Extensions/ (macOS)mvn package also builds the web UI (see below) via exec-maven-plugin, so no
manual npm step is needed — just node/npm on the PATH Maven runs with.
The embedded HTTP server's web page (remotes ↔ Chromatik params binding UI) lives
in web-ui/ as a Vite + React + TypeScript app, not hand-written HTML.
The page is two columns: a single Bitwig panel on the left, and Mapping
Slots + Chromatik Parameters stacked on the right. Clicking a knob on
the left arms it, then clicking a param on the right binds the two — the
Chromatik panel's header always shows what's currently armed ("Binding →
p3.k2 · <device> / <knob>", with a ✕ to clear it) so the click target is
never a guess.
The Bitwig panel folds what used to be two side-by-side panels into one, with chips to switch mode:
- Probes (the default) — 8 pinnable slots that follow whatever's selected
in Bitwig until captured; the intended way to reach devices nested too
deep for the static track/device banks (see below). Each probe card is
labeled with its coord prefix (
p1…p8) rather than a bare number, since that prefix is literally thep1in ap1.k1mapping; copy under the capture button explains that probes fill the lowest free slot and that the number is stable and referenced by your mappings. If the running extension build predates probes support, the panel opens on Browse automatically instead of dead-ending on an unsupported-feature message. - Browse all remotes — the full static track/device/knob bank tree (the old "Bitwig Remotes" panel), for binding without capturing a probe first.
The Chromatik params panel filters ~35,000 raw OSC params down to what's worth binding to a remote knob. A mode toggle at the top of the panel switches between two views:
- Macro controls (the default) — only the macro slots of Chromatik's macro
modulators (the "Knobs", "Triggers", and "Switches" modulators added in
Chromatik's modulation section), organized as a channel tree instead of a
flat list: "Global" (engine-level modulators with no owning channel) is
pinned first, then channels alphabetically, each an expandable node
(expanded by default) that shows the channel name once alongside its total
macro count. A channel's modulator groups nest inside it, with any
intermediate pattern/effect names on the way to the modulator dimmed in the
group heading (e.g. "Rack › Colorization" dimmed under the "Thunderstorm"
channel node, with the modulator's own label at full weight after it) —
previously every group repeated the channel name in its own heading. The
raw
/lx/mixer/channel/4/...path is still there, just moved to a hover tooltip. The header shows "{n} macros · {m} modulators · {c} channels"; on a real rig this narrows ~35,541 total params down to ~304 macro params across 38 modulators. If a macro knob has been given a custom name in Chromatik — a siblingStringParameter'svaluefield, e.g..../macros/label3next to.../macros/macro3— its slot renders as "K1 (YourName)" with the default label and OSC address moved to a hover tooltip; this needs a chromatik-mcp build that suppliesStringParameter.value(not yet released) plus a Chromatik restart to pick it up, otherwise slots just show plain defaults (K1..K8/T1..T8) as before. - All parameters — the full component tree. A category chip row above the search box — Knobs, Triggers, Enums & selectors, Other — toggles which LX parameter types are included (Knobs and Triggers on by default; a "{shown} of {total} shown" count sits alongside), and the search box then narrows further within whatever categories are active. Nothing is permanently hidden — toggling a chip back on brings its params back into the tree.
The search box works in both modes. Macro detection prefers a componentType
field from chromatik-mcp when present, falling back to matching OSC addresses
ending in /macroN otherwise — both signals identify the same params, but
componentType requires a chromatik-mcp build that emits it plus a Chromatik
restart to take effect.
Mapping Slots also got more readable: each filled slot shows a small
resolved line — source device/knob → param address tail — above the raw,
still directly editable coord /address text. After a bind, the slot row
you just wrote to flashes so you can find it in the list of 16.
cd web-ui
npm install
npm run dev # http://localhost:5173, proxies /api to the running extension on :3131Run the extension in Bitwig first so /api/* has something to talk to — npm run dev gives hot reload against the live extension, no rebuild/reinstall loop needed
while iterating on the UI.
mvn package runs npm ci && npm run build in web-ui/ during the
generate-resources phase and writes the build output straight into
src/main/resources/web/ (gitignored — it's generated, not source), which then
gets packaged into the .bwextension jar as before. There's nothing to run by
hand for a normal build.
Bitwig: Settings → Controllers → Add Controller → vendor lx-mcp, model
OSC Bridge (no auto-detect — add it manually). Preferences hold the OSC target
host/port, a Reconnect button, and the Print remotes / Print OSC stats buttons.
The Web UI category holds the HTTP port, an "Enable Chromatik integration"
toggle, and an Open button ("Open in browser") that opens the embedded web
UI in the default browser — the reliable way to reach it now, since the URL is
otherwise only shown in a transient popup notification at startup. Note the
actual port can differ from the "HTTP Port" preference (the server falls back
to an OS-assigned port if the preferred one is taken); the Open button always
opens whichever port is actually in use, also recorded in
~/.bitwig-osc-bridge/status.json. Mapping slots live in the Studio I/O
panel (window footer, opposite-arrows icon), under the OSC Bridge controller
entry.
Chromatik: enable OSC input (RX) on port 3030. Find the target parameter's
canonical path (e.g. a MacroKnobs modulator labeled "Globals" is
/lx/modulation/Globals/macro1..8) and put it in a mapping slot.
- Bank sizes are fixed at init; projects larger than 32×8 devices are silently outside the observed window (the inventory footer says so). Bump the constants if needed.
- Coordinate-based slots are the deliberately-minimal binding UI. The planned
next step is an embedded HTTP server in the extension (mirroring
chromatik-mcp's in-process MCP
pattern, with
~/.bitwig-mcp/status.jsondiscovery): a local web page that lists remotes on one side and Chromatik's parameters on the other for click-to-bind, and MCP tools so an AI session spanning both servers can create bindings conversationally. - Prior art in this repo's history: a tempo/beat-sync stream to
/lx/tempo(dropped as out of scope) and a selected-device knob page (superseded by whole-project enumeration).
mcp/ is a small stdio MCP server that wraps the extension's embedded
REST API (/api/*, served alongside the web UI on 127.0.0.1:3131 by
default) as tools, so an AI agent can inspect and edit OSC mappings directly
instead of going through the Studio I/O panel or the web UI.
It discovers the running extension itself — no port to configure — by reading
~/.bitwig-osc-bridge/status.json (written by the extension while its
controller is enabled) fresh on every call.
cd mcp
npm install
npm run build # emits mcp/dist/index.jsA repo-root .mcp.json is already checked in:
{
"mcpServers": {
"bitwig-osc-bridge": {
"command": "node",
"args": ["mcp/dist/index.js"]
}
}
}Point any stdio-capable MCP client (Claude Code, Claude Desktop, etc.) at
this repo and it will pick that up automatically; for other clients, copy
the bitwig-osc-bridge entry into their own MCP config, adjusting args to
an absolute path to mcp/dist/index.js if needed.
get_state— full snapshot: remotes, probes, mapping slots, OSC target. Start here.set_slot— write one of the 16 mapping slots ({ slot: 1-16, text }).set_all_slots— replace all 16 slots at once ({ slots: string[16] }).bind— bind a remote coord to an OSC address, reusing an existing slot for that coord or taking the first empty one ({ coord, oscAddress }).capture_probe— pin a probe (1-8) to whatever's currently selected in Bitwig, to reach devices nested too deep for the static track/device banks.release_probe— unpin a probe so it follows Bitwig's selection again.set_probe_page— switch which remote-controls page a probe exposes ({ probe: 1-8, pageIndex: 0-based }).set_osc_target— change the OSC send host/port ({ host, port }); the controller must be re-enabled in Bitwig afterward to apply it.list_chromatik_params— list OSC-addressable parameters from a running Chromatik/LX instance, if the Chromatik integration is enabled.
get_stateto see existing remotes and which coords are already bound.bind({ coord: "t3.d1.k2", oscAddress: "/lx/modulation/Globals/macro2" })to wire a device remote knob to an OSC address (auto-picks a free slot).- For a knob buried in a nested Grid chain not covered by the static banks,
select the target device in Bitwig, then
capture_probe({ probe: 1 })followed bybind({ coord: "p1.k1", oscAddress: "..." }).