skills/creative/touchdesigner-mcp/references/midi-osc.md
External controller input and output — MIDI hardware, TouchOSC mobile UIs, OSC routing across the network.
For audio-driven MIDI patterns (track triggers from spectrum analysis), see also audio-reactive.md.
List connected MIDI devices first. Use a midiinDAT to enumerate:
mdat = root.create(midiinDAT, 'mid_devices')
# Read available device names from the DAT after one cook
Or via Python directly:
# In td_execute_python
import td
devices = [d for d in op.MIDI.devices] # verify with td_get_docs('midi')
Verify the API with td_get_docs(topic='midi') since this varies between TD versions.
Standard pattern:
midi_in = root.create(midiinCHOP, 'midi_in')
midi_in.par.device = 0 # device index from discovery
midi_in.par.activechan = True
Output channels follow the convention chCcN and chCnN:
ch1c74 — channel 1, CC 74ch1n60 — channel 1, note 60 (middle C) — value is velocity 0-127Map a CC to a parameter:
op('/project1/bloom1').par.threshold.mode = ParMode.EXPRESSION
op('/project1/bloom1').par.threshold.expr = "op('midi_in')['ch1c74'][0] / 127.0"
Map a note as a trigger:
Notes in midiinCHOP output velocity while held, 0 when released. Use a triggerCHOP to convert a held note into pulses:
trig = root.create(triggerCHOP, 'note_trig')
trig.par.threshold = 1
trig.par.triggeron = 'increase'
trig.inputConnectors[0].connect(op('midi_in'))
# Filter to a single channel via a selectCHOP if desired
Build a reusable learn pattern when you don't know the controller's CC layout in advance:
midiinCHOP and selectCHOP after it.td_read_chop on the midiinCHOP to identify which channel is non-zero — that's the active CC.selectCHOP.par.channames to that channel name.tableDAT so it persists across sessions.midi_out = root.create(midioutCHOP, 'midi_out')
midi_out.par.device = 0
midi_out.par.outputformat = 'continuous' # 'continuous' | 'event'
# Drive an output: send out a CC mapped from any 0-1 source
src = root.create(constantCHOP, 'cc_src')
src.par.name0 = 'ch1c20'
src.par.value0 = 0.5
midi_out.inputConnectors[0].connect(src)
For note events specifically, use event mode and pulse the value with a pulseCHOP or triggerCHOP.
OSC is the more flexible cousin of MIDI. Used heavily for:
osc_in = root.create(oscinCHOP, 'osc_in')
osc_in.par.port = 7000 # listen on UDP 7000
osc_in.par.localaddress = '' # empty = all interfaces
osc_in.par.queued = False # immediate vs. queued processing
Each incoming OSC address becomes a channel. /scene/1/intensity becomes a channel named scene_1_intensity (TD sanitizes slashes to underscores).
Common gotcha: TD only creates the channel after the FIRST message arrives at that address. Send a "hello" message from the controller during setup, or pre-declare channel names manually.
Use a oscinDAT when you need full message access (multiple typed args, addresses with brackets/regex).
osc_dat = root.create(oscinDAT, 'osc_events')
osc_dat.par.port = 7001
# Each row: timestamp, address, type tags, args...
Drive logic via a datExecuteDAT watching the oscinDAT:
def onTableChange(dat):
last = dat[dat.numRows - 1, 'message']
parsed = last.val.split()
addr = parsed[0]
args = parsed[1:]
if addr == '/scene/trigger':
op('/project1/scene_switcher').par.index = int(args[0])
return
osc_out = root.create(oscoutCHOP, 'osc_out')
osc_out.par.netaddress = '127.0.0.1' # destination IP
osc_out.par.port = 9000
# Channel names become OSC addresses
src = root.create(constantCHOP, 'send')
src.par.name0 = 'scene/intensity' # → /scene/intensity
src.par.value0 = 0.7
osc_out.inputConnectors[0].connect(src)
Channel-to-address mapping: TD prepends / automatically. Use / in channel names to nest.
For one-shot string/typed messages, use oscoutDAT and call .sendOSC(address, args):
op('osc_out_dat').sendOSC('/scene/trigger', [1, 'fade'])
Common setup for live VJ control from a phone/tablet:
/vj/master, /vj/scene/1, etc.oscinCHOP.par.port = 8000 (or whichever).op('/project1/master_level').par.opacity.mode = ParMode.EXPRESSION
op('/project1/master_level').par.opacity.expr = "op('osc_in')['vj_master']"
oscoutCHOP — useful for syncing state across multiple devices.OSC over LAN works out-of-the-box. For multi-TD-instance sync (e.g., projection cluster):
/sync/... over OSCoscinCHOP listening on the same port192.168.1.255) on the master's oscoutCHOP.par.netaddress to hit all peersFor reliability over WAN, use webserverDAT or websocketDAT with an external relay instead — UDP loss is invisible.
0 is whichever device TD enumerated first. Reorder may shift it. Pin by name when possible.par.queued = True defers processing to a single per-frame batch. Lower latency but messages arriving same frame collapse to the last value. Off for triggers, on for continuous knobs.midiinCHOP reports clock if available. Use midisyncCHOP (if your TD version exposes it) or compute BPM from clock pulses (24 per quarter note).oscinCHOP shows no traffic, check that another app (Max, Ableton, etc.) isn't already listening on that port.| Goal | Op chain |
|---|---|
| Knob → bloom intensity | midiinCHOP → expression on bloom.par.threshold |
| Note → scene change | midiinCHOP → triggerCHOP → selectCHOP → drive switchTOP.par.index |
| Phone slider → master fader | TouchOSC /master → oscinCHOP → expression on output level.par.opacity |
| TD → Resolume scene trigger | oscoutCHOP channel composition/layers/1/clips/1/connect → Resolume listening on 7000 |
| Multi-projector sync | Master TD oscoutCHOP broadcast → workers oscinCHOP |