Voice Aurora - AI Sound Visualizer - xelsed.ai

This sketch turns your microphone into a wand for painting the night sky: it listens to speech, sends the transcript to OpenAI to generate a set of colors and a mood description, then renders flowing, glowing aurora ribbons using layered Perlin-noise curves while a voice reads the AI's description back to you. Floating particles drift upward in the foreground and the aurora subtly reacts to live microphone volume.

🧪 Try This!

Experiment with the code by making these changes:

  1. Speed up the flowing waves — Increasing how fast the noise offset advances each frame makes the aurora ribbons ripple and flow much faster.
  2. Thin out the aurora layers — Reducing numLayers draws fewer, more distinct ribbons instead of a dense stack, making individual wave shapes easier to see.
  3. Fill the sky with particles — Raising particleCount fills the screen with far more drifting glowing dots for a denser, starrier atmosphere.
Prefer the full editor? Open it there →

📖 About This Sketch

Voice Aurora listens to what you say, asks OpenAI's chat API to turn your words into a palette of colors and an intensity level, then paints layered, glowing ribbons of light across the screen using Perlin noise and curveVertex(). At the same time it calls OpenAI's text-to-speech API so the sketch can 'speak' a poetic description of the scene it just created, and it keeps a field of soft particles drifting upward for atmosphere.

The code is split into p5.js lifecycle functions (setup(), draw(), windowResized()), a Particle class for the floating dots, and a set of async helper functions that talk to the browser's Web Speech API and to two different OpenAI endpoints (chat completions and text-to-speech). By studying it you'll learn how to layer noise-based curves for an organic wave effect, how to fake a glow with multiple transparent strokes, how mic amplitude can drive an animation, and how to wire a p5.js sketch up to real-time speech recognition and external AI APIs.

⚙️ How It Works

  1. On load, setup() creates a full-window canvas, starts the microphone and an Amplitude analyzer, computes spacing for 10 aurora layers, grabs references to the HTML button/status elements, wires up speech recognition, spawns 200 floating particles, and has OpenAI speak the default description out loud.
  2. Every frame, draw() clears the canvas to black, then updates and redraws every particle (removing ones that have floated off the top), occasionally spawning a replacement particle to keep the count steady.
  3. draw() reads the current microphone level and maps it to a 'reactivity' offset that nudges every wave layer, adding a subtle pulse when you make noise.
  4. For each of 5 'glow passes', the sketch loops over all aurora layers, blends between two of the AI-chosen hex colors with lerpColor(), sets a transparency and stroke weight based on the glow pass and the AI's 'intensity' setting, then draws a smooth curve across the screen using curveVertex() at points whose height comes from 2D Perlin noise - this layered redraw is what creates the soft glowing look.
  5. When you click the mic button and speak, the Web Speech API transcribes your words; once a final transcript is ready it's sent to callOpenAI(), which asks GPT for a JSON object of colors, intensity, and a description, and that object replaces currentAuroraParams so the very next frame's aurora changes color and shape.
  6. callOpenAITextToSpeech() then sends that description to OpenAI's TTS endpoint, turns the returned audio blob into a p5.SoundFile, and plays it so the sketch narrates the scene it just generated.

🎓 Concepts You'll Learn

2D Perlin noise for organic motioncurveVertex() for smooth flowing shapesLayered transparency for a glow effectColor interpolation with lerpColor()p5.Amplitude for microphone-reactive visualsWeb Speech API for voice transcriptionAsync/await fetch() calls to external APIsObject-oriented particle systems with classes

📝 Code Breakdown

getApiKey()

This is a light obfuscation technique, not real security - XOR with a single byte is trivial to reverse. Any user can open devtools, call getApiKey(), and read your real OpenAI key. For a public sketch, API calls like this should go through a backend server that holds the real key.

function getApiKey() {
  return atob(encoded).split('').map(c => String.fromCharCode(c.charCodeAt(0) ^ key)).join('');
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

calculation XOR Decode Map return atob(encoded).split('').map(c => String.fromCharCode(c.charCodeAt(0) ^ key)).join('');

Base64-decodes the obfuscated string, then XORs each character code with a fixed key to reveal the real API key

return atob(encoded)
atob() decodes the Base64-encoded string 'encoded' back into raw scrambled text
.split('')
Breaks the scrambled string into an array of individual characters so they can be processed one by one
.map(c => String.fromCharCode(c.charCodeAt(0) ^ key))
For every character, gets its numeric code, flips bits using XOR with 'key' (0x5A), and turns that number back into a character - this undoes the same XOR that was used to scramble the key originally
.join('')
Glues the unscrambled characters back together into the final plain-text API key string

setup()

setup() runs once and is the right place to configure hardware access (mic), pre-calculate layout values like layerSeparation, connect to DOM elements outside the canvas, and populate starting arrays like particles.

function setup() {
  createCanvas(windowWidth, windowHeight);
  background(0); // Black background

  // Initialize mic input for visual reactivity
  mic = new p5.AudioIn();
  mic.start();
  amplitude = new p5.Amplitude();
  amplitude.setInput(mic);

  layerSeparation = height / (numLayers + 2); // Calculate vertical spacing for layers

  // Get references to DOM elements
  micButton = document.getElementById('micButton');
  transcriptElement = document.getElementById('transcript');
  statusElement = document.getElementById('status');

  // Set up speech recognition functionality
  setupSpeechRecognition();

  // Create initial particles
  for (let i = 0; i < particleCount; i++) {
    particles.push(new Particle());
  }

  // Speak the initial description using OpenAI TTS
  callOpenAITextToSpeech(currentAuroraParams.description);
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

for-loop Initial Particle Spawn for (let i = 0; i < particleCount; i++) {

Creates the starting batch of Particle objects and pushes them into the particles array

createCanvas(windowWidth, windowHeight);
Makes the canvas fill the entire browser window so the aurora spans the whole screen
mic = new p5.AudioIn();
Creates an audio input object that will request microphone access
mic.start();
Starts capturing audio from the microphone (the browser will prompt for permission)
amplitude = new p5.Amplitude();
Creates an analyzer that can measure how loud the current audio is
amplitude.setInput(mic);
Tells the amplitude analyzer to listen specifically to the microphone input rather than the whole page audio
layerSeparation = height / (numLayers + 2);
Divides the canvas height into evenly spaced bands so the 10 aurora layers stack neatly with some margin
micButton = document.getElementById('micButton');
Grabs a reference to the HTML button so p5.js code can read/change its text and attach click behavior
setupSpeechRecognition();
Calls a helper function (defined later) that configures the Web Speech API and hooks up the mic button's click event
for (let i = 0; i < particleCount; i++) { particles.push(new Particle()); }
Creates 200 Particle objects up front so the screen isn't empty when the sketch starts
callOpenAITextToSpeech(currentAuroraParams.description);
Immediately asks OpenAI to speak the default aurora description, so the sketch talks to you right away

draw()

draw() runs about 60 times per second and is where all animation logic lives - reading input (mic level), updating state (particles, noise offsets), and rendering everything fresh each frame. The 'draw the same thing multiple times with different transparency' technique used here for the glow is a common cheap alternative to real shader-based blur/bloom effects.

🔬 This outer loop redraws every layer 5 times to build the glow. What happens if you change glowPass < 5 to glowPass < 1 (no glow at all) or to glowPass < 15 (a much thicker, hazier glow)?

  for (let glowPass = 0; glowPass < 5; glowPass++) {
    for (let i = 0; i < numLayers; i++) {
      let layerY = height - (i + 1) * layerSeparation; // Vertical position of the layer

🔬 This loop samples noise every 20 pixels to shape the wave. What happens to the curve's smoothness and frame rate if you change the step to 5 (more detail) or 60 (chunkier, faster)?

      for (let x = 0; x <= width; x += 20) {
        let noiseVal = noise(x * noiseScale + noiseOffset, i * noiseScale + yOffset);
        let y = layerY + reactivity + noiseVal * 100; // Apply Perlin noise for wave shape
        curveVertex(x, y);
      }
function draw() {
  background(0); // Clear the background each frame

  // Update and draw particles
  for (let i = particles.length - 1; i >= 0; i--) {
    particles[i].update();
    particles[i].show();
    if (particles[i].finished()) {
      particles.splice(i, 1); // Remove particles that have floated off-screen
    }
  }
  // Add new particles occasionally to maintain the count
  if (random(1) < 0.1 && particles.length < particleCount) {
    particles.push(new Particle());
  }

  // Get mic level for reactivity
  let micLevel = amplitude.getLevel();
  // Map mic level to a reactivity range for the aurora waves
  let reactivity = map(micLevel, 0, 1, 0, 50);

  // Aurora effect
  noFill(); // Aurora waves should not be filled
  strokeJoin(ROUND); // Makes curve connections smoother

  // Draw multiple passes for a glow effect
  for (let glowPass = 0; glowPass < 5; glowPass++) {
    for (let i = 0; i < numLayers; i++) {
      let layerY = height - (i + 1) * layerSeparation; // Vertical position of the layer

      // Interpolate colors for a gradient effect across layers
      let auroraColor = lerpColor(
        color(currentAuroraParams.colors[i % currentAuroraParams.colors.length]),
        color(currentAuroraParams.colors[(i + 1) % currentAuroraParams.colors.length]),
        map(i, 0, numLayers - 1, 0, 1)
      );

      // Adjust alpha for the glow effect: outer passes are more transparent
      let alphaValue = map(glowPass, 0, 4, 20, 100);
      auroraColor.setAlpha(alphaValue);
      stroke(auroraColor);

      // Adjust stroke weight based on OpenAI's intensity and glow pass
      let baseStrokeWeight = map(currentAuroraParams.intensity, 'low', 'high', 2, 8);
      let glowStrokeWeight = baseStrokeWeight + map(glowPass, 0, 4, 0, 5);
      strokeWeight(glowStrokeWeight);

      beginShape();
      // Add a control point before the first vertex and after the last for smoother curves
      // This ensures the curve starts and ends cleanly at the canvas edges
      curveVertex(0, layerY + reactivity + noise(noiseOffset, i * noiseScale + yOffset) * 100);

      // Draw the main curve points
      for (let x = 0; x <= width; x += 20) {
        let noiseVal = noise(x * noiseScale + noiseOffset, i * noiseScale + yOffset);
        let y = layerY + reactivity + noiseVal * 100; // Apply Perlin noise for wave shape
        curveVertex(x, y);
      }
      curveVertex(width, layerY + reactivity + noise(width * noiseScale + noiseOffset, i * noiseScale + yOffset) * 100);
      endShape();
    }
  }

  noiseOffset += 0.005; // Increment noise offset for horizontal flow
  yOffset += 0.001; // Increment y offset for slight vertical variation over time
}
Line-by-line explanation (15 lines)

🔧 Subcomponents:

for-loop Particle Update & Cull for (let i = particles.length - 1; i >= 0; i--) {

Updates and draws every particle, looping backwards so removing finished particles with splice() doesn't skip elements

conditional Particle Respawn Chance if (random(1) < 0.1 && particles.length < particleCount) {

Randomly (10% chance per frame) adds a new particle if the count has dropped below the target

for-loop Glow Pass Loop for (let glowPass = 0; glowPass < 5; glowPass++) {

Redraws every layer 5 times with increasing transparency and thickness to fake a soft glow

for-loop Aurora Layer Loop for (let i = 0; i < numLayers; i++) {

Draws one ribbon of aurora per layer, each at a different vertical position and color blend

for-loop Wave Point Loop for (let x = 0; x <= width; x += 20) {

Samples Perlin noise every 20 pixels across the screen width to build the wavy shape of a single aurora curve

background(0); // Clear the background each frame
Paints the whole canvas black before drawing anything new, which erases the previous frame instead of leaving trails
let micLevel = amplitude.getLevel();
Reads the current microphone volume as a number roughly between 0 (silent) and 1 (loud)
let reactivity = map(micLevel, 0, 1, 0, 50);
Converts the mic volume into a pixel offset from 0 to 50, so louder sound pushes the waves further down
noFill(); // Aurora waves should not be filled
Ensures the wavy shapes are drawn as outlines only, not solid filled polygons
strokeJoin(ROUND); // Makes curve connections smoother
Rounds the corners where line segments meet, avoiding sharp, jagged joints along the curve
let layerY = height - (i + 1) * layerSeparation;
Calculates the base vertical position for this layer, stacking layers from the bottom of the screen upward
let auroraColor = lerpColor(color(...), color(...), map(i, 0, numLayers - 1, 0, 1));
Blends between two of the AI-selected colors based on the layer's position, so colors gradually shift across the aurora
let alphaValue = map(glowPass, 0, 4, 20, 100);
Makes each of the 5 glow passes progressively more opaque, so overlapping semi-transparent strokes build up a soft glow
let baseStrokeWeight = map(currentAuroraParams.intensity, 'low', 'high', 2, 8);
Attempts to turn the AI's intensity word ('low'/'medium'/'high') into a stroke thickness between 2 and 8 pixels
beginShape();
Starts defining a custom shape that will be built out of curveVertex() points
curveVertex(0, layerY + reactivity + noise(noiseOffset, i * noiseScale + yOffset) * 100);
Adds an extra control point at the very left edge so the smooth curve starts cleanly instead of looking cut off
for (let x = 0; x <= width; x += 20) { ... curveVertex(x, y); }
Walks across the canvas in 20-pixel steps, using 2D Perlin noise (based on x position and layer index) to decide each point's height, then adds it to the curve
endShape();
Finishes and renders the curve shape defined by all the curveVertex() calls
noiseOffset += 0.005;
Nudges the noise sampling position forward each frame, which makes the wave pattern appear to flow horizontally over time
yOffset += 0.001;
Slowly shifts the noise field vertically too, so the wave pattern doesn't repeat identically forever

windowResized()

windowResized() is a special p5.js callback that fires automatically on browser resize. Without recalculating layerSeparation here, the aurora layers would stay spaced for the old window size and look wrong after resizing.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  layerSeparation = height / (numLayers + 2); // Recalculate layer separation on resize
}
Line-by-line explanation (2 lines)
resizeCanvas(windowWidth, windowHeight);
Automatically called by p5.js whenever the browser window changes size; this resizes the canvas to match the new window dimensions
layerSeparation = height / (numLayers + 2);
Recomputes the vertical spacing between aurora layers so they still fit nicely within the new canvas height

Particle (class)

This is a classic p5.js particle pattern: a class with a constructor for randomized starting state, an update() method for per-frame physics, a show() method for drawing, and a finished() method that lets the owning array know when to remove it. Encapsulating this logic in a class keeps draw() clean even with hundreds of particles.

🔬 The fade rate is hardcoded as -2 per frame. What happens if you change it to -10 (particles vanish almost instantly) or -0.5 (they linger much longer)?

  update() {
    this.y -= this.vy;
    this.alpha -= 2; // Fade out over time
  }
class Particle {
  constructor() {
    this.x = random(width); // Random horizontal position
    this.y = random(height, height + 100); // Start below the canvas
    this.vy = random(0.5, 2); // Random upward velocity
    this.alpha = 255; // Initial alpha (opaque)
    this.size = random(1, 4); // Random size
  }

  // Check if the particle is off-screen
  finished() {
    return this.y < -10;
  }

  // Update particle's position and fade
  update() {
    this.y -= this.vy;
    this.alpha -= 2; // Fade out over time
  }

  // Draw the particle
  show() {
    noStroke();
    fill(255, this.alpha); // White, fading particle
    circle(this.x, this.y, this.size);
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

conditional Off-screen Check return this.y < -10;

Tells draw() this particle has floated above the top of the canvas and should be removed

this.x = random(width);
Picks a random horizontal starting position anywhere across the canvas width
this.y = random(height, height + 100);
Starts the particle just below the visible canvas so it drifts up into view rather than popping in
this.vy = random(0.5, 2);
Gives the particle a random upward speed between 0.5 and 2 pixels per frame
this.alpha = 255;
Starts the particle fully opaque
this.size = random(1, 4);
Gives the particle a random diameter between 1 and 4 pixels, so they vary slightly in size
return this.y < -10;
Returns true once the particle has floated above the top edge of the canvas, signaling it's safe to delete
this.y -= this.vy;
Moves the particle upward each frame by its velocity
this.alpha -= 2;
Gradually reduces opacity each frame so the particle fades out as it rises
fill(255, this.alpha);
Sets the fill color to white with the particle's current (fading) alpha value
circle(this.x, this.y, this.size);
Draws the particle as a small circle at its current position and size

setupSpeechRecognition()

This function bridges p5.js with the browser's built-in Web Speech API, which is separate from p5.sound. It demonstrates event-driven programming: rather than polling for speech in draw(), you register callbacks (onstart, onresult, onerror, onend) that the browser calls automatically as speech events occur.

function setupSpeechRecognition() {
  if ('webkitSpeechRecognition' in window) {
    speechRecognition = new webkitSpeechRecognition();
    speechRecognition.continuous = true; // Keep listening after a pause
    speechRecognition.interimResults = true; // Show results as they are being spoken
    speechRecognition.lang = 'en-US'; // Set language

    // Event handler when recognition starts
    speechRecognition.onstart = () => {
      isListening = true;
      micButton.innerText = 'Stop Listening';
      statusElement.innerText = 'Status: Listening... Speak now!';
    };

    // Event handler for recognition results
    speechRecognition.onresult = (event) => {
      let finalTranscript = '';
      let interimTranscript = '';

      // Loop through results to separate final and interim transcripts
      for (let i = event.resultIndex; i < event.results.length; ++i) {
        if (event.results[i].isFinal) {
          finalTranscript += event.results[i][0].transcript;
        } else {
          interimTranscript += event.results[i][0].transcript;
        }
      }

      // Display the current transcript (final or interim)
      transcriptElement.innerText = `Transcript: ${finalTranscript || interimTranscript}`;

      // If a final transcript is available, send it to OpenAI
      if (finalTranscript) {
        statusElement.innerText = 'Status: Processing speech with OpenAI...';
        callOpenAI(finalTranscript).then(result => {
          if (result) {
            currentAuroraParams = result; // Update global aurora parameters
            // Speak the description using OpenAI TTS
            callOpenAITextToSpeech(result.description);
            // statusElement update will be handled by callOpenAITextToSpeech
          } else {
            statusElement.innerText = 'Status: OpenAI response error. Try again.';
          }
        });
        // Clear transcript after sending to OpenAI
        transcriptElement.innerText = 'Transcript: ';
      }
    };

    // Event handler for recognition errors
    speechRecognition.onerror = (event) => {
      console.error("Speech Recognition Error:", event.error);
      statusElement.innerText = `Status: Speech Recognition Error - ${event.error}`;
      isListening = false;
      micButton.innerText = 'Start Listening';
    };

    // Event handler when recognition ends
    speechRecognition.onend = () => {
      console.log("Speech Recognition Ended.");
      isListening = false;
      micButton.innerText = 'Start Listening';
      statusElement.innerText = 'Status: Idle';
      // Optionally restart listening if continuous is desired but it stopped
      // if (shouldContinueListening) speechRecognition.start();
    };

    // Attach click listener to the mic button
    micButton.onclick = () => {
      if (isListening) {
        speechRecognition.stop();
      } else {
        speechRecognition.start();
      }
    };
  } else {
    // Disable button and show message if Speech Recognition is not supported
    micButton.disabled = true;
    micButton.innerText = 'Speech Recognition Not Supported';
    statusElement.innerText = 'Status: Speech Recognition Not Supported in this browser. Please use Chrome or Edge.';
    console.warn("webkitSpeechRecognition not supported in this browser. Please use Chrome or Edge.");
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

conditional Browser Support Check if ('webkitSpeechRecognition' in window) {

Only sets up voice recognition if the browser actually supports the webkitSpeechRecognition API, otherwise disables the button

for-loop Result Splitting Loop for (let i = event.resultIndex; i < event.results.length; ++i) {

Separates newly recognized speech into 'final' (confirmed) and 'interim' (still-being-spoken) transcript text

conditional Final Transcript Handler if (finalTranscript) {

Only sends text to OpenAI once the browser is confident the sentence is complete, avoiding wasted API calls on partial speech

conditional Mic Button Toggle if (isListening) {

Makes a single button start or stop listening depending on the current state

if ('webkitSpeechRecognition' in window) {
Checks whether this browser (Chrome/Edge) supports the speech recognition feature before trying to use it
speechRecognition.continuous = true;
Tells the recognizer to keep listening across pauses instead of stopping after the first phrase
speechRecognition.interimResults = true;
Enables live, in-progress transcription so the transcript updates word-by-word as you speak, not just at the end
speechRecognition.onstart = () => { ... };
Defines what happens the moment listening begins: updates the flag, button label, and status text
for (let i = event.resultIndex; i < event.results.length; ++i) { ... }
Loops through all new speech results since the last event, sorting each chunk into either the confirmed 'final' string or the still-changing 'interim' string
transcriptElement.innerText = `Transcript: ${finalTranscript || interimTranscript}`;
Shows whichever transcript is available on screen - prefers the final one, falls back to interim while still speaking
if (finalTranscript) { ... }
Only triggers the OpenAI call once a sentence is confirmed complete, to avoid spamming the API with partial words
callOpenAI(finalTranscript).then(result => { ... });
Sends the transcribed sentence to the custom callOpenAI() function and, once it resolves, updates the global aurora parameters and speaks the new description
speechRecognition.onerror = (event) => { ... };
Handles recognition failures (e.g. no mic permission) by resetting the UI and showing the error
speechRecognition.onend = () => { ... };
Runs whenever the recognizer stops (either manually or because the browser timed out), resetting the button text and status
micButton.onclick = () => { if (isListening) { speechRecognition.stop(); } else { speechRecognition.start(); } };
Wires the button to toggle between starting and stopping voice recognition

callOpenAITextToSpeech()

This function shows how to consume a binary (audio) API response instead of JSON: using response.blob() and feeding it directly into p5.SoundFile lets you play server-generated audio without ever saving a file to disk.

async function callOpenAITextToSpeech(textToSpeak) {
  // If there's an audio playing, stop it first
  if (currentAudioFile && currentAudioFile.isPlaying()) {
    currentAudioFile.stop();
  }

  const apiKey = getApiKey();
  const endpoint = 'https://api.openai.com/v1/audio/speech';

  try {
    statusElement.innerText = 'Status: Generating speech with OpenAI...';
    const response = await fetch(endpoint, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        model: 'tts-1', // You can choose 'tts-1' or 'tts-1-hd' for higher quality
        voice: 'alloy', // Choose from 'alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'
        input: textToSpeak
      })
    });

    if (!response.ok) {
      const errorData = await response.text();
      throw new Error(`OpenAI TTS API error: ${response.status} - ${errorData}`);
    }

    const audioBlob = await response.blob();
    statusElement.innerText = 'Status: Loading OpenAI speech...';

    // Create a new p5.SoundFile instance from the blob
    currentAudioFile = new p5.SoundFile(audioBlob, () => {
      // Callback when audio is loaded
      console.log("OpenAI audio loaded successfully.");
      statusElement.innerText = 'Status: Playing OpenAI speech...';
      currentAudioFile.play();
      currentAudioFile.onended(() => {
        statusElement.innerText = 'Status: OpenAI speech ended. Ready for next command.';
      });
    }, (error) => {
      // Error callback during loading
      console.error("Error loading p5.SoundFile from blob:", error);
      statusElement.innerText = `Status: Error loading speech - ${error}`;
      currentAudioFile = null; // Clear reference if loading failed
    });

  } catch (error) {
    console.error("Error calling OpenAI TTS API:", error);
    statusElement.innerText = `Status: API Error (TTS) - ${error.message}`;
    currentAudioFile = null; // Clear reference if API call failed
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Stop Existing Audio if (currentAudioFile && currentAudioFile.isPlaying()) {

Prevents two AI voice clips from overlapping by stopping any currently playing narration first

conditional Response Error Check if (!response.ok) {

Detects a failed HTTP request to OpenAI and throws a descriptive error instead of silently failing

if (currentAudioFile && currentAudioFile.isPlaying()) { currentAudioFile.stop(); }
Stops any previous AI narration still playing so new speech doesn't overlap with it
const apiKey = getApiKey();
Decodes the obfuscated OpenAI API key to use it in the request header
const response = await fetch(endpoint, { method: 'POST', ... });
Sends a POST request to OpenAI's text-to-speech endpoint with the model, chosen voice, and text to convert into audio, then waits (await) for the response
if (!response.ok) { ... throw new Error(...); }
Checks if the HTTP request failed (e.g. bad API key, rate limit) and throws an error with details if so
const audioBlob = await response.blob();
Reads the response body as raw binary audio data (a Blob) rather than text or JSON
currentAudioFile = new p5.SoundFile(audioBlob, () => { ... }, (error) => { ... });
Wraps the audio blob in a p5.SoundFile object; the second argument is a success callback that plays the audio once it's ready, and the third is an error callback if loading fails
currentAudioFile.play();
Starts playback of the AI-generated narration once it's finished loading
currentAudioFile.onended(() => { ... });
Registers a callback that updates the status text once the narration finishes playing

callOpenAI()

This function shows the modern pattern for getting structured data out of an LLM: writing a very explicit prompt describing the exact JSON shape you want, and using OpenAI's response_format: { type: 'json_object' } option so the model's reply can be trusted to be parseable JSON.

async function callOpenAI(transcript) {
  const apiKey = getApiKey();
  const endpoint = 'https://api.openai.com/v1/chat/completions';
  // Craft a precise prompt to guide OpenAI to produce the desired JSON format
  const prompt = `Based on the following audio transcript: "${transcript}", generate a JSON object describing an aurora.
  The JSON should have three keys:
  1.  'colors': An array of 3-5 hex color codes (e.g., ['#FF0000', '#00FF00']).
  2.  'intensity': A string, either 'low', 'medium', or 'high'.
  3.  'description': A short, poetic sentence describing the aurora scene.`;

  try {
    const response = await fetch(endpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${apiKey}`
      },
      body: JSON.stringify({
        model: 'gpt-3.5-turbo', // Using gpt-3.5-turbo, which supports response_format
        messages: [{
          role: 'user',
          content: prompt
        }],
        response_format: { type: "json_object" } // Request JSON object directly
      })
    });

    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(`OpenAI API error: ${response.status} - ${errorData.error.message}`);
    }

    const data = await response.json();
    const content = data.choices[0].message.content; // The JSON string is in the content
    return JSON.parse(content); // Parse the JSON string into a JavaScript object
  } catch (error) {
    console.error("Error calling OpenAI API:", error);
    document.getElementById('status').innerText = `Status: API Error - ${error.message}`;
    return null;
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Chat API Error Check if (!response.ok) {

Detects a failed chat completion request and surfaces OpenAI's own error message

const prompt = `Based on the following audio transcript: ...`;
Builds a text prompt instructing GPT to return a JSON object with specific keys (colors, intensity, description) based on what you said
const response = await fetch(endpoint, { method: 'POST', ... });
Sends the prompt to OpenAI's chat completions endpoint using the gpt-3.5-turbo model and waits for a reply
response_format: { type: "json_object" }
Tells OpenAI to guarantee the reply is valid JSON text rather than freeform prose, making it safe to parse
if (!response.ok) { ... throw new Error(...); }
Checks for a failed HTTP request and throws a descriptive error using OpenAI's own error message if something went wrong
const content = data.choices[0].message.content;
Digs into OpenAI's response structure to grab the actual text (a JSON string) that the model generated
return JSON.parse(content);
Converts that JSON string into a real JavaScript object with .colors, .intensity, and .description properties, which becomes the new currentAuroraParams
return null;
If anything throws an error along the way, the function returns null so the caller knows the request failed instead of crashing

📦 Key Variables

mic object

Holds the p5.AudioIn object representing the microphone input stream

let mic = new p5.AudioIn();
amplitude object

A p5.Amplitude analyzer used to measure the current loudness of the mic input each frame

let amplitude = new p5.Amplitude();
speechRecognition object

Holds the browser's webkitSpeechRecognition instance used to transcribe spoken words into text

let speechRecognition;
micButton object

Reference to the HTML button element that starts/stops voice listening

let micButton = document.getElementById('micButton');
transcriptElement object

Reference to the HTML element that displays the live speech transcript

let transcriptElement = document.getElementById('transcript');
statusElement object

Reference to the HTML element that shows status messages like 'Listening...' or errors

let statusElement = document.getElementById('status');
isListening boolean

Tracks whether speech recognition is currently active, used to toggle the mic button label

let isListening = false;
currentAudioFile object

Holds the currently loaded/playing p5.SoundFile generated by OpenAI's text-to-speech API

let currentAudioFile = null;
currentAuroraParams object

Stores the active colors, intensity, and description used to draw and narrate the aurora; replaced whenever OpenAI returns a new result

let currentAuroraParams = { colors: ['#00FFFF'], intensity: 'medium', description: '...' };
noiseScale number

Controls how zoomed-in the Perlin noise sampling is, affecting how smooth or turbulent the aurora waves look

let noiseScale = 0.005;
noiseOffset number

A horizontally scrolling offset added to noise sampling each frame, making the waves appear to flow sideways over time

let noiseOffset = 0;
yOffset number

A slowly increasing offset added to noise sampling to prevent the wave pattern from looping identically forever

let yOffset = 0;
numLayers number

The number of stacked aurora ribbons drawn each frame, controlling visual depth

let numLayers = 10;
layerSeparation number

The vertical pixel gap between aurora layers, calculated from canvas height and numLayers

let layerSeparation;
particles array

Stores all active Particle objects currently drifting upward on screen

let particles = [];
particleCount number

The target maximum number of particles kept alive at once

let particleCount = 200;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG draw() - baseStrokeWeight calculation

map(currentAuroraParams.intensity, 'low', 'high', 2, 8) passes string values ('low'/'high') as the input range to p5's map(), which expects numeric start/stop values. This produces unreliable or NaN-ish stroke weights rather than the intended low/medium/high thickness scaling.

💡 Convert intensity to a number first, e.g. const intensityMap = {low: 0, medium: 0.5, high: 1}; let baseStrokeWeight = map(intensityMap[currentAuroraParams.intensity] ?? 0.5, 0, 1, 2, 8);

STYLE getApiKey() / global encoded key

The OpenAI API key is only lightly obfuscated with Base64 + single-byte XOR directly in client-side JavaScript, so anyone can open devtools, call getApiKey(), and steal the real key for their own use (and your billing).

💡 Move all OpenAI calls (chat completions and TTS) to a small backend proxy server that holds the real API key, and have the sketch call your own server instead of api.openai.com directly.

PERFORMANCE draw() - glow pass and layer loops

Every frame recomputes 5 glow passes x numLayers x (width/20) curveVertex points plus a lerpColor() and color object per layer per pass - on large/high-resolution windows this is a lot of repeated work and can drop frame rate.

💡 Pre-compute the per-layer lerpColor() once per layer per frame (outside the glowPass loop) and reuse it across passes, or reduce glowPass count / increase the x step on lower-end devices.

FEATURE setupSpeechRecognition() - onend handler

The comment '// Optionally restart listening if continuous is desired but it stopped' is left unimplemented, so if the browser auto-stops recognition after a silence timeout, the user has to manually click 'Start Listening' again even though continuous mode was requested.

💡 Track a 'shouldContinueListening' flag set by the button click, and call speechRecognition.start() again inside onend when that flag is true and the stop wasn't user-initiated.

🔄 Code Flow

Code flow showing getapikey, setup, draw, windowresized, particle, setupspeechrecognition, callopenaitexttospeech, callopenai

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> particle-init-loop[particle-init-loop] particle-init-loop --> particle-update-loop[particle-update-loop] particle-update-loop --> particle-respawn[particle-respawn] particle-update-loop --> glow-pass-loop[glow-pass-loop] glow-pass-loop --> layer-loop[layer-loop] layer-loop --> curve-x-loop[curve-x-loop] particle-update-loop --> finished-check[finished-check] draw --> windowresized[windowresized] draw --> setupspeechrecognition[setupspeechrecognition] setupspeechrecognition --> browser-support-check[browser-support-check] browser-support-check -->|Supported| mic-button-toggle[mic-button-toggle] browser-support-check -->|Not Supported| stop-existing-audio[stop-existing-audio] setupspeechrecognition --> transcript-split-loop[transcript-split-loop] transcript-split-loop --> final-transcript-check[final-transcript-check] final-transcript-check --> callopenai[callopenai] callopenai --> response-ok-check[response-ok-check] response-ok-check --> chat-response-check[chat-response-check] click setup href "#fn-setup" click draw href "#fn-draw" click particle-init-loop href "#sub-particle-init-loop" click particle-update-loop href "#sub-particle-update-loop" click particle-respawn href "#sub-particle-respawn" click glow-pass-loop href "#sub-glow-pass-loop" click layer-loop href "#sub-layer-loop" click curve-x-loop href "#sub-curve-x-loop" click finished-check href "#sub-finished-check" click windowresized href "#fn-windowresized" click setupspeechrecognition href "#fn-setupspeechrecognition" click browser-support-check href "#sub-browser-support-check" click mic-button-toggle href "#sub-mic-button-toggle" click stop-existing-audio href "#sub-stop-existing-audio" click transcript-split-loop href "#sub-transcript-split-loop" click final-transcript-check href "#sub-final-transcript-check" click callopenai href "#fn-callopenai" click response-ok-check href "#sub-response-ok-check" click chat-response-check href "#sub-chat-response-check"

❓ Frequently Asked Questions

What kind of visuals does the Voice Aurora sketch create?

The Voice Aurora sketch generates stunning aurora-like visuals that respond to the user's voice, producing flowing colorful waves that mimic the described themes.

How can users interact with the Voice Aurora AI Sound Visualizer?

Users can interact by speaking specific phrases like 'peaceful ocean' or 'burning fire,' prompting the AI to create corresponding visual effects and describe the scene.

What creative coding techniques are showcased in the Voice Aurora sketch?

This sketch demonstrates techniques in audio visualization, speech recognition, and dynamic color generation using p5.js to create an immersive interactive experience.

Preview

Voice Aurora - AI Sound Visualizer - xelsed.ai - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Voice Aurora - AI Sound Visualizer - xelsed.ai - Code flow showing getapikey, setup, draw, windowresized, particle, setupspeechrecognition, callopenaitexttospeech, callopenai
Code Flow Diagram