hermes-agent/skills/creative/touchdesigner-mcp/references/midi-osc.md
SHL0MS 02df438316 feat(skills): expand touchdesigner-mcp with animation, MIDI/OSC, particles, projection refs
Adds four new reference docs covering common TD use cases not previously
documented in the skill:

- animation.md: LFOs, timers, keyframes, easing, time references
- midi-osc.md: MIDI controllers, OSC routing, TouchOSC, multi-machine sync
- particles.md: POPs and particleSOP — emission, forces, collisions, render
- projection-mapping.md: windowCOMP, corner pin, mesh warp, edge blending

Also clarifies the SKILL.md tool quick reference: adds td_screen_point_to_global
and notes that 4 admin/dev-mode tools (td_project_quit, td_test_session,
td_dev_log, td_clear_dev_log) live only in mcp-tools.md to keep the main
reference focused on creative workflows.

No SKILL.md workflow or critical-rules changes. References load on demand
so no token-budget impact at session start.
2026-04-27 19:35:18 -07:00

7.2 KiB

MIDI / OSC Reference

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.


MIDI Input — Hardware Controllers

Discovery

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.

MIDI In CHOP

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 74
  • ch1n60 — channel 1, note 60 (middle C) — value is velocity 0-127

Map 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

MIDI Learn Pattern

Build a reusable learn pattern when you don't know the controller's CC layout in advance:

  1. Drop a midiinCHOP and selectCHOP after it.
  2. User wiggles the controller knob.
  3. Use td_read_chop on the midiinCHOP to identify which channel is non-zero — that's the active CC.
  4. Set the selectCHOP.par.channames to that channel name.
  5. Save the mapping to a tableDAT so it persists across sessions.

MIDI Output

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 Input — Network Control

OSC is the more flexible cousin of MIDI. Used heavily for:

  • TouchOSC / Lemur mobile control surfaces
  • Show control systems (QLab, Watchout)
  • Inter-application sync (Ableton via Max for Live, Resolume, etc.)

OSC In CHOP

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.

OSC In DAT (for raw events)

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 Output — Sending to External Apps

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'])

TouchOSC / Mobile UI Pattern

Common setup for live VJ control from a phone/tablet:

  1. Configure TouchOSC layout — assign each control an OSC address like /vj/master, /vj/scene/1, etc.
  2. Find your machine's LAN IP — TouchOSC needs to point at it.
  3. TD listens on oscinCHOP.par.port = 8000 (or whichever).
  4. Map channels to params via expressions:
op('/project1/master_level').par.opacity.mode = ParMode.EXPRESSION
op('/project1/master_level').par.opacity.expr = "op('osc_in')['vj_master']"
  1. Send feedback to the controller via oscoutCHOP — useful for syncing state across multiple devices.

Network / Multi-Machine

OSC over LAN works out-of-the-box. For multi-TD-instance sync (e.g., projection cluster):

  • One TD acts as master, broadcasts /sync/... over OSC
  • Worker TDs run oscinCHOP listening on the same port
  • Use UDP broadcast address (e.g., 192.168.1.255) on the master's oscoutCHOP.par.netaddress to hit all peers

For reliability over WAN, use webserverDAT or websocketDAT with an external relay instead — UDP loss is invisible.


Pitfalls

  1. MIDI device indexing — device 0 is whichever device TD enumerated first. Reorder may shift it. Pin by name when possible.
  2. OSC channel names — TD doesn't create a channel until the first message lands. New channels invalidate cooked dependents on first arrival, causing a one-frame stutter.
  3. OSC queued modepar.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.
  4. MIDI clock vs. transportmidiinCHOP reports clock if available. Use midisyncCHOP (if your TD version exposes it) or compute BPM from clock pulses (24 per quarter note).
  5. Latency — wired MIDI is ~1-3ms. WiFi OSC is 10-30ms with jitter. Use wired for tight beat-locked work.
  6. Port conflicts — only one process can bind a UDP port on most OS. If oscinCHOP shows no traffic, check that another app (Max, Ableton, etc.) isn't already listening on that port.

Quick Recipes

Goal Op chain
Knob → bloom intensity midiinCHOP → expression on bloom.par.threshold
Note → scene change midiinCHOPtriggerCHOPselectCHOP → drive switchTOP.par.index
Phone slider → master fader TouchOSC /masteroscinCHOP → 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