Skip to content
Snippets Groups Projects
midi_particles.coffee 6.1 KiB
Newer Older
  • Learn to ignore specific revisions
  • #!/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
    
    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
    
    # 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
    
    Serene H's avatar
    Serene H committed
    midiToAngle = (key) -> (2 * Math.PI / 24) * key
    
    # Musical Constants
    
    Serene H's avatar
    Serene H committed
    # 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
    
                logMsg += ' : ' + getKeyName key
    
                delete lfoNotes[key]
                delete particleNotes[key]
    
    
            when 0x9  # Voice 0, note on
    
                logMsg += ' : ' + getKeyName key
    
    Serene H's avatar
    Serene H committed
                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 = () ->
    
        spinAngle = (spinAngle + timestepSize * spinRate) % (Math.PI * 2)
    
    Serene H's avatar
    Serene H committed
        # 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)
    
    Micah Elizabeth Scott's avatar
    Micah Elizabeth Scott committed
            # 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
    
                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