Files
hermes-agent/optional-skills/creative/touchdesigner-mcp/references/midi-osc.md
Teknium c49fa88b80 refactor(skills): shipped-set slim — 15 to optional, github 6-way merge, pdf absorbs OCR, channel-gated teams pipeline (index −26%) (#98539)
* refactor(skills): shipped-set slim — 15 skills to optional, github six-way merge, pdf absorbs OCR+nano-pdf, channel-gated teams pipeline

Maintainer-directed shipped-skills curation (skills index 1,900 -> ~1,400
tok/call on desktop; every session pays the index, so this is a per-call
diet on all installs):

- optional-skills moves (installable via skills hub, history preserved):
  creative comfyui/ascii-art/excalidraw/pretext/sketch/touchdesigner-mcp;
  ALL of mlops (huggingface-hub, llama-cpp, serving-llms-vllm,
  weights-and-biases, evaluating-llms-harness — subcategory structure
  kept); research-paper-writing (55 supporting files, 17.3K-tok load);
  openhue; blogwatcher (first taught the cronjob monitor-field watch
  pattern + web_extract instead of pre-cron manual workflows)
- DELETED session-librarian (Aug-12 'inspired by Perplexity Computer'
  port, never maintainer-intended; session_search covers discovery)
- github: six skills (auth, issues, pr-workflow, issue-to-pr,
  code-review, repo-management) merged into ONE software-development/
  github skill — routing body + complete per-workflow references;
  benbarclay authorship credited; codebase-inspection rides along;
  discipline pins from test_github_issue_to_pr_skill.py preserved
  against the reference body in the new test_github_skill.py
- pdf absorbs ocr-and-documents + nano-pdf as references/ + scripts
  (extract_pymupdf, extract_marker converted to the argparse house
  standard its contract test enforces)
- NEW session_platforms frontmatter gate (metadata.hermes): hides a
  skill from the index on gateway channels it is not for; fail-open on
  unknown platform; teams-meeting-pipeline gated to [teams, cron]
- blocked-page-recovery: research -> new web category; trigger-first
  description ('Use when a fetch fails: 403/429, paywall, WAF, bot
  wall.') so the model actually reaches for it on blocked fetches
- docs regenerated via generate-skill-docs.py (195 pages); related_skills
  swept repo-wide; tests: 1672 passed (2 openclaw failures pre-existing
  on clean main, Windows-local)

* chore: ignore .skills_prompt_snapshot.json (local index cache, accidentally committed)
2026-08-30 04:53:39 -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 mode — 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.
  4. MIDI clock vs. transport — midiinCHOP 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 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