TooneTouch

TooneTouch is an interactive generative art piece that combines visual and audio elements. A 10x10 grid of luminous circles responds to user touch and mouse clicks, expanding and contracting while playing musical notes from a C major pentatonic scale. In idle mode, the sketch autonomously triggers cells and plays an ambient drone; in interactive mode, the user controls which cells activate.

๐ŸŽ“ Concepts You'll Learn

Interactive event handlingState managementAudio synthesis with Tone.jsHSB color modeAnimation and easingGrid-based layoutClass-based object designResponsive canvas resizingPolyphonic soundGenerative music and visuals

๐Ÿ”„ Code Flow

Code flow showing setup, draw, mousepressed, windowresized, switchmode, triggerrandomcell, cell

๐Ÿ’ก Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> canvas-setup[Canvas and Color Setup] setup --> synth-setup[Tone.js Synth Configuration] setup --> reverb-setup[Reverb Effect Setup] setup --> drone-setup[Ambient Drone Synth] setup --> idle-sequence-setup[Idle Mode Sequence] setup --> grid-creation[Grid Cell Creation] setup --> draw[draw loop] click setup href "#fn-setup" click canvas-setup href "#sub-canvas-setup" click synth-setup href "#sub-synth-setup" click reverb-setup href "#sub-reverb-setup" click drone-setup href "#sub-drone-setup" click idle-sequence-setup href "#sub-idle-sequence-setup" click grid-creation href "#sub-grid-creation" draw --> overlay-check[Play Overlay Display] draw --> background-mode-check[Mode-Based Background] draw --> idle-color-animation[Idle Mode Color Pulsing] draw --> cell-update-loop[Cell Update and Display] draw --> inactivity-check[Inactivity Detection] click draw href "#fn-draw" click overlay-check href "#sub-overlay-check" click background-mode-check href "#sub-background-mode-check" click idle-color-animation href "#sub-idle-color-animation" click cell-update-loop href "#sub-cell-update-loop" click inactivity-check href "#sub-inactivity-check" overlay-check --> overlay-dismiss[Play Overlay Dismissal] overlay-dismiss --> audio-context-resume[Audio Context Initialization] overlay-dismiss --> mode-switch-check[Mode Switching] click overlay-dismiss href "#sub-overlay-dismiss" click audio-context-resume href "#sub-audio-context-resume" click mode-switch-check href "#sub-mode-switch-check" mode-switch-check --> switchmode[switchMode] switchmode --> idle-mode-setup[Idle Mode Initialization] switchmode --> interactive-mode-setup[Interactive Mode Initialization] click switchmode href "#fn-switchmode" click idle-mode-setup href "#sub-idle-mode-setup" click interactive-mode-setup href "#sub-interactive-mode-setup" interactive-mode-setup --> cell-clearing-loop[Active Cell Clearing] click cell-clearing-loop href "#sub-cell-clearing-loop" cell-update-loop --> closest-cell-search[Closest Cell Detection] closest-cell-search --> random-cell-selection[Random Cell Selection] random-cell-selection --> state-check[Cell State Validation] state-check --> triggerrandomcell[triggerRandomCell] click cell-update-loop href "#sub-cell-update-loop" click closest-cell-search href "#sub-closest-cell-search" click random-cell-selection href "#sub-random-cell-selection" click state-check href "#sub-state-check" click triggerrandomcell href "#fn-triggerrandomcell" triggerrandomcell --> cell[Cell] cell --> constructor[Cell Constructor] constructor --> update-expanding[Expansion Animation] update-expanding --> update-contracting[Contraction Animation] update-contracting --> display-color-mapping[Dynamic Color Mapping] click cell href "#fn-cell" click constructor href "#sub-constructor" click update-expanding href "#sub-update-expanding" click update-contracting href "#sub-update-contracting" click display-color-mapping href "#sub-display-color-mapping" windowresized[windowResized] --> canvas-resize[Canvas Resizing] canvas-resize --> cell-position-reset[Cell Position and State Reset] click windowresized href "#fn-windowresized" click canvas-resize href "#sub-canvas-resize" click cell-position-reset href "#sub-cell-position-reset"

๐Ÿ“ Code Breakdown

setup()

setup() runs once when the sketch starts. It initializes the canvas, configures all audio synthesis and effects, creates the visual grid, and prepares the timing system. The sketch waits for the first user interaction before starting audio to comply with browser autoplay policies.

function setup() {
  createCanvas(windowWidth, windowHeight);
  colorMode(HSB, 360, 100, 100, 100); // Use HSB for easier color manipulation
  noStroke(); // No borders for the circles

  // --- Tone.js Setup ---
  // Create a PolySynth with a sine wave oscillator for cell notes
  synth = new Tone.PolySynth(Tone.Synth, {
    oscillator: { type: "sine" },
    envelope: { attack: 0.05, decay: 0.1, sustain: 0.5, release: 1 }
  }).toDestination();

  // Create a Reverb effect for ambience, connected to master output
  reverb = new Tone.Reverb(2).toDestination();
  // Connect the cell synth to the reverb
  synth.connect(reverb);

  // Initialize ambient drone synth
  ambientDrone = new Tone.Synth({
    oscillator: { type: "sawtooth" }, // Sawtooth wave for a richer, more complex drone sound
    envelope: { attack: 4, decay: 2, sustain: 0.8, release: 8 } // Long attack/release for a smooth drone
  }).connect(reverb); // Connect drone to reverb as well

  // Start Tone.Transport for scheduling autonomous events
  // Note: Tone.Transport will start, but audio won't play until Tone.context.resume() is called
  Tone.Transport.start(); // https://tonejs.github.io/docs/14.7.77/Transport#start

  // Initialize idle sequence for autonomous cell triggering
  idleSequence = new Tone.Sequence((time, value) => {
    if (value === 1 && mode === 'idle') {
      triggerRandomCell();
    }
  }, [1, null, 1, null, null, 1, null, 1, null, null, 1, null, 1, null, null, 1], "8n");
  idleSequence.loop = true;
  idleSequence.loopEnd = "2m"; // Loop length of 2 measures

  // --- Visuals Setup ---
  cellSpacing = min(width, height) / gridSize;

  for (let i = 0; i < gridSize; i++) {
    for (let j = 0; j < gridSize; j++) {
      let x = i * cellSpacing + cellSpacing / 2;
      let y = j * cellSpacing + cellSpacing / 2;
      let note = random(scale);
      cells.push(new Cell(x, y, note));
    }
  }

  // We no longer call switchMode('idle') here.
  // The first user interaction will handle starting the sketch.
  lastInteractionTime = millis(); // Initialize interaction time
}

๐Ÿ”ง Subcomponents:

initialization Canvas and Color Setup createCanvas(windowWidth, windowHeight); colorMode(HSB, 360, 100, 100, 100); noStroke();

Creates a full-screen canvas, switches to HSB color mode for easier hue manipulation, and removes circle outlines

initialization Tone.js Synth Configuration synth = new Tone.PolySynth(Tone.Synth, { oscillator: { type: "sine" }, envelope: { attack: 0.05, decay: 0.1, sustain: 0.5, release: 1 } }).toDestination();

Creates a polyphonic synthesizer that can play multiple notes simultaneously, with a sine wave and ADSR envelope for smooth note transitions

initialization Reverb Effect Setup reverb = new Tone.Reverb(2).toDestination(); synth.connect(reverb);

Creates a 2-second reverb effect and connects the synth to it, adding spatial ambience to the sounds

initialization Ambient Drone Synth ambientDrone = new Tone.Synth({ oscillator: { type: "sawtooth" }, envelope: { attack: 4, decay: 2, sustain: 0.8, release: 8 } }).connect(reverb);

Creates a separate synth for the ambient drone sound using a sawtooth wave with slow attack/release for smooth, continuous background sound

initialization Idle Mode Sequence idleSequence = new Tone.Sequence((time, value) => { if (value === 1 && mode === 'idle') { triggerRandomCell(); } }, [1, null, 1, null, null, 1, null, 1, null, null, 1, null, 1, null, null, 1], "8n");

Creates a rhythmic pattern that triggers random cells when in idle mode, creating autonomous visual and musical activity

for-loop Grid Cell Creation for (let i = 0; i < gridSize; i++) { for (let j = 0; j < gridSize; j++) { let x = i * cellSpacing + cellSpacing / 2; let y = j * cellSpacing + cellSpacing / 2; let note = random(scale); cells.push(new Cell(x, y, note)); } }

Creates a 10x10 grid of Cell objects, each positioned evenly across the canvas and assigned a random note from the pentatonic scale

Line by Line:

createCanvas(windowWidth, windowHeight)
Creates a canvas that fills the entire browser window, making the sketch responsive to window size
colorMode(HSB, 360, 100, 100, 100)
Switches from RGB to HSB (Hue, Saturation, Brightness) color mode, where hue ranges 0-360 (like a color wheel), making it easier to create color variations
noStroke()
Removes outlines from all shapes, so circles will be filled without borders
synth = new Tone.PolySynth(Tone.Synth, { ... }).toDestination()
Creates a polyphonic synthesizer that can play multiple notes at once, with sine wave oscillation and ADSR envelope for smooth sound transitions
reverb = new Tone.Reverb(2).toDestination()
Creates a reverb effect with 2 seconds of decay time, adding spacious ambience to sounds
synth.connect(reverb)
Routes the synth output through the reverb effect before sending to speakers
ambientDrone = new Tone.Synth({ oscillator: { type: "sawtooth" }, ... }).connect(reverb)
Creates a separate synth for ambient drone using a sawtooth wave (richer harmonics) with very long attack/release times for smooth, continuous sound
Tone.Transport.start()
Starts Tone.js's timing system, which allows scheduling of musical events and sequences
idleSequence = new Tone.Sequence((time, value) => { ... }, [1, null, 1, null, ...], "8n")
Creates a rhythmic sequence where 1 means trigger a random cell and null means skip, repeating in eighth-note timing
cellSpacing = min(width, height) / gridSize
Calculates the spacing between grid cells by dividing the smaller dimension by 10, ensuring the grid fits on screen
cells.push(new Cell(x, y, note))
Adds each newly created Cell object to the cells array for later updating and display
lastInteractionTime = millis()
Records the current time in milliseconds, used later to detect inactivity

draw()

draw() runs 60 times per second, creating the animation. It handles three main tasks: displaying the play overlay, managing the background and cell animations, and detecting user inactivity. The semi-transparent background creates fading trails that give the sketch a sense of motion and history.

function draw() {
  if (playOverlayActive) {
    // Draw the "Press to Play" overlay
    fill(0); // Black background for the overlay
    rect(0, 0, width, height);

    fill(255); // White text
    textSize(32);
    textAlign(CENTER, CENTER);
    textFont('Arial', 32); // Using a common font and size
    text('Press to Play', width / 2, height / 2);
  } else {
    // Existing drawing logic for idle/interactive mode
    // --- Fading Trails and Background ---
    if (mode === 'interactive') {
      fill(0, 0, 0, 15); // Slightly less transparent black for interactive mode trails
    } else {
      // Idle mode background: pulsing hue, saturation, and brightness for a more vibrant effect
      let idleHue = (frameCount * 0.1) % 360; // Slow, continuous hue shift
      let idleSaturation = map(sin(frameCount * 0.03), -1, 1, 50, 80); // Pulsing saturation
      let idleBrightness = map(sin(frameCount * 0.02), -1, 1, 20, 50); // Pulsing brightness
      fill(idleHue, idleSaturation, idleBrightness, 15); // Semi-transparent color for trails and background
    }
    rect(0, 0, width, height); // Draw over the entire canvas

    // Update and display each cell
    for (let cell of cells) {
      cell.update();
      cell.display();
    }

    // Check for inactivity to switch back to idle mode
    if (mode === 'interactive' && millis() - lastInteractionTime > idleTimeoutDuration) {
      switchMode('idle');
    }
  }
}

๐Ÿ”ง Subcomponents:

conditional Play Overlay Display if (playOverlayActive) { ... }

Shows the 'Press to Play' overlay on first load until the user clicks to enable audio

conditional Mode-Based Background if (mode === 'interactive') { ... } else { ... }

Uses different background colors for interactive mode (dark trails) versus idle mode (pulsing colors)

calculation Idle Mode Color Pulsing let idleHue = (frameCount * 0.1) % 360; let idleSaturation = map(sin(frameCount * 0.03), -1, 1, 50, 80); let idleBrightness = map(sin(frameCount * 0.02), -1, 1, 20, 50);

Creates animated background colors that slowly shift hue and pulse saturation/brightness using sine waves

for-loop Cell Update and Display for (let cell of cells) { cell.update(); cell.display(); }

Updates each cell's animation state and redraws it every frame

conditional Inactivity Detection if (mode === 'interactive' && millis() - lastInteractionTime > idleTimeoutDuration) { switchMode('idle'); }

Switches back to idle mode if the user hasn't interacted for 10 seconds

Line by Line:

if (playOverlayActive) { ... }
Checks if the play overlay should be displayed; if true, shows 'Press to Play' and skips the rest of draw()
fill(0); rect(0, 0, width, height);
Fills the entire canvas with black for the overlay background
fill(255); text('Press to Play', width / 2, height / 2);
Draws white text centered on the screen prompting the user to click
if (mode === 'interactive') { fill(0, 0, 0, 15); } else { ... }
In interactive mode, uses dark semi-transparent black for trails; in idle mode, uses animated colors
let idleHue = (frameCount * 0.1) % 360
Slowly shifts hue over time by incrementing 0.1 per frame, cycling back to 0 after reaching 360
let idleSaturation = map(sin(frameCount * 0.03), -1, 1, 50, 80)
Uses a sine wave to smoothly pulse saturation between 50% and 80%, creating a breathing effect
let idleBrightness = map(sin(frameCount * 0.02), -1, 1, 20, 50)
Uses a slower sine wave to pulse brightness between 20% and 50%, adding depth to the pulsing effect
rect(0, 0, width, height)
Draws a semi-transparent rectangle over the entire canvas, creating fading trails as new frames are drawn on top
for (let cell of cells) { cell.update(); cell.display(); }
Loops through every cell, updating its animation state and then drawing it on screen
if (mode === 'interactive' && millis() - lastInteractionTime > idleTimeoutDuration)
Checks if 10 seconds have passed since the last user interaction; if so, switches to idle mode

mousePressed()

mousePressed() is called whenever the user clicks. It handles two different scenarios: the first click (dismissing the overlay and enabling audio) and subsequent clicks (triggering cells). By finding the closest cell to the mouse, the sketch creates an intuitive interaction where clicking near a cell activates it.

function mousePressed() {
  if (playOverlayActive) {
    // If the overlay is active, this is the first user interaction
    if (Tone.context.state !== 'running') {
      Tone.context.resume(); // Start the audio context
      // https://tonejs.github.io/docs/14.7.77/Context#resume
    }
    playOverlayActive = false; // Dismiss the overlay
    lastInteractionTime = millis(); // Reset interaction timer to prevent immediate idle timeout
    switchMode('idle'); // Start the sketch in idle mode
    return; // Exit mousePressed to prevent immediate cell interaction
  }

  // --- Existing cell interaction logic (only runs after overlay is dismissed) ---
  // Switch to interactive mode on any mouse interaction
  if (mode === 'idle') {
    switchMode('interactive');
  }
  lastInteractionTime = millis(); // Reset interaction timer

  // Find the cell closest to the mouse and trigger its expansion
  let closestCell = null;
  let minDist = Infinity;

  for (let cell of cells) {
    let d = dist(mouseX, mouseY, cell.x, cell.y);
    if (d < minDist) {
      minDist = d;
      closestCell = cell;
    }
  }

  if (closestCell) {
    closestCell.triggerExpand();
  }
}

๐Ÿ”ง Subcomponents:

conditional Play Overlay Dismissal if (playOverlayActive) { ... return; }

Handles the first user click by enabling audio and dismissing the overlay

conditional Audio Context Initialization if (Tone.context.state !== 'running') { Tone.context.resume(); }

Starts the audio context if it hasn't been started yet, required by browsers for audio playback

conditional Mode Switching if (mode === 'idle') { switchMode('interactive'); }

Switches from idle to interactive mode when the user clicks

Line by Line:

if (playOverlayActive) { ... return; }
If the overlay is still showing, handle the first click to enable audio and dismiss the overlay, then exit early
if (Tone.context.state !== 'running') { Tone.context.resume(); }
Checks if audio hasn't started yet and resumes the audio context, which is required by modern browsers before playing sound
playOverlayActive = false
Removes the 'Press to Play' overlay so the main sketch is now visible
lastInteractionTime = millis()
Records the current time to reset the inactivity timer
switchMode('idle')
Starts the sketch in idle mode, where it will autonomously trigger cells until the user interacts
return
Exits the function early to prevent the overlay click from also triggering a cell
if (mode === 'idle') { switchMode('interactive'); }
When the user clicks after the overlay is dismissed, switches from idle to interactive mode
let d = dist(mouseX, mouseY, cell.x, cell.y)
Calculates the distance from the mouse click to the current cell using the dist() function
if (d < minDist) { minDist = d; closestCell = cell; }
Updates the closest cell if this cell is closer than the previous minimum distance
if (closestCell) { closestCell.triggerExpand(); }
Triggers the expansion animation and sound for the closest cell

windowResized()

windowResized() is automatically called by p5.js whenever the browser window is resized. This function ensures the sketch remains responsive by repositioning all cells to fit the new canvas size and resetting their animation states to prevent visual glitches.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  cellSpacing = min(width, height) / gridSize;

  // Recalculate cell positions and reset their states and sizes
  let index = 0;
  for (let i = 0; i < gridSize; i++) {
    for (let j = 0; j < gridSize; j++) {
      let x = i * cellSpacing + cellSpacing / 2;
      let y = j * cellSpacing + cellSpacing / 2;
      cells[index].x = x;
      cells[index].y = y;
      cells[index].size = 0; // Reset size
      cells[index].targetSize = 0; // Reset target size
      cells[index].state = 0; // Reset state to idle
      index++;
    }
  }
}

๐Ÿ”ง Subcomponents:

initialization Canvas Resizing resizeCanvas(windowWidth, windowHeight); cellSpacing = min(width, height) / gridSize;

Resizes the canvas to match the new window size and recalculates cell spacing

for-loop Cell Position and State Reset for (let i = 0; i < gridSize; i++) { for (let j = 0; j < gridSize; j++) { let x = i * cellSpacing + cellSpacing / 2; let y = j * cellSpacing + cellSpacing / 2; cells[index].x = x; cells[index].y = y; cells[index].size = 0; cells[index].targetSize = 0; cells[index].state = 0; index++; } }

Repositions all cells to fit the new canvas size and resets their animation states

Line by Line:

resizeCanvas(windowWidth, windowHeight)
Resizes the p5.js canvas to match the new browser window dimensions
cellSpacing = min(width, height) / gridSize
Recalculates the spacing between cells based on the new canvas size
let x = i * cellSpacing + cellSpacing / 2
Recalculates the x position for each cell based on the new cell spacing
let y = j * cellSpacing + cellSpacing / 2
Recalculates the y position for each cell based on the new cell spacing
cells[index].x = x; cells[index].y = y
Updates the cell's position to the newly calculated coordinates
cells[index].size = 0; cells[index].targetSize = 0; cells[index].state = 0
Resets the cell's animation state, clearing any ongoing expansions or contractions

switchMode(newMode)

switchMode() manages the transition between idle and interactive modes. In idle mode, the sketch runs autonomously with a scheduled sequence triggering random cells and an ambient drone playing continuously. In interactive mode, the user has full control, and the autonomous elements stop. This creates two distinct experiences within the same sketch.

function switchMode(newMode) {
  mode = newMode;
  if (mode === 'idle') {
    console.log('Switching to Idle Mode');
    idleSequence.start(Tone.now()); // Start the autonomous cell triggering sequence
    // Start the ambient drone in idle mode.
    // We triggerAttackRelease with a very long duration (32 measures)
    // so the drone plays continuously until triggerRelease() is called.
    ambientDrone.triggerAttackRelease("C3", "32m", Tone.now()); // Play a low C note
  } else {
    console.log('Switching to Interactive Mode');
    idleSequence.stop(Tone.now()); // Stop the autonomous sequence
    // Stop the ambient drone in interactive mode by releasing it
    ambientDrone.triggerRelease(Tone.now());
    // Optionally, clear any currently expanding cells to give the user immediate control
    for (let cell of cells) {
      if (cell.state !== 0) {
        cell.size = 0;
        cell.targetSize = 0;
        cell.state = 0;
      }
    }
  }
}

๐Ÿ”ง Subcomponents:

conditional Idle Mode Initialization if (mode === 'idle') { ... }

Starts the autonomous sequence and ambient drone when entering idle mode

conditional Interactive Mode Initialization } else { ... }

Stops the autonomous sequence and drone, and clears any active cells when entering interactive mode

for-loop Active Cell Clearing for (let cell of cells) { if (cell.state !== 0) { cell.size = 0; cell.targetSize = 0; cell.state = 0; } }

Resets any cells that are currently expanding or contracting, giving the user immediate control

Line by Line:

mode = newMode
Updates the global mode variable to either 'idle' or 'interactive'
if (mode === 'idle') { ... }
Executes idle mode setup if switching to idle
idleSequence.start(Tone.now())
Starts the autonomous cell-triggering sequence that fires at scheduled intervals
ambientDrone.triggerAttackRelease("C3", "32m", Tone.now())
Plays a low C note (C3) on the ambient drone for 32 measures, creating a continuous background sound
idleSequence.stop(Tone.now())
Stops the autonomous sequence when switching to interactive mode
ambientDrone.triggerRelease(Tone.now())
Stops the ambient drone by releasing it, allowing its long release envelope to fade out smoothly
if (cell.state !== 0) { cell.size = 0; cell.targetSize = 0; cell.state = 0; }
Resets any cell that is currently animating, giving the user immediate control over the grid

triggerRandomCell()

triggerRandomCell() is called by the idle sequence at scheduled intervals. It selects a random cell and triggers its expansion, but only if the cell is not already in the middle of expanding. This prevents overlapping animations and creates a more organic, musical pattern.

function triggerRandomCell() {
  let randomCell = random(cells);
  if (randomCell) {
    // Only trigger if the cell is currently idle or contracting
    if (randomCell.state === 0 || randomCell.state === 2) {
      randomCell.triggerExpand();
    }
  }
}

๐Ÿ”ง Subcomponents:

calculation Random Cell Selection let randomCell = random(cells)

Selects a random cell from the cells array

conditional Cell State Validation if (randomCell.state === 0 || randomCell.state === 2)

Only triggers cells that are idle or contracting, preventing interruption of expanding cells

Line by Line:

let randomCell = random(cells)
Picks a random cell from the cells array using p5.js's random() function
if (randomCell) { ... }
Checks that a cell was actually selected (safety check)
if (randomCell.state === 0 || randomCell.state === 2)
Only triggers the cell if it's idle (state 0) or contracting (state 2), not if it's expanding (state 1)
randomCell.triggerExpand()
Calls the cell's triggerExpand() method to start its expansion animation and sound

Cell (Class)

The Cell class represents each individual circle in the grid. Each cell has three states: idle (0), expanding (1), and contracting (2). When triggered, a cell grows to its target size, plays a musical note, and then shrinks back to nothing. The color becomes more vibrant as the cell expands, providing visual feedback. Each cell has its own speed and note, making the overall effect feel organic and musical.

class Cell {
  constructor(x, y, note) {
    this.x = x;
    this.y = y;
    this.size = 0; // Current size of the circle
    this.targetSize = 0; // Desired size for expansion
    this.speed = random(0.02, 0.08); // Speed of expansion/contraction
    this.state = 0; // 0: idle, 1: expanding, 2: contracting
    this.note = note; // The musical note assigned to this cell
    this.hue = random(360); // Random initial hue for the cell
  }

  // Update the cell's size based on its state
  update() {
    if (this.state === 1) { // If expanding
      if (this.size < this.targetSize) {
        this.size += cellSpacing * this.speed;
        if (this.size >= this.targetSize) {
          this.size = this.targetSize;
          // Trigger sound when fully expanded
          synth.triggerAttackRelease(this.note, "4n"); // Play note for a quarter note duration
          // https://tonejs.github.io/docs/14.7.77/PolySynth#triggerattackrelease
          this.state = 2; // Change state to contracting
        }
      }
    } else if (this.state === 2) { // If contracting
      if (this.size > 0) {
        this.size -= cellSpacing * this.speed * 0.5; // Contract slower than expanding
        if (this.size <= 0) {
          this.size = 0;
          this.state = 0; // Change state to idle
        }
      }
    }
  }

  // Display the cell as a circle
  display() {
    // Map the cell's size to saturation and brightness for visual feedback
    let saturation = map(this.size, 0, cellSpacing, 30, 100);
    let brightness = map(this.size, 0, cellSpacing, 10, 100);
    fill(this.hue, saturation, brightness, 90); // Use the random hue with mapped saturation/brightness
    circle(this.x, this.y, this.size); // Draw the circle
    // https://p5js.org/reference/#/p5/circle
  }

  // Method to trigger the cell's expansion
  triggerExpand() {
    // Only trigger if the cell is idle or contracting
    if (this.state === 0 || this.state === 2) {
      this.targetSize = cellSpacing * 0.9; // Expand to 90% of the cell spacing
      this.state = 1; // Set state to expanding
      this.hue = random(360); // Change hue on trigger for visual variety
      // https://p5js.org/reference/#/p5/random
    }
  }
}

๐Ÿ”ง Subcomponents:

initialization Cell Constructor constructor(x, y, note) { ... }

Initializes a new Cell with position, note, random speed, and visual properties

conditional Expansion Animation if (this.state === 1) { ... }

Grows the cell from 0 to target size, then plays a sound and switches to contracting

conditional Contraction Animation } else if (this.state === 2) { ... }

Shrinks the cell back to 0 size at half the expansion speed

calculation Dynamic Color Mapping let saturation = map(this.size, 0, cellSpacing, 30, 100); let brightness = map(this.size, 0, cellSpacing, 10, 100);

Maps the cell's size to color saturation and brightness for visual feedback

Line by Line:

constructor(x, y, note) { ... }
Creates a new Cell with the given x, y position and musical note
this.x = x; this.y = y
Stores the cell's position on the canvas
this.size = 0; this.targetSize = 0
Initializes the cell as invisible (size 0) with no target expansion
this.speed = random(0.02, 0.08)
Assigns a random expansion/contraction speed between 0.02 and 0.08, making each cell feel unique
this.state = 0
Sets the initial state to idle (0), meaning the cell is not animating
this.note = note; this.hue = random(360)
Stores the assigned musical note and gives the cell a random starting color
if (this.state === 1) { if (this.size < this.targetSize) { this.size += cellSpacing * this.speed; } }
When expanding, increases the size by a speed amount each frame until reaching the target size
if (this.size >= this.targetSize) { synth.triggerAttackRelease(this.note, "4n"); this.state = 2; }
When the cell reaches full size, plays its assigned note and switches to contracting state
this.size -= cellSpacing * this.speed * 0.5
When contracting, decreases the size at half the expansion speed, creating an asymmetrical animation
let saturation = map(this.size, 0, cellSpacing, 30, 100)
Maps the cell's size to saturation: small cells are dull (30%), large cells are vibrant (100%)
let brightness = map(this.size, 0, cellSpacing, 10, 100)
Maps the cell's size to brightness: small cells are dark (10%), large cells are bright (100%)
fill(this.hue, saturation, brightness, 90); circle(this.x, this.y, this.size)
Draws the cell as a circle with the calculated color and current size
if (this.state === 0 || this.state === 2) { this.targetSize = cellSpacing * 0.9; this.state = 1; }
Starts expansion if the cell is idle or contracting, setting the target size to 90% of cell spacing
this.hue = random(360)
Changes the cell's color to a new random hue each time it's triggered, creating visual variety

๐Ÿ“ฆ Key Variables

cells array

Stores all 100 Cell objects in the grid, allowing the sketch to update and display them each frame

let cells = [];
gridSize number

Defines the dimensions of the grid (10x10), determining how many cells are created

let gridSize = 10;
cellSpacing number

Calculated spacing between cell centers, ensuring the grid fits evenly on the canvas

cellSpacing = min(width, height) / gridSize;
synth object

Tone.js PolySynth that plays the musical notes triggered by cells

synth = new Tone.PolySynth(Tone.Synth, { ... }).toDestination();
reverb object

Tone.js Reverb effect that adds spatial ambience to all sounds

reverb = new Tone.Reverb(2).toDestination();
ambientDrone object

Tone.js Synth that plays a continuous background drone sound in idle mode

ambientDrone = new Tone.Synth({ ... }).connect(reverb);
scale array

Array of musical note names from the C major pentatonic scale, used to assign notes to cells

const scale = ["C4", "D4", "E4", "G4", "A4"];
mode string

Tracks whether the sketch is in 'idle' (autonomous) or 'interactive' (user-controlled) mode

let mode = 'idle';
lastInteractionTime number

Stores the timestamp of the last user interaction, used to detect inactivity

let lastInteractionTime = 0;
idleTimeoutDuration number

Time in milliseconds (10000 = 10 seconds) before the sketch switches back to idle mode

let idleTimeoutDuration = 10000;
idleSequence object

Tone.js Sequence that autonomously triggers random cells at scheduled intervals in idle mode

idleSequence = new Tone.Sequence((time, value) => { ... }, [...], "8n");
playOverlayActive boolean

Flag that determines whether the 'Press to Play' overlay is displayed; dismissed on first click

let playOverlayActive = true;

๐Ÿงช Try This!

Experiment with the code by making these changes:

  1. Change gridSize from 10 to 15 or 20 to create a finer or coarser grid. Notice how cell spacing automatically adjusts to fit the canvas.
  2. Modify the scale array in setup() to use different notes, like ["C3", "E3", "G3", "B3", "D4"] to create a different harmonic feel.
  3. Change the idleTimeoutDuration from 10000 to 5000 (5 seconds) or 20000 (20 seconds) to make the sketch switch modes faster or slower.
  4. In the Cell class display() method, change the fill alpha from 90 to 50 to make cells more transparent, or to 255 for fully opaque circles.
  5. Modify the idle mode background colors by changing the sine wave multipliers: try frameCount * 0.05 for even slower hue shifts, or frameCount * 0.2 for faster shifts.
  6. In the idleSequence pattern, change [1, null, 1, null, null, 1, null, 1, ...] to [1, 1, 1, null, 1, null, 1, 1, ...] to create a different rhythm of autonomous cell triggers.
  7. Change the ambientDrone note from "C3" to "G2" or "E3" to experiment with different bass notes for the background drone.
  8. In the Cell triggerExpand() method, change targetSize from cellSpacing * 0.9 to cellSpacing * 0.5 or cellSpacing * 1.2 to make cells expand to different sizes.
Open in Editor & Experiment โ†’

๐Ÿ”ง Potential Improvements

Here are some ways this code could be enhanced:

BUG mousePressed() closest cell detection

If no cells exist (edge case), closestCell remains null but the code still tries to call triggerExpand() on it, which could cause an error

๐Ÿ’ก The null check is already in place (if (closestCell) { ... }), but add a console warning if no cell is found to help with debugging

PERFORMANCE draw() - idle mode background calculation

Three map() and sin() calculations happen every frame even in interactive mode, where they're not used

๐Ÿ’ก Move the idle color calculations inside the else block to only calculate them when mode === 'idle', avoiding unnecessary math in interactive mode

PERFORMANCE windowResized() - cell reset loop

The nested for loop recalculates positions using the same formula as setup(), duplicating code

๐Ÿ’ก Create a helper function like recalculateCellPositions() to avoid code duplication and make maintenance easier

STYLE Cell class - state management

Cell states are represented as numbers (0, 1, 2) which is not intuitive; magic numbers reduce code readability

๐Ÿ’ก Define constants at the top of the sketch: const CELL_STATE = { IDLE: 0, EXPANDING: 1, CONTRACTING: 2 }; then use CELL_STATE.IDLE instead of 0

FEATURE switchMode() - interactive mode cleanup

When switching to interactive mode, all active cells are immediately reset, which can feel jarring if the user clicks during an animation

๐Ÿ’ก Instead of immediately resetting, allow cells to finish their current animation before accepting new triggers, or add a fade-out effect

BUG setup() - Tone.Transport.start()

Tone.Transport is started in setup(), but the audio context isn't resumed until the first click, potentially causing timing issues

๐Ÿ’ก Consider starting Tone.Transport only after Tone.context.resume() is called in mousePressed(), ensuring the transport and audio context are synchronized

FEATURE Cell class - visual feedback

Cells only change color when triggered; there's no visual indication of which cell is closest to the mouse

๐Ÿ’ก Add a hover effect that slightly brightens or changes the hue of cells near the mouse cursor, providing better visual feedback

Preview

TooneTouch - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of TooneTouch - Code flow showing setup, draw, mousepressed, windowresized, switchmode, triggerrandomcell, cell
Code Flow Diagram