#!/usr/bin/env coffee
#
# Particle system, playable with a MIDI keyboard!
#
# Dependencies: run `npm install` in this directory.
#
# Assumes the default MIDI input port (0).
# Specify a layout file on the command line, or use the default (grid32x16z)
#
# Controls:
#    (Tested with Alesis Q49 keyboard controller)
#    Left hand  -> Low frequency oscillators (wobble)
#    Right hand -> Particles, shifting in color and pointiness
#
# 2014, Micah Elizabeth Scott & Keroserene
#

# Default MIDI input
midi = require 'midi'
input = new midi.input
input.openPort 1
input.ignoreTypes false, false, false

# Default OPC output
OPC = new require './opc'
model = OPC.loadModel process.argv[2] || '../layouts/grid32x16z.json'
client = new OPC 'localhost', 7890

# Live particles
particles = []

# Notes for low frequency oscillators
lfoNotes = {}

# Notes for particles
particleNotes = {}

# Adjustable parameters
particleLifetime = 1.0
brightness = 1.0
spinRate = 0
noteSustain = 1.6
wobbleAmount = 24.0
origin = [0, 0, 0]

# Physics
# numPhysicsTimesteps = 20  TODO: Re-enable when things become more complex
frameDelay = 5
timestepSize = 0.010
gain = 0.1

# Derived values
particleDecay = timestepSize / particleLifetime

# Controlled by the Pitch Transpose Knob
spinAngle = 0

# Time clock in seconds
clock = () -> 0.001 * new Date().getTime()

# Midi to frequency
midiToHz = (key) -> 440 * Math.pow 2, (key - 69) / 12

# Midi note to angle, one rev per octave
midiToAngle = (key) -> (2 * Math.PI / 24) * key

# Musical Constants
# Boundary between the left-hand and right-hand patterns.
LIMINAL_KEY = 40
MAX_VELOCITY = 100
SHARP_NAMES = ['A', 'A#', 'B', 'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#']
FLAT_NAMES = ['A', 'Bb', 'B', 'C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab']
LOWEST_A = 1
HIGHEST_C = 124
getKeyName = (key) -> FLAT_NAMES[(key - LOWEST_A) % 12]

input.on 'message', (deltaTime, message) ->
    logMsg = message
    msgType = Math.floor parseInt(message[0]) / 16  # Examine the 0xF0 byte.

    switch msgType
        when 0x8  # Voice 0, note off
            key = message[1]
            logMsg += ' : ' + getKeyName key
            delete lfoNotes[key]
            delete particleNotes[key]

        when 0x9  # Voice 0, note on
            key = message[1]
            logMsg += ' : ' + getKeyName key
            info =
                key: key
                velocity: message[2]
                timestamp: clock()

            # Split keyboard into particles and LFOs
            if key >= LIMINAL_KEY
                particleNotes[key] = info
            else
                lfoNotes[key] = info

        when 0xb  # Voice 0, Control Change
            switch message[1]
                when 7   # "Data entry" slider, brightness
                    brightness = message[2] * 2.0 / 127

                when 1   # "Modulation" slider, particle speed
                    particleLifetime = 0.1 + message[2] * 2.0 / 127

        when 0xe  # Voice 0, Pitch Bend
            # Default spin 1.0, but allow forward/backward
            spinRate = (message[2] - 64) * 10.0 / 64
    console.log logMsg

draw = () ->

    # Time delta calculations
    now = clock()

    # Global spin update
    spinAngle = (spinAngle + timestepSize * spinRate) % (Math.PI * 2)

    # Experiment - moves the origin in a circle
    theta = now * 2.5
    origin[0] = 0.2 * Math.cos theta
    origin[2] = 0.2 * Math.sin theta

    # Launch new particles for all active notes
    for key, note of particleNotes
        particles.push
            life: 1
            note: note
            point: origin.slice 0
            velocity: [0, 0, 0]
            timestamp: note.timestamp

    # Update appearance of all particles
    for p in particles

        # Angle: Global spin, thne positional mapping to key
        theta = spinAngle + midiToAngle p.note.key

        # Radius: Particles spawn in center, fly outward
        radius = 3.0 * (1 - p.life)

        # Positioned in polar coordinates
        x = origin[0] + radius * Math.cos(theta)
        y = origin[2] + radius * Math.sin(theta)

        # Hop around between almost-opposing colors, eventually going
        # around the rainbow. These ratios control what kinds of color
        # schemes we get for different chords. This operates by the circle of
        # fifths.
        hue = (p.note.key - LIMINAL_KEY + 0.1) * (7 / 12.0)
        p.color = OPC.hsv hue, 0.5, 0.8

        # Intensity mapped to velocity, nonlinear
        p.intensity = Math.pow(p.note.velocity / MAX_VELOCITY, 2.0) * 0.2 * brightness

        # Fade with age
        noteAge = now - p.note.timestamp
        p.intensity *= Math.max(0, 1 - (noteAge / noteSustain))

        # Falloff gets sharper as the note gets higher
        p.falloff = 15 * Math.pow(2, (p.note.key - LIMINAL_KEY) / 6)

        # Add influence of LFOs
        for key, note of lfoNotes
            lfoAge = now - note.timestamp
            hz = midiToHz key
            lfoAngle = midiToAngle key

            # Amplitude starts with left hand velocity
            wobbleAmp = Math.pow(note.velocity / MAX_VELOCITY, 2.0) * wobbleAmount

            # Scale based on particle fuzziness
            wobbleAmp /= p.falloff

            # Fade over time
            wobbleAmp /= 1 + lfoAge

            # Wobble
            wobbleAmp *= Math.sin(p.life * Math.pow(3, (p.note.key - LIMINAL_KEY/2) / 12.0))

            # Wobble angle driven by LFO note and particle life
            x += wobbleAmp * Math.cos lfoAngle
            y += wobbleAmp * Math.sin lfoAngle

        # Update velocity; use the XZ plane
        p.velocity[0] += (x - p.point[0]) * (gain) # / numPhysicsTimesteps)
        p.velocity[2] += (y - p.point[2]) * (gain) # / numPhysicsTimesteps)

        # Fixed timestep physics
        # TODO: Re-enable this when it's not just re-adding the delta.
        # for i in [1 .. numPhysicsTimesteps]
        p.point[0] += p.velocity[0]
        p.point[1] += p.velocity[1]
        p.point[2] += p.velocity[2]
        p.life -= particleDecay

    # Filter out dead particles
    particles = particles.filter (p) -> p.life > 0

    # Render particles to the LEDs
    client.mapParticles particles, model

setInterval draw, frameDelay