Voice to Visualizer

Assignment Submission

Primary Link: Provide your live demo / repo URL in Canvas.
Attached Code: visualizer.ck (download below)

Per the instructions, here is my primary project link (submitted in Canvas). For your convenience, I’ve also attached the raw ChucK source file for review and grading.

Tip: If Canvas only allows a URL, this HTML page contains the downloadable code and a readable copy below.

visualizer.ck
// =================== SIMPLE MODAL JAM ===================
// Lead: random E Dorian (2 octaves) + reverb
// Drone: single E3 with slow gain oscillation
// Rhythm: kick every beat

// ---------- GRAPHICS FLAG ----------
global int kickFlash;   // 0/1: set to 1 when kick triggers
global float droneVis;   // 0..1 mirror of the drone's current gain

// lead ? visuals bridge
global int   leadTrig;   // set to 1 on each note-on
global float leadFreq;   // Hz of current note
global float prevFreq;   // Hz of previous note
global float leadVel;    // 0..1 (how strong the note is; use a constant if no envelope)
global float leadDurSec; // duration (in seconds) of the current lead note



// ---------- TEMPO ----------
120 => float BPM;                         // tweak tempo
(60.0 / BPM)::second => dur beat;         // 1 beat

// ---------- LEAD (random notes + reverb) ----------
SawOsc lead => Gain lg => JCRev rv => dac;
0.25 => lg.gain;
0.20 => rv.mix;                  // tweak reverb amount (0..1)

// E Dorian (E F# G A B C# D) across two octaves: E3..D5
[52,54,55,57,59,61,62, 64,66,67,69,71,73,74] @=> int E_DORIAN[];

fun void leadRandom() {
    // start aligned to an 8th-note grid
    (beat/2) - (now % (beat/2)) => now;
    
    // base lengths to choose from: 8ths, quarters, halves
    [ beat/2, beat, 2::beat ] @=> dur baseChoices[];
    
    0.06 => float jitterFrac; // +-6% timing jitter
    
    while (true) {
        // --- pick pitch ---
        Math.random2(0, E_DORIAN.size()-1) => int i;
        
        // compute the frequency once
        Std.mtof(E_DORIAN[i]) => float f;
        
        // finally set the oscillator
        f => lead.freq;
        
        // --- timing (quantized with light jitter) ---
        baseChoices[Math.random2(0, baseChoices.size()-1)] => dur base;
        (base / samp) => float baseSamps;
        Math.random2f(-jitterFrac, jitterFrac) * baseSamps => float jitterSamps;
        Math.max((5::ms / samp), baseSamps + jitterSamps) $ int => int totalSamps;
        
        // >>> expose exact duration in seconds for visuals <<<
        (totalSamps :: samp / second) $ float => leadDurSec;
        
        // --- update globals for the visual (orbits) ---
        leadFreq => prevFreq;   // remember previous note
        f        => leadFreq;   // current note
        0.8      => leadVel;    // or Math.random2f(0.6,0.9)
        
        // trigger *after* we?ve set leadFreq and leadDurSec
        1        => leadTrig;   // spawn an orbit this note
        
        // wait for this note's actual (jittered) duration
        (totalSamps :: samp) => now;
    }
}

spork ~ leadRandom();

// ---------- DRONE (one note with gently shifting volume) ----------
SinOsc drone => Gain dG => dac;
Std.mtof(52) => drone.freq;      // E3
0.0 => dG.gain;                  // start silent

SinOsc lfo => blackhole;         // slow LFO for gain
0.20 => lfo.freq;                // ~5s cycle

0.10 => float DRONE_MIN;
0.45 => float DRONE_MAX;

fun void droneSwell() {
    while (true) {
        DRONE_MIN + ((lfo.last() + 1.0) * 0.5) * (DRONE_MAX - DRONE_MIN) => dG.gain;
        dG.gain() => droneVis;     // expose to visuals
        10::ms => now;
    }
}
spork ~ droneSwell();

// ---------- RHYTHM: KICK (808-style synth) ----------
fun void kick808() {
    // Body: sine -> amp env -> mix
    SinOsc body => ADSR amp => Gain mix => dac;
    // Click: noise -> highpass -> short env -> mix
    Noise n => HPF hp => ADSR click => mix;
    
    // Levels
    0.6  => mix.gain;     // overall kick loudness (raise/lower to taste)
    0.9  => body.gain;    // body dominates
    0.25 => n.gain;       // click is subtle
    
    // Envelopes
    amp.set(1::ms, 120::ms, 0.0, 25::ms);   // fast attack, short decay
    click.set(1::ms, 15::ms, 0.0, 5::ms);   // tiny transient
    1800 => hp.freq;                        // brighten click
    
    // Timing helpers
    70::ms => dur sweep;    // pitch sweep length (feel of the "thump")
    20     => int steps;    // smoothness of sweep
    
    // Sync to beat grid
    beat - (now % beat) => now;
    
    while(true) {
        // slight natural variation each hit
        Math.random2f(110.0, 140.0) => float fStart;  // Hz
        Math.random2f(40.0, 55.0)   => float fEnd;    // Hz
        
        // trigger envelopes
        amp.keyOn();
        click.keyOn();
        1 => kickFlash;   // <-- set flag for the light pulse
        
        // exponential pitch sweep: f(t) = fStart * (fEnd/fStart)^t
        for (0 => int i; i < steps; i++) {
            (i $ float) / (steps - 1) => float t;
            fStart * Math.pow(fEnd / fStart, t) => body.freq;
            (sweep / steps) => now;
        }
        
        // release envelopes
        click.keyOff();
        amp.keyOff();
        
        // wait the rest of the beat
        (beat - sweep) => now;
    }
}
spork ~ kick808();

//visuals 

// ===== ChuGL: side stage lights (pastel blue) that flash on kick =====
fun void visualsKickSideBars()
{
    // set window title (optional size is handled automatically)
    GG.windowTitle( "Kick Side Lights" );   // API style in examples
    
    // ----- geometry: two tall, thin planes added to the default scene -----
    // connect geometry into the scene (example shows: GPlane --> GG.scene())
    GPlane leftGeo  --> GG.scene();
    GPlane rightGeo --> GG.scene();
    
    // scale & position (normalized-ish scene coordinates)
    // width ~0.05 looks like a ~10px sliver on ~800?900px window;
    // height 2 fills the view vertically with default camera.
    @(0.55, 5.0, 1) => leftGeo.sca;
    @(-.99, 0, 0)  => leftGeo.pos;
    
    @(0.55, 5.0, 1) => rightGeo.sca;
    @( 0.99, 0, 0)  => rightGeo.pos;
    
    // make flat-color materials (pastel blue) and assign
    FlatMaterial leftMat ( Color.WHITE );
    FlatMaterial rightMat( Color.WHITE );
    leftGeo.mat(leftMat);
    rightGeo.mat(rightMat);
    
    // base pastel blue
    @(0.0, 0.3, 1.0) => vec3 baseCol;   // strong saturated blue
        
    // flash accumulator (0..1), exponential-ish decay per frame
    0.0 => float flash;
    0.90 => float decay;     // closer to 1.0 = longer glow
    
    // render loop (step one graphics frame at a time)
    while (true)
    {
        // bump when kick fires, otherwise decay
        if (kickFlash == 1) { 1.0 => flash; 0 => kickFlash; }
        else                { flash * decay => flash; }
        
        // brighten toward white on flash; lerp base -> white
        // c = base*(1 - a) + white*a
        1.0 => float a;
        (a * flash) => a;
        @( baseCol.x * (1 - a) + 1.0 * a,
        baseCol.y * (1 - a) + 1.0 * a,
        baseCol.z * (1 - a) + 1.0 * a ) => vec3 c;
        
        leftMat.color(c);
        rightMat.color(c);
        
        // draw next frame (per docs: first call also ensures window is created)
        GG.nextFrame() => now;   // sync render & advance time
    }
}

spork ~ visualsKickSideBars();

// ===== ChuGL: gradient sky tied to drone gain (Cosmic Wash) =====
fun void visualsDroneGradient()
{
    GWindow.windowed(900, 500);
    GWindow.title("Drone Gradient Sky");
    
    // background plane in the default scene
    GPlane bg --> GG.scene();
    @(2.6, 5.0, 1.0) => bg.sca;   // size (X,Y,Z)
    @(0.0, 0.0, -0.1) => bg.pos;  // slightly behind
    
    // colors: quiet = deep navy, loud = cyan/teal
    @(0.05, 0.05, 0.25) => vec3 navy;
    @(0.35, 0.85, 1.00) => vec3 teal;
    
    (1::second/60) => dur frame;  // ~60 FPS
    
    while (true)
    {
        // clamp droneVis (0..1)
        Math.min(1.0, Math.max(0.0, droneVis)) => float dv;
        
        // interpolate: navy*(1-dv) + teal*dv
        (navy.x * (1.0 - dv) + teal.x * dv) => float r;
        (navy.y * (1.0 - dv) + teal.y * dv) => float g;
        (navy.z * (1.0 - dv) + teal.z * dv) => float b;
        
        @(r, g, b) => vec3 rgb;
        bg.color(rgb);
        
        GG.nextFrame() => now;
        frame => now;
    }
}

// start the gradient visual
spork ~ visualsDroneGradient();

// ---------- SAFETY & COLOR HELPERS ----------
fun vec3 pcColor(float hz) {
    if (hz <= 0.0 || Math.isinf(hz) || Math.isnan(hz)) return @(0.85, 0.90, 1.00);
    Std.ftom(hz) => float midi;
    (Math.floor(midi + 0.5) $ int) % 12 => int pc;
    if(pc == 0 ) return @(1.00, 0.60, 0.60); // C
    if(pc == 1 ) return @(1.00, 0.75, 0.55); // C#
    if(pc == 2 ) return @(1.00, 0.95, 0.60); // D
    if(pc == 3 ) return @(0.85, 1.00, 0.65); // D#
    if(pc == 4 ) return @(0.65, 1.00, 0.75); // E
    if(pc == 5 ) return @(0.60, 1.00, 0.95); // F
    if(pc == 6 ) return @(0.60, 0.85, 1.00); // F#
    if(pc == 7 ) return @(0.65, 0.70, 1.00); // G
    if(pc == 8 ) return @(0.80, 0.65, 1.00); // G#
    if(pc == 9 ) return @(0.95, 0.65, 1.00); // A
    if(pc == 10) return @(1.00, 0.65, 0.85); // A#
    return @(1.00, 0.60, 0.70);              // B
}

// bright impact color (white flash)
fun vec3 impactColor(vec3 _) { return @(1.0, 1.0, 1.0); }

// linear blend (0..1)
fun vec3 mix(vec3 a, vec3 b, float t) {
    t => float u; if(u < 0.0) 0.0 => u; if(u > 1.0) 1.0 => u;
    return @(
        a.x*(1.0-u) + b.x*u,
        a.y*(1.0-u) + b.y*u,
        a.z*(1.0-u) + b.z*u
    );
}

// ----- prevent multiple window inits if you re-run the file -----
global int _visuals_inited;

// ===== ChuGL: Starfield Orbits for the lead (safe + synced + size variety) =====
fun void visualsLeadOrbits()
{
    if(_visuals_inited == 0) {
        // window + title (init once)
        GWindow.windowed(900, 500);
        GWindow.title("Lead Orbits");
        1 => _visuals_inited;
    }
    
    // center nucleus
    GPlane center --> GG.scene();
    @(0.06, 0.06, 1.0) => center.sca;
    @(0.0, 0.0, 0.0)   => center.pos;
    center.color(@(0.90, 0.95, 1.00));
    
    // pool of dots (planes)
    24 => int MAX;
    GPlane dots[MAX];
    for (0 => int i; i < MAX; i++) {
        GPlane p --> GG.scene();
        // avoid exact zero scale on X/Y
        @(0.0001, 0.0001, 1.0) => p.sca;  // hidden until used
        @(0.0, 0.0, 0.0)       => p.pos;
        p @=> dots[i];
    }
    
    // per-dot state
    float ang[MAX];        // angle (rad)
    float rad[MAX];        // base radius for X
    float omg[MAX];        // angular speed (rad/sec)
    float life[MAX];       // remaining life (sec)
    float lifeMax[MAX];    // max life (sec)
    float size0[MAX];      // starting dot size (with jitter)
    vec3  baseC[MAX];      // pitch color
    vec3  hitC[MAX];       // impact flash color
    float sinceSpawn[MAX]; // seconds since spawn
    int   alive[MAX];      // 0/1
    
    // parameters (unchanged feel)
    0.03 => float R_PER_SEMITONE;
    0.06 => float R_MIN;
    0.60 => float R_MAX;            // width cap unchanged (keep > 0)
    0.05 => float SIZE_MIN;
    0.11 => float SIZE_MAX;
    0.20 => float OMG_MULT;         // spin multiplier (lower = slower)
    
    // NEW: size variety + optional pulse
    0.35 => float SIZE_JITTER;  // +-35% random size variance at spawn
    0.10 => float PULSE_AMT;    // 0..~0.2 nice; set 0.0 to disable
    2.0  => float PULSE_HZ;     // pulses per second
    
    // vertical stretch to +-5.0 (guard divisor)
    5.0  => float Y_MAX;
    (Y_MAX / Math.max(R_MAX, 0.0001)) => float Y_SCALE;
    
    // impact flash duration
    0.10 => float IMPACT_DUR;       // 100 ms flash
    
    // frame timing (guard dt)
    (1::second/60) => dur frame;
    Math.max(1.0/600.0, (frame / second) $ float) => float dt;
    
    while (true)
    {
        // spawn EVERY time we get a note-on (even if same pitch)
        if (leadTrig == 1) {
            0 => leadTrig; // consume trigger immediately
            
            // find free slot (or overwrite oldest index 0 if none)
            -1 => int s;
            for (0 => int i; i < MAX; i++) if (!alive[i]) { i => s; break; }
            if (s == -1) 0 => s;
            
            // snapshot current/prev freq (guard values)
            (leadFreq  > 0.0 && !Math.isnan(leadFreq))  ? leadFreq  : 220.0 => float curr;
            (prevFreq  > 0.0 && !Math.isnan(prevFreq))  ? prevFreq  : curr   => float prev;
            
            // width radius from INTERVAL (in semitones), clamped
            Math.fabs(Std.ftom(curr) - Std.ftom(prev)) => float semis;
            (semis * R_PER_SEMITONE) => float rTmp;
            Math.max(R_MIN, Math.min(R_MAX, rTmp)) => rad[s];
            
            // pitch-locked spin; clamp to sane range
            (2.0 * Math.PI * curr * OMG_MULT) => float omgTmp;
            if(Math.isnan(omgTmp) || Math.isinf(omgTmp)) 0.0 => omgTmp;
            omgTmp => omg[s];
            
            // lifetime = exact note duration, with floor/ceiling
            Math.max(0.01, Math.min(10.0, Math.isnan(leadDurSec) ? 0.25 : leadDurSec)) => lifeMax[s];
            lifeMax[s] => life[s];
            
            // size from velocity, then add jitter (NEW)
            (Math.isnan(leadVel) ? 0.8 : Math.min(1.0, Math.max(0.0, leadVel))) => float v;
            SIZE_MIN + (SIZE_MAX - SIZE_MIN) * v => float baseSz;
            (1.0 + Math.random2f(-SIZE_JITTER, SIZE_JITTER)) * baseSz => size0[s];
            // clamp to safe bounds
            if (size0[s] < 0.02) 0.02 => size0[s];
            if (size0[s] > 0.25) 0.25 => size0[s];
            
            // colors: flash then settle to pitch color
            pcColor(curr) => baseC[s];
            impactColor(baseC[s]) => hitC[s];
            0.0 => sinceSpawn[s];
            
            // start angle
            Math.random2f(0.0, 2.0*Math.PI) => ang[s];
            
            1 => alive[s];
        }
        
        // updates
        for (0 => int i; i < MAX; i++) {
            if (!alive[i]) continue;
            
            life[i] - dt => life[i];
            sinceSpawn[i] + dt => sinceSpawn[i];
            
            if (life[i] <= 0.0) {
                0 => alive[i];
                // hide safely (avoid exact zero scale on both X/Y)
                @(0.0001, 0.0001, 1.0) => dots[i].sca;
                continue;
            }
            
            // motion: width same, Y stretched
            ang[i] + omg[i]*dt => ang[i];
            Math.cos(ang[i]) * rad[i]           => float x;
            Math.sin(ang[i]) * rad[i] * Y_SCALE => float y;
            
            // guard NaNs
            if(Math.isnan(x) || Math.isinf(x)) 0.0  => x;
            if(Math.isnan(y) || Math.isinf(y)) 0.0  => y;
            
            // impact flash -> base color blend
            Math.max(0.0, 1.0 - (sinceSpawn[i] / Math.max(IMPACT_DUR, 0.0001))) => float flashT; // 1..0
            mix(baseC[i], hitC[i], flashT) => vec3 currC;
            
            // fade brightness with age
            (life[i] / Math.max(lifeMax[i], 0.0001)) => float a; // 1..0
            if(Math.isnan(a) || Math.isinf(a)) 0.0 => a;
            @( currC.x * a, currC.y * a, currC.z * a ) => vec3 outC;
            
            // NEW: optional gentle pulse over lifetime
            float pulse;
            if (PULSE_AMT > 0.0) {
                ((lifeMax[i]-life[i]) * 2.0 * Math.PI * PULSE_HZ) => float tphase;
                (1.0 + PULSE_AMT * Math.sin(tphase)) => pulse;
            } else { 1.0 => pulse; }
            
            // size with floor
            (size0[i] * (0.4 + 0.6*a) * pulse) => float sz;
            if(sz < 0.0002) 0.0002 => sz;  // avoid fully zero scale
            
            @(sz, sz, 1.0) => dots[i].sca;
            @(x, y, 0.0)   => dots[i].pos;
            dots[i].color(outC);
        }
        
        GG.nextFrame() => now;  // per-frame step (required)
        frame => now;
    }
}

// run visuals (safe to call multiple times; init guarded)
spork ~ visualsLeadOrbits();

// ---------- keep VM alive ----------
while (true) 1::second => now;