# OSCAR: Type Tunnel, a TouchDesigner sketch for the Parameter Rack template.
#
# The rack's caption is the star: the word you type on your phone flies
# through a tunnel of its own trails, tiles into a drifting wall, or orbits
# the point you hold on the pad. Every control on the rack does something:
#
#   /td/caption      the word on screen
#   /td/speed        how fast it travels
#   /td/scale        how big it is
#   /td/hue          how fast the trails drift through colours
#   /td/feedback     how long the trails last
#   /td/position     where the tunnel's vanishing point is
#   /td/colour       the colour of the word
#   /td/scene/1..3   tunnel, wall, orbit (the last one pressed stays)
#
# and both meters on the rack read something back:
#
#   /td/audio/level      the microphone's loudness; it also makes the word pulse
#   /td/sensor/distance  how bright the picture is, 0 to 1. No sensor needed:
#                        the sketch measures itself. Swap in yours.
#
# Latest version:   https://www.createwithoscar.site/touchdesigner/oscar-type-tunnel.py
# Ready to drop in: https://www.createwithoscar.site/touchdesigner/oscar-type-tunnel.tox
# OSCAR, free:      https://www.createwithoscar.site/download
#
# To use it:
#   1. Run OSCAR, open the TouchDesigner Parameter Rack template, and open it
#      on a phone.
#   2. Drag this file into TouchDesigner's network editor. It becomes a Text DAT.
#   3. Right-click that DAT and choose "Run Script". A Base COMP called
#      type_tunnel appears, with the picture on its output.
#   Or, instead of 2 and 3, drag the .tox in.
#
# If TouchDesigner is not on the same computer as OSCAR, change OSCAR_IP below.
# OSCAR prints its IP when it starts; it listens on 8880.
#
# What is built, inside type_tunnel:
#   oscin       OSC In CHOP, port 10000: the rack's numbers.
#   oscin_text  OSC In DAT on the same port: the caption, which is text.
#   caption     a Text DAT holding the latest caption.
#   params      a Script CHOP that turns the rack into one list of numbers
#               for the shader: it keeps the last scene pressed, eases from
#               one scene to the next, and adds speed up into distance
#               travelled, so moving the speed fader never makes a jump. Its
#               order is ORDER in params_callbacks, and the shader's #defines.
#   word        a Text TOP drawing the caption in white.
#   tunnel      a GLSL TOP, fed back into itself, that draws all three scenes.
#   audio       Audio Device In -> Analyze (RMS) -> Lag -> Math -> Limit: 0 to 1.
#   brightness  the picture's average brightness, 0 to 1.
#   oscout      OSC Out CHOP to OSCAR: the two readings the meters follow.
#
# Written for TouchDesigner 2022.35320 and later.

# ---- Where OSCAR is ------------------------------------------------------------
OSCAR_IP = '127.0.0.1'
OSCAR_PORT = 8880
LISTEN_PORT = 10000

NAME = 'type_tunnel'
W, H = 1280, 720

# ---- the caption: the OSC In DAT's callback ------------------------------------
CAPTION_CALLBACKS = '''
def onReceiveOSC(dat, rowIndex, message, bytes, timeStamp, address, args, peer):
    if address == '/td/caption' and args:
        op('caption').text = str(args[0])
'''

# ---- params: the rack, as the numbers the shader reads --------------------------
# uP[0..15] in the shader, in this order.
PARAMS_CALLBACKS = '''
ORDER = ['speed', 'scale', 'hue', 'feedback', 'posx', 'posy', 'r', 'g', 'b',
         'w1', 'w2', 'w3', 'audio', 'scene', 'travel']

def rack(osc, name, default, index=0):
    """A value the rack sent, or the default until it has. A message with
    several values (the pad's x y, the colour's r g b) arrives as name1,
    name2, ...; index picks one."""
    if osc is None:
        return default
    chans = {c.name: c for c in osc.chans()}
    if index == 0 and name in chans:
        return chans[name][0]
    c = chans.get(name + str(index + 1))
    return c[0] if c is not None else default

def onCook(scriptOp):
    osc = op('oscin')
    store = scriptOp.storage
    # The scene keys are momentary: the last one pressed is the scene.
    scene = store.get('scene', 1)
    for n in (1, 2, 3):
        on = rack(osc, 'td/scene/' + str(n), 0) > 0.5
        if on and not store.get('held' + str(n), False):
            scene = n
        store['held' + str(n)] = on
    store['scene'] = scene
    # Ease toward the scene, about half a second from one to the next.
    weights = store.get('weights', [1.0, 0.0, 0.0])
    k = min(1.0, absTime.stepSeconds * 5.0)
    weights = [w + ((1.0 if i + 1 == scene else 0.0) - w) * k for i, w in enumerate(weights)]
    store['weights'] = weights
    # Distance travelled: speed added up, so a new speed never jumps.
    speed = rack(osc, 'td/speed', 1.0)
    travel = store.get('travel', 0.0) + speed * absTime.stepSeconds
    store['travel'] = travel

    audio = op('audio')
    values = {
        'speed': speed,
        'scale': rack(osc, 'td/scale', 0.5),
        'hue': rack(osc, 'td/hue', 0.2),
        'feedback': rack(osc, 'td/feedback', 0.65),
        'posx': rack(osc, 'td/position', 0.0, 0),
        'posy': rack(osc, 'td/position', 0.0, 1),
        'r': rack(osc, 'td/colour', 0.21, 0),
        'g': rack(osc, 'td/colour', 0.83, 1),
        'b': rack(osc, 'td/colour', 1.0, 2),
        'w1': weights[0], 'w2': weights[1], 'w3': weights[2],
        'audio': audio[0][0] if audio is not None and audio.numChans else 0.0,
        'scene': scene,
        'travel': travel,
    }
    # One channel, one sample per value: a CHOP uniform array's elements are
    # its samples. uP[0] is speed, uP[1] scale, and so on, in ORDER.
    scriptOp.clear()
    scriptOp.numSamples = 16
    uP = scriptOp.appendChan('uP')
    for i, name in enumerate(ORDER):
        uP[i] = values[name]
'''

# ---- the picture ----------------------------------------------------------------
SHADER = '''
// Type Tunnel. Input 0 is the caption in white, input 1 is the last frame.
uniform float uP[16];      // the rack, from the params Script CHOP

#define SPEED    uP[0]
#define SCALE    uP[1]
#define HUE      uP[2]
#define FEEDBACK uP[3]
#define POS      vec2(uP[4], uP[5])
#define COLOUR   vec3(uP[6], uP[7], uP[8])
#define WEIGHTS  vec3(uP[9], uP[10], uP[11])
#define AUDIO    uP[12]
#define TRAVEL   uP[14]

out vec4 fragColor;

float aspect() { return uTDOutputInfo.res.z / uTDOutputInfo.res.w; }

// From centred, height-is-one coordinates to texture coordinates.
vec2 toUV(vec2 q) { return q / vec2(aspect(), 1.0) + 0.5; }

vec2 rot(vec2 p, float a) {
    float c = cos(a), s = sin(a);
    return vec2(c * p.x - s * p.y, s * p.x + c * p.y);
}

// Turns a colour around the grey axis: the trails' drift.
vec3 hueRotate(vec3 c, float a) {
    const vec3 k = vec3(0.57735);
    float ca = cos(a);
    return c * ca + cross(k, c) * sin(a) + k * dot(k, c) * (1.0 - ca);
}

// How much of the caption covers p. The caption is drawn on a wide strip;
// `h` is how tall that strip is on screen, centred on p's origin.
float word(vec2 p, float h) {
    vec2 r = uTD2DInfos[0].res.zw;
    vec2 uv = vec2(p.x / (h * r.x / r.y), p.y / h) + 0.5;
    if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) return 0.0;
    return texture(sTD2DInputs[0], uv).a;
}

void main() {
    float A = aspect();
    vec2 q = (vUV.st - 0.5) * vec2(A, 1.0);
    vec2 vp = POS * vec2(A, 1.0) * 0.4;
    float size = (0.3 + SCALE) * (1.0 + AUDIO * 0.6);

    // 1, tunnel: the word sits at the vanishing point and what came before
    // grows away from it, turning slowly.
    float f1 = word(q - vp, size * 0.2);
    vec2 p1 = vp + rot(q - vp, -0.004 * SPEED) / (1.004 + 0.03 * SPEED);

    // 2, wall: the word in rows of bricks drifting sideways.
    float h2 = size * 0.12;
    vec2 cell = vec2(6.6, 0.8) * h2;
    vec2 g = q - vp + vec2(TRAVEL * 0.15, 0.0);
    g.x += floor(g.y / cell.y + 0.5) * cell.x * 0.5;
    float f2 = word(g - cell * floor(g / cell + 0.5), h2);
    vec2 p2 = q - vec2(0.004 * SPEED, 0.0);

    // 3, orbit: three copies of the word ride a ring around the vanishing
    // point, each following the curve, and their trails spiral out.
    float radius = 0.2 + 0.2 * SCALE;
    float f3 = 0.0;
    for (int i = 0; i < 3; i++) {
        float ang = TRAVEL * 0.8 + float(i) * 2.0943951;
        vec2 at = vp + radius * vec2(cos(ang), sin(ang));
        f3 = max(f3, word(rot(q - at, -(ang + 1.5707963)), size * 0.08));
    }
    vec2 p3 = vp + rot(q - vp, -0.006 * SPEED) * 0.996;

    vec3 w = WEIGHTS / max(WEIGHTS.x + WEIGHTS.y + WEIGHTS.z, 1e-3);
    float fresh = clamp(w.x * f1 + w.y * f2 + w.z * f3, 0.0, 1.0);
    vec3 prev = w.x * texture(sTD2DInputs[1], toUV(p1)).rgb
              + w.y * texture(sTD2DInputs[1], toUV(p2)).rgb
              + w.z * texture(sTD2DInputs[1], toUV(p3)).rgb;
    prev = max(hueRotate(prev, HUE * 0.15), 0.0) * FEEDBACK * 0.985;

    vec3 c = COLOUR * fresh * (1.0 + AUDIO * 0.5) + prev * (1.0 - fresh);
    fragColor = TDOutputSwizzle(vec4(c, 1.0));
}
'''


# ---- build ---------------------------------------------------------------------
def setpar(o, name, value=None, expr=None):
    """Sets a parameter, or says so when this TouchDesigner hasn't got it."""
    p = getattr(o.par, name, None)
    if p is None:
        print('OSCAR sketch: no parameter', name, 'on', o.path, '- skipped')
        return
    try:
        if expr is not None:
            p.expr = expr
        else:
            p.val = value
    except Exception as e:
        print('OSCAR sketch: could not set', name, 'on', o.path, '-', e)

def place(o, x, y):
    o.nodeX, o.nodeY = x, y
    return o

def wire(*ops):
    for a, b in zip(ops, ops[1:]):
        a.outputConnectors[0].connect(b)

home = me.parent() if hasattr(me, 'parent') else op('/project1')
old = home.op(NAME)
if old:
    old.destroy()
sketch = home.create(baseCOMP, NAME)
sketch.nodeX, sketch.nodeY = 0, 0
sketch.viewer = True
# Operators below point at each other by name, never by path, so the
# component keeps working when it is renamed, moved, or saved as a .tox.

# What the rack sends.
oscin = place(sketch.create(oscinCHOP, 'oscin'), -1000, 300)
setpar(oscin, 'port', LISTEN_PORT)
caption = place(sketch.create(textDAT, 'caption'), -1000, 500)
caption.text = 'HELLO WORLD'
caption_cb = place(sketch.create(textDAT, 'caption_callbacks'), -1200, 650)
caption_cb.text = CAPTION_CALLBACKS
oscin_text = place(sketch.create(oscinDAT, 'oscin_text'), -1000, 650)
setpar(oscin_text, 'port', LISTEN_PORT)
setpar(oscin_text, 'shared', True)
setpar(oscin_text, 'clamp', True)
setpar(oscin_text, 'maxlines', 20)
setpar(oscin_text, 'callbacks', caption_cb.name)

# The microphone's loudness, 0 to 1.
mic = place(sketch.create(audiodeviceinCHOP, 'mic'), -1000, -400)
analyze = place(sketch.create(analyzeCHOP, 'loudness'), -800, -400)
rms = [m for m in analyze.par.function.menuNames if 'rms' in m.lower()]
if rms:
    analyze.par.function = rms[0]
lag = place(sketch.create(lagCHOP, 'smooth'), -600, -400)
lag.par.lag1, lag.par.lag2 = 0.05, 0.25
gain = place(sketch.create(mathCHOP, 'gain'), -400, -400)
gain.par.gain = 6
clamp = place(sketch.create(limitCHOP, 'clamp'), -200, -400)
clamp.par.type = 'clamp'
clamp.par.min, clamp.par.max = 0, 1
audio = place(sketch.create(renameCHOP, 'audio'), 0, -400)
audio.par.renamefrom, audio.par.renameto = '*', 'td/audio/level'
wire(mic, analyze, lag, gain, clamp, audio)

# The rack, as numbers for the shader. The frame counter makes it cook every
# frame, so a scene change eases in even while nothing else moves.
frame = place(sketch.create(constantCHOP, 'frame'), -1000, 100)
setpar(frame, 'name0', 'frame')
setpar(frame, 'value0', expr='absTime.frame')
params_cb = place(sketch.create(textDAT, 'params_callbacks'), -800, 450)
params_cb.text = PARAMS_CALLBACKS
params = place(sketch.create(scriptCHOP, 'params'), -800, 200)
setpar(params, 'callbacks', params_cb.name)
oscin.outputConnectors[0].connect(params.inputConnectors[0])
frame.outputConnectors[0].connect(params.inputConnectors[1])
audio.outputConnectors[0].connect(params.inputConnectors[2])

# The caption, drawn white on nothing, on a strip wide enough for about
# sixteen letters; the shader colours it. 1280 wide, the most the free
# Non-Commercial licence draws.
word = place(sketch.create(textTOP, 'word'), -600, 0)
setpar(word, 'outputresolution', 'custom')
setpar(word, 'resolutionw', 1280)
setpar(word, 'resolutionh', 160)
setpar(word, 'text', expr="op('caption').text")
setpar(word, 'fontsizex', 80)
setpar(word, 'bold', True)
setpar(word, 'fontcolorr', 1)
setpar(word, 'fontcolorg', 1)
setpar(word, 'fontcolorb', 1)
setpar(word, 'bgalpha', 0)

# The tunnel: a shader fed its own last frame.
shader = place(sketch.create(textDAT, 'tunnel_pixel'), -400, 250)
shader.text = SHADER
tunnel = place(sketch.create(glslTOP, 'tunnel'), -200, 0)
setpar(tunnel, 'pixeldat', shader.name)
setpar(tunnel, 'outputresolution', 'custom')
setpar(tunnel, 'resolutionw', W)
setpar(tunnel, 'resolutionh', H)
setpar(tunnel, 'format', 'rgba16float')
setpar(tunnel, 'chopuniname0', 'uP')
setpar(tunnel, 'chop0', params.name)
setpar(tunnel, 'choparraytype0', 'uniformarray')
feedback = place(sketch.create(feedbackTOP, 'feedback'), -400, -150)
setpar(feedback, 'outputresolution', 'custom')
setpar(feedback, 'resolutionw', W)
setpar(feedback, 'resolutionh', H)
setpar(feedback, 'format', 'rgba16float')
word.outputConnectors[0].connect(feedback)
setpar(feedback, 'top', tunnel.name)
word.outputConnectors[0].connect(tunnel.inputConnectors[0])
feedback.outputConnectors[0].connect(tunnel.inputConnectors[1])

out = place(sketch.create(outTOP, 'out1'), 0, 0)
wire(tunnel, out)

# How bright the picture is: the meter's reading, 0 to 1.
mono = place(sketch.create(monochromeTOP, 'mono'), 0, -200)
average = place(sketch.create(analyzeTOP, 'average'), 200, -200)
setpar(average, 'op', 'average')
toCHOP = place(sketch.create(toptoCHOP, 'brightness_raw'), 400, -200)
setpar(toCHOP, 'g', '')
setpar(toCHOP, 'b', '')
setpar(toCHOP, 'a', '')
bgain = place(sketch.create(mathCHOP, 'brightness_gain'), 600, -200)
bgain.par.gain = 2.5
blimit = place(sketch.create(limitCHOP, 'brightness_clamp'), 800, -200)
blimit.par.type = 'clamp'
blimit.par.min, blimit.par.max = 0, 1
brightness = place(sketch.create(renameCHOP, 'brightness'), 1000, -200)
brightness.par.renamefrom, brightness.par.renameto = '*', 'td/sensor/distance'
wire(tunnel, mono, average)
setpar(toCHOP, 'top', average.name)
wire(toCHOP, bgain, blimit, brightness)

# Both readings, up to OSCAR: the rack's meters follow them.
readings = place(sketch.create(mergeCHOP, 'readings'), 1200, -300)
audio.outputConnectors[0].connect(readings.inputConnectors[0])
brightness.outputConnectors[0].connect(readings.inputConnectors[1])
oscout = place(sketch.create(oscoutCHOP, 'oscout'), 1400, -300)
setpar(oscout, 'netaddress', OSCAR_IP)
setpar(oscout, 'port', OSCAR_PORT)
wire(readings, oscout)

print('OSCAR Type Tunnel built in', sketch.path, '- listening on', LISTEN_PORT,
      '- sending readings to', OSCAR_IP, OSCAR_PORT)
