//----------------------------------------------------------------------------- // name: sndpeek-felipe.ck // desc: sndpeek in ChuGL, with a basic 3D waterfall and a camera // that orbits to whichever pitch bucket is loudest // + color mapping - (uses paradigms from petal spiral hw1 for pitch / color mapping) // // author: Ge Wang (https://ccrma.stanford.edu/~ge/) // Andrew Zhu Aday (https://ccrma.stanford.edu/~azaday/) // (https://https://kunwookim.com/) // Felipe Campos // date: Fall 2026 //----------------------------------------------------------------------------- // window size 1024 => int WINDOW_SIZE; // y position of waveform 2 => float WAVEFORM_Y; // y position of spectrum -2.5 => float SPECTRUM_Y; // width of waveform and spectrum display 10 => float DISPLAY_WIDTH; // waterfall depth (number of spectra kept in history) 64 => int WATERFALL_DEPTH; // z distance between history rows; DEPTH * SPACING ~= DISPLAY_WIDTH so the // history is a square block that reads from any camera angle .15 => float WATERFALL_SPACING; // frequency range (Hz) that feeds the pitch buckets 150 => float PITCH_LO; 3000 => float PITCH_HI; // pitch bucket smoothing per audio hop: rise fast, fall slow .5 => float ATTACK; .97 => float RELEASE; // below this rms the input counts as silence and the camera holds still .005 => float SILENCE_RMS; // camera orbit 14 => float CAM_RADIUS; 3 => float CAM_HEIGHT; // window title GWindow.title( "sndpeek (felipe)" ); // uncomment to fullscreen GWindow.fullscreen(); // middle of the waterfall block; the camera orbits around this point @(0, 0, -WATERFALL_DEPTH * WATERFALL_SPACING / 2) => vec3 center; // custom GGen to render waterfall (ring buffer from https://chuck.stanford.edu/chugl/examples/deep/sndpeek.ck w/ pitch bucket color mapping) class Waterfall extends GGen { // waterfall playhead 0 => int playhead; // lines GLines wfl[WATERFALL_DEPTH]; // color each row had when it was recorded vec3 rowColor[WATERFALL_DEPTH]; // iterate over line GGens for( GLines w : wfl ) { // aww yea, connect as a child of this GGen w --> this; // line width w.width(.01); // color w.color( @(.4, 1, .4) ); } // copy fun void latest( vec2 positions[], vec3 color ) { // set into positions => wfl[playhead].positions; color => rowColor[playhead]; // advance playhead playhead++; // wrap it WATERFALL_DEPTH %=> playhead; } // update fun void update( float dt ) { // position playhead => int pos; // so good for( int i; i < wfl.size(); i++ ) { // start with playhead-1 and go backwards pos--; if( pos < 0 ) WATERFALL_DEPTH-1 => pos; // offset Z wfl[pos].posZ( -i ); // set fade wfl[pos].color( rowColor[pos] * Math.pow(1.0 - (i $ float / WATERFALL_DEPTH), 4) ); } } } // waveform renderer GLines waveform --> GG.scene(); waveform.width(.01); // float above the middle of the history block waveform.pos( @(0, WAVEFORM_Y, center.z) ); // make a waterfall Waterfall waterfall --> GG.scene(); // translate down waterfall.posY( SPECTRUM_Y ); // squeeze rows together in z waterfall.scaZ( WATERFALL_SPACING ); // accumulate samples from mic adc => Flip accum => blackhole; // take the FFT adc => PoleZero dcbloke => FFT fft => blackhole; // set DC blocker .95 => dcbloke.blockZero; // set size of flip WINDOW_SIZE => accum.size; // set window type and size Windowing.hann(WINDOW_SIZE) => fft.window; // set FFT size (will automatically zero pad) WINDOW_SIZE*2 => fft.size; // get a reference for our window for visual tapering of the waveform Windowing.hann(WINDOW_SIZE) @=> float window[]; // sample array float samples[0]; // FFT response complex response[0]; vec2 positions[WINDOW_SIZE]; // pitch class (0-11) of each fft bin, -1 if outside PITCH_LO..PITCH_HI (second/samp) / fft.size() => float binHz; int binPitch[WINDOW_SIZE]; for (int i; i < WINDOW_SIZE; i++) { i * binHz => float f; if (f < PITCH_LO || f > PITCH_HI) -1 => binPitch[i]; else (Math.round(Std.ftom(f)) $ int) % 12 => binPitch[i]; } // smoothed energy per pitch bucket float bucket[12]; // loudest bucket pitch class and loudest note (for that octave) 0 => int topBucket; 60 => int topMidi; // hsv -> rgb mirroring python's colorsys.hsv_to_rgb // https://github.com/python/cpython/blob/main/Lib/colorsys.py fun vec3 hsv(float h, float s, float v) { (h - Math.floor(h)) * 6.0 => float hh; Math.floor(hh) $ int => int sector; hh - sector => float f; v * (1 - s) => float p; v * (1 - s * f) => float q; v * (1 - s * (1 - f)) => float r; if (sector == 0) return @(v, r, p); if (sector == 1) return @(q, v, p); if (sector == 2) return @(p, v, r); if (sector == 3) return @(p, q, v); if (sector == 4) return @(r, p, v); return @(v, p, q); } // midi note -> color via circle of fifths - octave sets brightness note sets color (ported from hw1/petal-spiral.ck) fun vec3 midiColor(int midi) { ((midi % 12) * 7) % 12 => int fifths; // https://en.wikipedia.org/wiki/Multiplication_(music) "When the chromatic scale is multiplied by 7 (mod 12), the result is a cycle of fifths." trick haha midi / 12 - 1 => int octave; Math.max(0.2, Math.min(0.75, 0.25 + 0.06 * octave)) => float bright; return hsv(fifths / 12.0, 0.95, bright); } // sum spectrum energy into 12 pitch buckets, smooth, pick the loudest fun void updateBuckets() { // how loud is the input overall 0.0 => float sum; for (auto s : samples) s*s +=> sum; Math.sqrt(sum / samples.size()) => float rms; float raw[12]; 0.0 => float peak; 0 => int peakBin; for (int i; i < response.size(); i++) { if (binPitch[i] < 0) continue; (response[i] $ polar).mag => float m; // nice lil trick haha found in https://chuck.stanford.edu/chugl/examples/deep/soundbulb.ck m*m +=> raw[binPitch[i]]; if (m > peak) { m => peak; i => peakBin; } } // fast rise, slow fall for (int p; p < 12; p++) { if (raw[p] > bucket[p]) bucket[p] + (raw[p] - bucket[p]) * ATTACK => bucket[p]; else bucket[p] * RELEASE => bucket[p]; } // hold the last pitch through silence if (rms < SILENCE_RMS) return; 0 => int top; for (int p; p < 12; p++) if (bucket[p] > bucket[top]) p => top; top => topBucket; Math.round(Std.ftom(peakBin * binHz)) $ int => topMidi; } // map audio buffer to 3D positions fun void map2waveform( float in[], vec2 out[] ) { if( in.size() != out.size() ) { <<< "size mismatch in map2waveform()", "" >>>; return; } // mapping to xyz coordinate DISPLAY_WIDTH => float width; for (int i; i < in.size(); i++) { // space evenly in X -width/2 + width/WINDOW_SIZE*i => out[i].x; // map y, using window function to taper the ends in[i] * 2 * window[i] => out[i].y; } } // map FFT output to 3D positions fun void map2spectrum( complex in[], vec2 out[] ) { if( in.size() != out.size() ) { <<< "size mismatch in map2spectrum()", "" >>>; return; } // mapping to xyz coordinate DISPLAY_WIDTH => float width; for (int i; i < in.size(); i++) { // space evenly in X -width/2 + width/WINDOW_SIZE*i => out[i].x; // map frequency bin magnitide in Y 25 * Math.sqrt( (in[i]$polar).mag ) => out[i].y; } } // do audio stuff fun void doAudio() { while( true ) { // upchuck to process accum accum.upchuck(); // get the last window size samples (waveform) accum.output( samples ); // upchuck to take FFT, get magnitude response fft.upchuck(); // get spectrum (as complex values) fft.spectrum( response ); // pitch buckets updateBuckets(); // jump by samples WINDOW_SIZE::samp/2 => now; } } spork ~ doAudio(); // graphics render loop 0.0 => float camAngle; @(.4, .4, 1) => vec3 shownColor; 2.0 => float cameraSpeed; now => time previous; while( true ) { // next graphics frame GG.nextFrame() => now; (now - previous) / second => float dt; now => previous; // target camera angle - loudest bucket's spot on the circle of fifths (2 * pi * 7/12 = 210 degrees) 2 * pi * ((topBucket * 7) % 12) / 12.0 => float target; // shortest way around the circle target - camAngle => float diff; while (diff > pi) 2 * pi -=> diff; while (diff < -pi) 2 * pi +=> diff; diff * Math.min(1.0, cameraSpeed * dt) +=> camAngle; // easing per frame GG.camera().pos( center + @(CAM_RADIUS * Math.sin(camAngle), CAM_HEIGHT, CAM_RADIUS * Math.cos(camAngle)) ); GG.camera().lookAt( center ); // keep the waveform facing the camera waveform.rotY( camAngle ); // ease toward the pitch color (same as petal-spiral) midiColor(topBucket + 12 * (topMidi / 12)) => vec3 noteColor; shownColor + (noteColor - shownColor) * Math.min(1.0, 8.0 * dt) => shownColor; waveform.color( shownColor * 1.5 ); // map to interleaved format map2waveform( samples, positions ); // set the mesh position waveform.positions( positions ); // chugl // map to spectrum display map2spectrum( response, positions ); // push into the history waterfall.latest( positions, shownColor * 1.5 ); }