🔬 This flips mouseY so the top of the screen is loud. What happens if you swap the arguments to map(mouseY, 0, height, 0, MAX_AMP, true) instead?
const amp = map(mouseY, height, 0, 0, MAX_AMP, true);
function updateThereminSound() {
// Map mouseX -> pitch (frequency)
const freq = map(mouseX, 0, width, MIN_FREQ, MAX_FREQ, true);
// Map mouseY -> volume (top loud, bottom quiet)
const amp = map(mouseY, height, 0, 0, MAX_AMP, true);
// Smooth ramping for less clicky sound
// https://p5js.org/reference/#/p5.Oscillator/freq
// https://p5js.org/reference/#/p5.Oscillator/amp
osc.freq(freq, 0.05); // 50 ms ramp
osc.amp(amp, 0.05);
}
Line-by-line explanation (4 lines)
const freq = map(mouseX, 0, width, MIN_FREQ, MAX_FREQ, true);
- Converts the mouse's horizontal position (0 to width) into a frequency between MIN_FREQ and MAX_FREQ; the trailing true clamps the value so it never goes outside that range.
const amp = map(mouseY, height, 0, 0, MAX_AMP, true);
- Converts mouseY into a volume, but the input range is flipped (height to 0) so being near the top of the screen produces louder sound and the bottom produces near-silence.
osc.freq(freq, 0.05);
- Tells the oscillator to glide to the new frequency over 50 milliseconds instead of jumping instantly, which avoids harsh clicking sounds.
osc.amp(amp, 0.05);
- Same idea but for volume - ramps smoothly to the new amplitude over 50 milliseconds.
🔬 This loop draws 5 layered circles. What happens if you change the starting value from i = 5 to i = 12 (and keep dividing by 5)?
for (let i = 5; i >= 1; i--) {
const r = (maxR * i) / 5;
const alpha = 10 + (50 * boost * i) / 5;
fill(0, 255, 120, alpha);
ellipse(cx, cy, r * 2, r * 2);
}
function drawBackgroundGlow() {
const level = amplitudeAnalyzer.getLevel(); // 0..~0.3
const boost = constrain(level * 8, 0, 1); // exaggerate a bit
const cx = width / 2;
const cy = height * 0.5;
const maxR = min(width, height) * 0.8;
noStroke();
// A few layered circles for a soft, eerie green glow
for (let i = 5; i >= 1; i--) {
const r = (maxR * i) / 5;
const alpha = 10 + (50 * boost * i) / 5;
fill(0, 255, 120, alpha);
ellipse(cx, cy, r * 2, r * 2);
}
}
Line-by-line explanation (7 lines)
🔧 Subcomponents:
for-loop
Layered Glow Circles
for (let i = 5; i >= 1; i--) { ... }
Draws five overlapping semi-transparent circles from largest to smallest to create a soft glow effect
const level = amplitudeAnalyzer.getLevel();
- Reads the current loudness of the oscillator, typically a small number like 0 to 0.3.
const boost = constrain(level * 8, 0, 1);
- Multiplies the quiet level by 8 to make small volume changes visually obvious, then clamps the result between 0 and 1.
const maxR = min(width, height) * 0.8;
- Picks a maximum glow radius based on whichever of width or height is smaller, so the glow fits on any screen shape.
for (let i = 5; i >= 1; i--) {
- Loops from 5 down to 1, drawing the biggest circle first so smaller, brighter circles layer on top of it.
const alpha = 10 + (50 * boost * i) / 5;
- Calculates transparency for this ring - louder sound (higher boost) and outer rings (higher i) get more opacity.
fill(0, 255, 120, alpha);
- Sets a green glow color with the calculated transparency.
ellipse(cx, cy, r * 2, r * 2);
- Draws the circle centered on screen with a diameter of r*2.
🔬 numWaves controls how many rings are drawn. What happens visually if you change numWaves from 8 to 20 for much denser ripples?
for (let i = 0; i < numWaves; i++) {
const radius = (frameCount * speed + i * spacing) % maxR;
let alpha = map(radius, 0, maxR, 220, 0);
alpha *= 0.25 + levelBoost * 0.9; // brighter when louder
function drawSoundWaves() {
const level = amplitudeAnalyzer.getLevel();
const levelBoost = pow(constrain(level * 5, 0, 1), 1.3);
const cx = width / 2;
const cy = height * 0.45;
const maxR = min(width, height) * 0.65;
const numWaves = 8;
const speed = 2.2;
const spacing = maxR / numWaves;
noFill();
for (let i = 0; i < numWaves; i++) {
const radius = (frameCount * speed + i * spacing) % maxR;
let alpha = map(radius, 0, maxR, 220, 0);
alpha *= 0.25 + levelBoost * 0.9; // brighter when louder
const weight = 1 + levelBoost * 4;
stroke(0, 255, 180, alpha);
strokeWeight(weight);
ellipse(cx, cy, radius * 2, radius * 2);
}
}
Line-by-line explanation (6 lines)
🔧 Subcomponents:
for-loop
Expanding Ring Loop
for (let i = 0; i < numWaves; i++) { ... }
Draws 8 evenly-spaced rings that continuously expand outward and fade, using the modulo operator to loop them seamlessly
const levelBoost = pow(constrain(level * 5, 0, 1), 1.3);
- Amplifies the loudness reading and applies a power curve so quiet sounds barely register but loud sounds react strongly.
const spacing = maxR / numWaves;
- Evenly spaces out the starting radius of each ring so they don't all overlap at once.
const radius = (frameCount * speed + i * spacing) % maxR;
- Uses frameCount (which always increases) plus each ring's offset, then wraps the result with % maxR so each ring continuously grows and resets, like a real ripple.
let alpha = map(radius, 0, maxR, 220, 0);
- Makes rings fade out as they expand outward - close to the center they're opaque, far away they're nearly invisible.
alpha *= 0.25 + levelBoost * 0.9;
- Further scales the opacity by how loud the sound currently is, so the whole ripple effect gets brighter with volume.
ellipse(cx, cy, radius * 2, radius * 2);
- Draws the ring as an ellipse outline (since noFill() was called) with the calculated radius.
function drawThereminBody() {
const baseY = height * 0.75;
const pitchX = width * 0.18; // left vertical antenna
const volX = width * 0.82; // right horizontal loop
// Glows behind antennas
noStroke();
fill(0, 255, 140, 40);
ellipse(pitchX, baseY - height * 0.25, width * 0.2, height * 0.5);
ellipse(volX, baseY - height * 0.15, width * 0.25, height * 0.4);
// Base box
stroke(0, 255, 150, 160);
strokeWeight(3);
fill(10, 20, 40);
const baseW = width * 0.45;
const baseH = height * 0.08;
rectMode(CENTER);
rect(width / 2, baseY + baseH * 0.1, baseW, baseH, 10);
// Vertical pitch antenna (left)
stroke(0, 255, 160);
strokeWeight(6);
const pitchTopY = baseY - height * 0.4;
line(pitchX, baseY, pitchX, pitchTopY);
// Tip sphere
noStroke();
fill(0, 255, 180);
circle(pitchX, pitchTopY, 20);
// Horizontal volume loop (right)
noFill();
stroke(0, 255, 160);
strokeWeight(6);
const loopR = width * 0.075;
ellipse(volX, baseY - height * 0.12, loopR * 2, loopR * 1.3);
// Labels
fill(180, 255, 220);
noStroke();
textAlign(CENTER, CENTER);
textSize(14);
text('PITCH (mouse X)', pitchX, baseY + baseH * 0.9);
text('VOLUME (mouse Y)', volX, baseY + baseH * 0.9);
}
Line-by-line explanation (6 lines)
const baseY = height * 0.75;
- Positions the theremin's base box three-quarters of the way down the screen, using a percentage so it scales with any window size.
rect(width / 2, baseY + baseH * 0.1, baseW, baseH, 10);
- Draws the rounded rectangle base box centered horizontally, with a 10px corner radius.
line(pitchX, baseY, pitchX, pitchTopY);
- Draws the tall vertical pitch antenna as a straight line from the base up to its tip.
circle(pitchX, pitchTopY, 20);
- Draws a small glowing sphere at the very top of the pitch antenna.
ellipse(volX, baseY - height * 0.12, loopR * 2, loopR * 1.3);
- Draws the oval volume loop antenna as an outline (no fill) on the right side.
text('PITCH (mouse X)', pitchX, baseY + baseH * 0.9);
- Labels the pitch antenna so visitors know what controlling mouseX does.
function drawHandIndicator() {
// Simple glowing circle that follows the mouse
const level = amplitudeAnalyzer.getLevel();
const boost = constrain(level * 8, 0, 1);
const r = 10 + boost * 20;
noFill();
stroke(0, 255, 180, 200);
strokeWeight(2 + boost * 4);
ellipse(mouseX, mouseY, r * 2, r * 2);
stroke(0, 255, 120, 120);
strokeWeight(1);
ellipse(mouseX, mouseY, r * 1.3, r * 1.3);
}
Line-by-line explanation (3 lines)
const r = 10 + boost * 20;
- Calculates the indicator circle's radius - it grows from 10px to up to 30px as the volume increases.
ellipse(mouseX, mouseY, r * 2, r * 2);
- Draws the main glowing ring centered exactly on the current mouse position.
ellipse(mouseX, mouseY, r * 1.3, r * 1.3);
- Draws a second, thinner, dimmer ring slightly smaller than the first to give a layered glow effect.
function drawStartInstructions() {
push();
fill(0, 0, 0, 180);
noStroke();
rectMode(CENTER);
const w = min(width * 0.7, 500);
const h = 120;
rect(width / 2, height * 0.2, w, h, 12);
fill(180, 255, 220);
textAlign(CENTER, CENTER);
textSize(18);
text('Click or tap to awaken the Theremin\nMove mouse X for pitch, Y for volume', width / 2, height * 0.2);
pop();
}
Line-by-line explanation (5 lines)
push();
- Saves the current drawing style settings so changes made in this function don't leak into other functions.
const w = min(width * 0.7, 500);
- Makes the instruction box 70% of the screen width, but never wider than 500px, so it looks good on both phones and large monitors.
rect(width / 2, height * 0.2, w, h, 12);
- Draws a semi-transparent black rounded rectangle as a backdrop for the instruction text.
text('Click or tap to awaken the Theremin\nMove mouse X for pitch, Y for volume', width / 2, height * 0.2);
- Displays two lines of instruction text (the \n creates a line break) centered inside the box.
pop();
- Restores the drawing style settings that were active before this function ran.