Live Performance Architecture in TouchDesigner
Build a robust, cue-driven live performance rig with MIDI control, scene management, crash recovery, and Perform Mode optimisations for consistent show operation.
A live performance rig in TouchDesigner is not just a creative system — it is a piece of infrastructure. It must be deterministic, recoverable, and operable under pressure by humans who may be running a show in a dark room with a minute between cues. Every design decision should be evaluated against that operational reality.
The Single .toe File Architecture
Everything lives in one .toe file. Never split a live show across multiple files with Touchin/out OPs bridging them — each inter-file connection is a potential desync point and a slower save operation. Consolidate all assets relative to the .toe file and use File > Consolidate Assets before each show to gather all external media.
Project structure inside the .toe:
/project1
/master_controller ← Python class managing all show state
/scenes
/scene_01 ← Base COMP
/scene_02 ← Base COMP
...
/scene_06 ← Base COMP
/audio
/analysis ← CHOP network
/playback
/output
/composite ← Final compositing chain
/display ← Window COMP
/midi
/input ← MIDI In Map COMP
/mapping ← Table DAT of CC → parameter mappings
/cue_system
/cue_table ← Table DAT
/cue_runner ← Script DAT
/watchdog
/heartbeat ← Timer CHOP pulsing every 500ms
/monitor ← Script DAT checking heartbeat
Scene Management with Base COMPs
Each scene is a Base COMP. Scenes are isolated — they have their own feedback loops, GLSL TOPs, and animation networks. Scene switching is handled by a Switch TOP at the compositing level and by Cook flag management.
# master_controller.py — module extension on the master_controller Base COMP
class MasterController:
def __init__(self, owner_comp):
self.owner = owner_comp
self.current_scene = 0
self.scene_comps = [op(f'/project1/scenes/scene_{i+1:02d}') for i in range(6)]
self.transition_duration = 2.0 # seconds
self._active_transition = False
def go_to_scene(self, index, duration=None):
"""Switch to scene by index (0-based) with optional cross-fade duration."""
if index == self.current_scene:
return
if duration is None:
duration = self.transition_duration
prev = self.current_scene
self.current_scene = index
# Enable new scene's Cook flag
self.scene_comps[index].allowCooking = True
# Cross-fade via LFO CHOP controlling Switch TOP blend
lfo = op('/project1/output/crossfade_lfo')
lfo.par.period = duration
lfo.par.Start.pulse()
# Disable previous scene after transition completes
run("args[0].allowCooking = False", self.scene_comps[prev],
delayFrames=int(duration * project.cookRate) + 5)
print(f'[MASTER] Scene {prev+1} → Scene {index+1} ({duration:.1f}s fade)')
def update_midi(self, cc, value):
"""Route incoming MIDI CC to parameter — called from MIDI In CHOP callback."""
mapping = op('/project1/midi/mapping')
for row in range(mapping.numRows):
if int(mapping[row, 'cc']) == cc:
target_op = op(mapping[row, 'op'])
target_par = mapping[row, 'par']
min_val = float(mapping[row, 'min'])
max_val = float(mapping[row, 'max'])
mapped = min_val + (value / 127.0) * (max_val - min_val)
setattr(target_op.par, target_par, mapped)
break
MIDI Mapping with MIDI In Map COMP
The MIDI In Map COMP (available in TD 2022+) is the cleanest way to handle hardware controller integration. It provides a visual matrix of CC → parameter mappings that can be learned via MIDI learn mode.
For complex shows, augment with a Table DAT that stores the mapping and a Python script that applies it — this makes the mapping inspectable, version-controllable, and reconfigurable without opening the MIDI In Map COMP UI.
# /project1/midi/mapping Table DAT structure:
cc op par min max
1 /project1/scenes/scene_01 Brightness 0.0 2.0
2 /project1/scenes/scene_01 Speed 0.1 5.0
7 /project1/output/composite Opacity 0.0 1.0
10 /project1/audio/analysis Gain 0.0 4.0
The Cue System
A cue system drives scene transitions and parameter automation without manual intervention. The cue table stores each cue’s target scene, transition duration, and any parameter snapshots to apply:
# /project1/cue_system/cue_table Table DAT:
cue scene duration trigger notes
1 1 0.0 manual Opening state
2 2 3.0 manual First transition
3 3 2.0 manual Mid-show build
4 4 4.0 auto:30 30s auto-advance
5 5 1.5 manual Climax
6 1 5.0 manual Outro / reset
# /project1/cue_system/cue_runner Script DAT
current_cue = 0
auto_timer = None
def go(dat):
"""Advance to next cue — called by MIDI button or keyboard shortcut."""
global current_cue, auto_timer
cue_table = op('/project1/cue_system/cue_table')
if current_cue >= cue_table.numRows - 1:
return
current_cue += 1
row = cue_table[current_cue]
scene_idx = int(row['scene']) - 1
duration = float(row['duration'])
trigger = row['trigger'].val
mc = op('/project1/master_controller').ext.MasterController
mc.go_to_scene(scene_idx, duration)
# Schedule auto-advance if specified
if trigger.startswith('auto:'):
seconds = float(trigger.split(':')[1])
auto_timer = run("op('/project1/cue_system/cue_runner').go(None)",
delaySeconds=seconds)
print(f'[CUE] {current_cue}: Scene {scene_idx+1}, fade={duration}s')
Perform Mode Optimisations
Perform Mode (F1) hides the UI and locks the network, but the bigger gains come from deliberately managing what cooks:
Cook flag management: each inactive scene has allowCooking = False. A 1920×1080 GLSL simulation that isn’t needed right now should not consume GPU cycles. Apply this consistently — a scene with 10 feedback-looping GLSL TOPs costs a significant fraction of your frame budget even when invisible.
Viewer Active flags: all TOP viewers in inactive COMPs should be disabled. TD’s viewer rendering (the small thumbnails in network view) persists in Perform Mode if viewers are set to Active. Add a startup script that disables all viewers:
# Startup Execute DAT — runs once on first frame
def frameStart(frame):
if frame > 1: return
for top in root.findChildren(type=topType, depth=10):
top.viewer = False
Python optimization: avoid per-frame Python for anything that can be a CHOP expression. A Script CHOP that runs Python on every cook costs ~0.1–0.5 ms even for trivial operations; a Math CHOP expression costs microseconds.
Watchdog and Crash Recovery
TD can crash — a GPU driver reset, a Python exception in a callback, a malformed media file. Your watchdog strategy must survive TD itself being absent.
Internal watchdog (catches freezes, not crashes): a Timer CHOP pulses every 500 ms. A Script DAT monitors the pulse. If the pulse hasn’t updated in 1 second, the script logs the error and can attempt a soft recovery (re-cooking stuck OPs):
last_pulse = 0.0
def onPulse(channel, sampleIndex, val, prev):
global last_pulse
last_pulse = absTime.seconds
def frameEnd(frame):
if absTime.seconds - last_pulse > 1.0:
print('WARNING: Watchdog pulse missed — checking state')
# Attempt recovery: re-enable cooking on stuck OPs
for comp in op('/project1/scenes').children:
comp.allowCooking = comp == op(f'/project1/scenes/scene_{master_controller.current_scene+1:02d}')
External watchdog (catches crashes): a separate Windows batch script or Python process monitors TD’s process and relaunches it:
@echo off
:loop
start /wait "" "C:\Program Files\Derivative\TouchDesigner\TouchDesigner.exe" "C:\Show\show.toe" --perform
echo [%date% %time%] TouchDesigner exited — relaunching in 3 seconds
timeout /t 3 /nobreak >nul
goto loop
Save this as td_watchdog.bat in the show directory and add it to Windows Task Scheduler to run at system startup with highest privileges.
Pre-Show Checklist
Run this before every show — ideally scripted as a Python diagnostic that prints a pass/fail report:
def pre_show_check():
checks = []
import psutil, subprocess
# GPU temperature
result = subprocess.run(['nvidia-smi', '--query-gpu=temperature.gpu',
'--format=csv,noheader'], capture_output=True, text=True)
gpu_temp = int(result.stdout.strip())
checks.append(('GPU temp < 75°C', gpu_temp < 75, f'{gpu_temp}°C'))
# Available VRAM
result = subprocess.run(['nvidia-smi', '--query-gpu=memory.free',
'--format=csv,noheader,nounits'], capture_output=True, text=True)
vram_free = int(result.stdout.strip())
checks.append(('VRAM > 4000 MB free', vram_free > 4000, f'{vram_free} MB'))
# Cook rate
actual_fps = 1.0 / max(me.cookTime, 0.001)
checks.append(('Cook rate ≥ 58 fps', actual_fps >= 58, f'{actual_fps:.1f} fps'))
# All scenes present
for i in range(6):
sc = op(f'/project1/scenes/scene_{i+1:02d}')
checks.append((f'Scene {i+1} exists', sc is not None, ''))
for label, passed, detail in checks:
status = 'PASS' if passed else 'FAIL'
print(f' [{status}] {label} {detail}')
Also ensure Windows Update is disabled (or paused for 30 days), power plan is set to High Performance, and all non-essential background processes are killed. A clean Windows install dedicated to TD shows, with no antivirus scanning the TD project directory, is the production standard.