Teachable Machine > P5.js > Device (web serial)

This sketch connects a Teachable Machine image classifier to a web browser, processes live webcam video to identify objects or subjects, and sends classification results via Web Serial to microcontroller devices like Arduino or Micro:bit. It bridges machine learning and hardware by creating a real-time visual classification system with two-way serial communication.

🧪 Try This!

Experiment with the code by making these changes:

  1. Change the model baud rate — If your Arduino uses 115200 baud instead of 9600, change the baud rate in connectSerial() to match—the device won't respond if baud rates don't match.
  2. Add a custom label mapping — If your Teachable Machine model detects a new class like 'cat', add a new case in the switch statement to map it to a numeric code (e.g., 4).
  3. Speed up data sending — Lower the send interval so classification updates reach the device more frequently—useful for real-time interactive installations.
  4. Change the video preview size — Modify the height fraction in draw() to make the video larger or smaller—try height / 3 for a smaller preview or height to fill most of the screen.
  5. Detect a device acknowledgment code — Change the acknowledgment detection from '1' to a different code your device sends (e.g., '2' for device ready)—the sketch will display a custom message.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch creates a complete machine-learning-to-hardware pipeline: it loads a custom Teachable Machine classifier from a URL, processes your webcam video frame-by-frame to detect objects or people, displays the top classification with confidence percentage, and sends numeric codes over a serial connection to physical devices like Arduino or Micro:bit. The project demonstrates ml5.js image classification, Web Serial API for hardware communication, and responsive canvas layout—three powerful tools for creative technologists building interactive installations and IoT projects.

The code is organized into three main phases. First, setup() creates the canvas and prepares input fields for the Teachable Machine model URL. Second, once a model URL is loaded, the sketch switches to 'classifier' state, continuously classifying video frames with ml5.js and mapping detected labels to numeric codes. Third, the Web Serial module handles bidirectional communication with microcontrollers—sending classification numbers and receiving acknowledgment data. By reading this sketch you will learn how to load external ML models, process video streams, manage application state, and integrate browser-to-hardware communication.

⚙️ How It Works

  1. When the sketch loads, setup() creates a responsive canvas and displays a prompt asking the user to paste a Teachable Machine model URL into an input field. The webcam is initialized via createCapture() with a callback (videoReady) to detect when video is ready.
  2. The user enters a model URL and clicks OK. This triggers loadModelFromInput(), which initializes the ml5.js image classifier with the provided URL. When the model finishes loading, modelLoaded() is called—if both the model and video are ready, classifyVideo() begins.
  3. classifyVideo() runs in a loop, passing each video frame to the classifier. The ml5.js classifier returns an array of prediction objects with label and confidence scores. The top prediction (highest confidence) is stored in currentClassification.
  4. Inside draw(), the sketch displays the live video feed on the left and shows the current label and confidence percentage on the right. A switch statement maps recognized labels (like 'ihminen', 'koira', 'kissa') to numeric codes (1, 2, 3).
  5. Every 3 seconds (controlled by classificationSendInterval), if the label has changed and a mapped code exists, writeSerial() sends the numeric code plus a newline to the connected device. readSerial() continuously listens for incoming data from the microcontroller and displays it on screen.
  6. The Web Serial connection (connectSerial, disconnectSerial) is established via a button. Once connected at 9600 baud, TextEncoderStream and TextDecoderStream handle the encoding/decoding of text data to and from the device.

🎓 Concepts You'll Learn

Machine learning image classificationWeb Serial API for hardware communicationApplication state managementAsynchronous callbacks and promisesVideo stream processingResponsive canvas layout

📝 Code Breakdown

preload()

preload() runs once before setup() and is typically used to load images, fonts, or files. In this sketch, model loading happens later (when the user clicks OK) rather than here, so preload() is nearly empty. This design lets users choose their model URL dynamically.

function preload() {
  console.log('preload() started.');
  // We won't load the model here anymore.
  // Model loading will happen when the user enters a URL and clicks OK.
  console.log('preload() finished.');
}
Line-by-line explanation (2 lines)
console.log('preload() started.');
Logs to the browser console to track when preload begins—useful for debugging
console.log('preload() finished.');
Logs when preload finishes. The comment explains that model loading is deferred until the user provides a URL

setup()

setup() runs once when the sketch starts. It initializes the canvas, webcam, UI elements, and checks for browser capabilities. The key insight here is that the model is NOT loaded in preload()—instead, users paste a URL and trigger loading manually, making the sketch flexible for different ML models.

function setup() {
  console.log('setup() started.');
  createCanvas(windowWidth, windowHeight); // Create a responsive canvas
  
  // Create the video capture from the webcam
  video = createCapture(VIDEO, videoReady); // Add a callback for video readiness
  video.hide(); // Hide the HTML video element as we'll draw it on the canvas
  console.log('Video capture object created in setup().');
  // Initialize custom ready flag to false
  video.ready = false;

  // Initial display message asking for model URL
  textSize(32);
  textAlign(CENTER, CENTER);
  fill(0);
  text('Please enter your Teachable Machine model URL below', width / 2, height / 2);

  // --- Model URL Input Setup ---
  modelURLInput = createInput();
  modelURLInput.attribute('placeholder', 'Enter Teachable Machine model URL here');
  modelURLInput.style('font-size', '16px');
  modelURLInput.style('padding', '8px');
  // FIX: videomargin is now defined, so this line will work.
  modelURLInput.style('width', 'calc(100% - 100px - 2 * ' + videoMargin + 'px)'); // Adjust width
  modelURLInput.style('border', '1px solid #ccc');
  modelURLInput.style('border-radius', '4px');

  modelURLLoadButton = createButton('OK');
  modelURLLoadButton.mousePressed(loadModelFromInput);
  modelURLLoadButton.style('font-size', '16px');
  modelURLLoadButton.style('padding', '10px 15px');
  modelURLLoadButton.style('background-color', '#007BFF');
  modelURLLoadButton.style('color', 'white');
  modelURLLoadButton.style('border', 'none');
  modelURLLoadButton.style('border-radius', '5px');
  modelURLLoadButton.style('cursor', 'pointer');

  // --- Web Serial Setup ---
  // Create p5 DOM elements for serial status and received data
  serialStatusP = createP('Web Serial: Not connected.');
  serialStatusP.style('font-size', '18px');
  serialStatusP.style('color', '#333');
  serialStatusP.style('font-family', 'sans-serif'); // Add a standard font for consistency

  receivedSerialDataP = createP('Received: '); // Renamed for clarity
  receivedSerialDataP.style('font-size', '18px');
  receivedSerialDataP.style('color', '#333');
  receivedSerialDataP.style('font-family', 'sans-serif'); // Add a standard font for consistency

  // Check if Web Serial API is supported
  if ('serial' in navigator) {
    // Create the "Connect Serial Port" button
    serialConnectButton = createButton('Connect Serial Port');
    serialConnectButton.mousePressed(connectSerial); // Call connectSerial() when button is pressed
    serialConnectButton.style('font-size', '16px');
    serialConnectButton.style('padding', '10px 15px');
    serialConnectButton.style('background-color', '#4CAF50');
    serialConnectButton.style('color', 'white');
    serialConnectButton.style('border', 'none');
    serialConnectButton.style('border-radius', '5px');
    serialConnectButton.style('cursor', 'pointer');

    // Add event listener for port disconnection (e.g., USB cable unplugged)
    navigator.serial.addEventListener('disconnect', portDisconnected);

  } else {
    // Display an error message if Web Serial is not supported
    serialStatusP.html('Web Serial API not supported in this browser. Try Chrome/Edge.');
    serialStatusP.style('color', 'red');
  }

  // Set initial visibility for UI elements
  setClassifierUIVisibility(false); // Hide classifier elements initially

  // Call windowResized once to set initial positions correctly
  windowResized();
  console.log('setup() finished. appState:', appState);
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

function-call Video Capture Initialization video = createCapture(VIDEO, videoReady);

Creates a live video stream from the webcam and calls videoReady() when the stream is ready

conditional Web Serial API Support Check if ('serial' in navigator)

Checks if the browser supports the Web Serial API; if not, displays a fallback message

createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire window—responsive to screen size
video = createCapture(VIDEO, videoReady);
Starts capturing video from the user's webcam and calls videoReady() when the video stream is ready
video.hide();
Hides the default HTML video element so we can draw it ourselves onto the canvas using image()
video.ready = false;
Creates a custom flag to track whether the video stream has loaded—we'll check this before trying to classify
modelURLInput = createInput();
Creates a text input field where the user will paste their Teachable Machine model URL
if ('serial' in navigator)
Checks if the Web Serial API exists in the browser (Chrome, Edge, Opera support it; Firefox and Safari do not)
navigator.serial.addEventListener('disconnect', portDisconnected);
Listens for when the USB cable is physically unplugged so we can clean up gracefully
setClassifierUIVisibility(false);
Hides the classifier UI elements (serial status, received data, connect button) until a model is loaded
windowResized();
Calls windowResized() to position input fields and buttons correctly—important for responsive layout

videoReady()

videoReady() is a callback—p5.js calls this function automatically when the webcam stream is ready. This separates the moment we request video (in setup) from the moment we can actually use it. The dual-readiness check (both video.ready and classifier.ready) prevents crashes by ensuring neither component tries to process before the other is available.

function videoReady() {
  console.log('Video capture is ready! (videoReady() callback executed)'); // Critical log
  video.ready = true; // Custom flag to indicate video is ready
  console.log('Video element readyState:', video.elt.readyState); // Check HTML video element state
  console.log('Video dimensions: ', video.width, 'x', video.height); // Log actual video dimensions

  // If model is already loaded and we're in classifier state, start classifying
  if (appState === 'classifier' && classifier && classifier.ready) {
    console.log('Video ready and classifier ready. Starting classification from videoReady().');
    classifyVideo();
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Classifier Readiness Check if (appState === 'classifier' && classifier && classifier.ready)

Ensures both video and model are ready before beginning classification

video.ready = true;
Sets our custom flag to true, signaling that the video stream is now available for processing
console.log('Video element readyState:', video.elt.readyState);
Logs the native HTML video element's readyState (0-4) for debugging—helps verify the video is truly loaded
console.log('Video dimensions: ', video.width, 'x', video.height);
Logs the actual pixel dimensions of the video stream—useful for calculating preview sizes and aspect ratios
if (appState === 'classifier' && classifier && classifier.ready)
Checks three conditions: we're in classifier mode, the classifier object exists, and the ML model is loaded—only then should we start classifying
classifyVideo();
Begins the classification loop if all readiness conditions are met

setClassifierUIVisibility()

This helper function encapsulates the logic for toggling two sets of UI elements. When appState is 'input', users see the model URL input field. When appState is 'classifier', they see serial connection controls and classification results. Using a helper prevents duplicating visibility logic throughout the code.

/**
 * Hides/shows UI elements belonging to the classifier view.
 * @param {boolean} isVisible - True to show, false to hide.
 */
function setClassifierUIVisibility(isVisible) {
  const displayStyle = isVisible ? 'block' : 'none';
  
  if (serialStatusP) serialStatusP.style('display', displayStyle);
  if (receivedSerialDataP) receivedSerialDataP.style('display', displayStyle);
  if (serialConnectButton) serialConnectButton.style('display', displayStyle);

  // Input view elements are visible when classifier elements are hidden
  if (modelURLInput) modelURLInput.style('display', isVisible ? 'none' : 'block');
  if (modelURLLoadButton) modelURLLoadButton.style('display', isVisible ? 'none' : 'block');
  console.log('Classifier UI visibility set to:', isVisible);
}
Line-by-line explanation (3 lines)
const displayStyle = isVisible ? 'block' : 'none';
Converts the boolean isVisible into a CSS display value—'block' to show, 'none' to hide
if (serialStatusP) serialStatusP.style('display', displayStyle);
Only styles serialStatusP if it exists, then sets its display to the calculated displayStyle
if (modelURLInput) modelURLInput.style('display', isVisible ? 'none' : 'block');
Shows input fields when isVisible is false (input mode), hides them when true (classifier mode)—the opposite of serial elements

loadModelFromInput()

loadModelFromInput() is triggered by the OK button. It reads the user's input, validates it, and calls ml5.imageClassifier() to load the model asynchronously. The modelLoaded callback handles the result. By disabling the button during loading, we prevent the user from accidentally triggering multiple loads.

function loadModelFromInput() {
  console.log('loadModelFromInput() called.');
  const newModelURL = modelURLInput.value().trim();
  if (newModelURL) {
    modelURL = newModelURL;
    console.log('Attempting to load model from:', modelURL); // Log attempt
    currentClassification.label = 'Loading model...';
    classifier = ml5.imageClassifier(modelURL, modelLoaded); // Re-initialize classifier
    classifier.ready = false; // Reset ready flag
    modelURLInput.attribute('disabled', ''); // Disable input during loading
    modelURLLoadButton.attribute('disabled', ''); // Disable button during loading
  } else {
    alert('Please enter a valid Teachable Machine model URL.');
    console.warn('No model URL entered.');
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional URL Validation if (newModelURL)

Checks that the user actually entered something in the input field before attempting to load

const newModelURL = modelURLInput.value().trim();
Reads the text from the input field and removes leading/trailing whitespace—trim() prevents errors from accidental spaces
if (newModelURL)
Only proceeds if the input is not empty—alerts the user if they click OK without pasting a URL
classifier = ml5.imageClassifier(modelURL, modelLoaded);
Initializes the ml5.js image classifier with the provided URL and passes modelLoaded as a callback—ml5 will call modelLoaded() when the model finishes loading
classifier.ready = false;
Resets the ready flag to false, ensuring videoReady() and other code won't try to classify until the model is fully loaded
modelURLInput.attribute('disabled', '');
Disables the input field while loading—prevents the user from clicking OK multiple times and starting multiple loads

modelLoaded()

modelLoaded() is the callback that ml5.js invokes when the model either loads successfully or fails. The dual-readiness check here (both classifier.ready and video.ready) ensures we don't start classifying until both the ML model and the webcam are available. The appState switch is a key state-management pattern—it tells draw() to completely change what it displays.

// Modified modelLoaded function to handle errors
function modelLoaded(error) {
  console.log('modelLoaded() callback received.');
  if (error) {
    console.error('Error loading model:', error); // Log the error
    currentClassification.label = 'Model loading failed: ' + error.message;
    // Re-enable input and button so user can try again
    modelURLInput.removeAttribute('disabled');
    modelURLLoadButton.removeAttribute('disabled');
    // Keep appState as 'input'
    console.log('Model loading failed, staying in input state.');
    return; // Stop here if loading failed
  }
  console.log('Model Loaded successfully!'); // Confirm model is loaded
  classifier.ready = true; // Set a custom flag to indicate the model is ready
  currentClassification.label = 'Model Loaded. Starting classification...';
  // Check if video is also ready before starting classification
  if (video && video.ready) {
    console.log('Classifier ready and video ready. Starting classification from modelLoaded().');
    classifyVideo(); // Start classifying once the model is loaded AND video is ready
  } else {
    console.warn('Model loaded, but video not yet ready. Will start classification when video is ready.');
  }
  modelURLInput.removeAttribute('disabled'); // Enable input after loading
  modelURLLoadButton.removeAttribute('disabled'); // Enable button after loading
  appState = 'classifier'; // Switch to classifier view
  setClassifierUIVisibility(true); // Show classifier elements
  windowResized(); // Re-position elements for the classifier view
  console.log('modelLoaded() finished. appState:', appState);
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional Error Handling if (error)

Checks if the model failed to load and displays an error message, re-enabling UI for retry

conditional Video Readiness Check if (video && video.ready)

Verifies the video stream is ready before starting classification

if (error)
ml5.js calls this function with an error object if loading fails—we catch it here to handle gracefully
currentClassification.label = 'Model loading failed: ' + error.message;
Displays the error message on screen so the user knows what went wrong
modelURLInput.removeAttribute('disabled');
Re-enables the input field so the user can paste a different URL and try again
return;
Exits the function early—stops executing the rest of modelLoaded() if there was an error
classifier.ready = true;
Sets our custom ready flag to true, signaling that the model is loaded and can be used for classification
if (video && video.ready)
Checks both that the video object exists AND that its ready flag is true—prevents trying to classify with an unavailable video stream
appState = 'classifier';
Changes the application state from 'input' to 'classifier', which triggers the draw() function to switch its display logic
setClassifierUIVisibility(true);
Hides the URL input field and shows the serial connection controls and classification results
windowResized();
Repositions all UI elements for the classifier layout—input field and button move below the video, serial elements appear

classifyVideo()

classifyVideo() starts the classification loop. It doesn't run just once—instead, every time gotResult() finishes processing a frame, it calls classifyVideo() again. This creates a continuous loop of frame analysis. The setTimeout fallback ensures the loop doesn't start until both video and model are truly ready, preventing errors from race conditions.

function classifyVideo() {
  // Ensure the video is loaded and the model is ready before attempting to classify
  if (video && video.ready && classifier && classifier.ready) {
    console.log('Classifying video frame.');
    // Classify the current video frame and call gotResult when done
    // Reference: https://learn.ml5js.org/docs/#/reference/image-classifier?id=classify
    classifier.classify(video, gotResult);
  } else {
    // If not ready, try again after a short delay
    // This is important if video or classifier isn't ready simultaneously
    console.warn('classifyVideo() called, but video or classifier not ready. Retrying in 100ms.');
    setTimeout(classifyVideo, 100);
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Readiness Guard if (video && video.ready && classifier && classifier.ready)

Ensures both video and classifier are ready before attempting to classify

function-call Retry Loop setTimeout(classifyVideo, 100);

Delays execution by 100ms and tries classifyVideo again—polls until both dependencies are ready

if (video && video.ready && classifier && classifier.ready)
Guards against crashes by checking four things: video exists, video is ready, classifier exists, and classifier is ready
classifier.classify(video, gotResult);
Asks ml5.js to analyze the current video frame—gotResult is the callback that receives predictions
setTimeout(classifyVideo, 100);
If either dependency isn't ready yet, wait 100 milliseconds and call classifyVideo() again—a simple retry loop

gotResult()

gotResult() is ml5.js's callback when classification of a frame finishes. It receives an array of predictions with label/confidence pairs. By sorting and taking the first element, we always display the most confident guess. The recursive call to classifyVideo() at the end creates the feedback loop that drives continuous real-time classification.

🔬 This sorts predictions so the highest confidence is first. What happens if you reverse the sort order—change > to < or swap a and b—which prediction would you see?

  // If no error, results will be an array of objects
  // Each object has a 'label' and 'confidence'
  // Sort results by confidence in descending order to get the top prediction
  results.sort((a, b) => b.confidence - a.confidence);
  const topResult = results[0];
function gotResult(error, results) {
  // If there's an error during classification, log it
  if (error) {
    console.error('Error during classification:', error);
    currentClassification.label = 'Error: ' + error.message;
    currentClassification.confidence = 0;
    // Don't stop classifying, try again
    classifyVideo();
    return;
  }

  // If no error, results will be an array of objects
  // Each object has a 'label' and 'confidence'
  // Sort results by confidence in descending order to get the top prediction
  results.sort((a, b) => b.confidence - a.confidence);
  const topResult = results[0];

  // Update the current classification result
  currentClassification.label = topResult.label;
  currentClassification.confidence = topResult.confidence;

  // Keep classifying the video feed recursively
  classifyVideo();
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Error Handler if (error)

Catches classification errors and displays them while continuing to classify

calculation Sort by Confidence results.sort((a, b) => b.confidence - a.confidence);

Sorts predictions from highest to lowest confidence so the top prediction is accessible

if (error)
Checks if ml5.js encountered an error during classification
currentClassification.label = 'Error: ' + error.message;
Displays the error message on screen instead of stopping—allows the user to see what went wrong
classifyVideo();
Continues classifying despite the error—the next frame might succeed
results.sort((a, b) => b.confidence - a.confidence);
Sorts the array of predictions in descending order by confidence score—highest confidence comes first
const topResult = results[0];
Extracts the top prediction (highest confidence) from the sorted array
currentClassification.label = topResult.label;
Stores the label (e.g., 'ihminen', 'koira') of the top prediction
currentClassification.confidence = topResult.confidence;
Stores the confidence score (0.0 to 1.0) of the top prediction—higher means more certain
classifyVideo();
Recursively calls classifyVideo() to analyze the next frame—creates the continuous classification loop

draw()

draw() is the render loop—it runs 60 times per second. Here it handles two states: showing the input prompt or displaying the live classification. The key insight is the aspect-ratio-preserving calculation: by computing width from height (or vice versa), the video never stretches. The switch statement maps recognized labels to numbers for serial communication; unrecognized labels default to -1 and don't send anything. The timed-send logic (checking millis() and interval) prevents flooding the microcontroller with repeated data.

function draw() {
  background(220); // Clear the canvas each frame

  if (appState === 'input') {
    // Display input view message
    textSize(32);
    textAlign(CENTER, CENTER);
    fill(0);
    text('Please enter your Teachable Machine model URL below', width / 2, height / 2 - 50); // Slightly above input field
  } else if (appState === 'classifier') {
    if (video && video.ready && classifier && classifier.ready) { // Check video.ready here too
      console.log('Attempting to draw video. Video ready:', video.ready, 'Classifier ready:', classifier.ready); // Critical log
      // Calculate preview size while maintaining aspect ratio
      // Let's make the preview height about half the canvas height, minus margins.
      let previewHeight = height / 2 - 2 * videoMargin;
      let previewWidth = (previewHeight / video.height) * video.width;

      // Ensure previewWidth doesn't exceed available space if canvas is very narrow
      // (e.g., if canvas width is less than previewWidth + videoMargin * 2)
      let availableWidthForVideo = width - 2 * videoMargin;
      if (previewWidth > availableWidthForVideo) {
        previewWidth = availableWidthForVideo;
        previewHeight = (previewWidth / video.width) * video.height;
      }

      // Draw the video feed at the top-left with margin
      image(video, videoMargin, videoMargin, previewWidth, previewHeight);

      // Classification text and background rectangle to the right of the video preview.
      let textRectX = videoMargin + previewWidth + videoMargin;
      let textRectWidth = width - textRectX - videoMargin;
      let textRectHeight = previewHeight; // Make it the same height as the video preview
      let textRectY = videoMargin; // Align with the top of the video

      fill(255, 200); // White with 200 alpha
      noStroke();
      rect(textRectX, textRectY, textRectWidth, textRectHeight);

      // Display the current classification result
      fill(0);
      textSize(24);
      textAlign(LEFT, CENTER);
      // Center text vertically within its rectangle
      let textYOffset = textRectY + textRectHeight / 2;
      text(`Label: ${currentClassification.label}`, textRectX + 10, textYOffset - 20); // Adjust y for two lines
      // Use nf() to format confidence as a percentage with 2 decimal places
      text(`Confidence: ${nf(currentClassification.confidence * 100, 0, 2)}%`, textRectX + 10, textYOffset + 20);

      // --- Web Serial: Send classification data ---
      let numberToSend = -1; // Default to -1 for unknown labels
      const currentLabel = currentClassification.label.toLowerCase(); // Use lowercase for comparison
      switch (currentLabel) {
        case 'ihminen':
          numberToSend = 1;
          break;
        case 'koira':
          numberToSend = 2;
          break;
        case 'kissa': // Assuming 'kissa' for number 3 based on common classification tasks
          numberToSend = 3;
          break;
        default:
          numberToSend = -1; // For any other label, or if label is empty/null
          break;
      }

      // Send the classification number if connected,
      // the classification label has changed,
      // enough time has passed, AND a valid number was mapped.
      if (port && writer && currentLabel !== lastSentLabel.toLowerCase() && millis() - lastClassificationSendTime > classificationSendInterval) {
        if (numberToSend !== -1) {
          const dataToSend = numberToSend + '\n'; // Convert number to string and add newline for microcontroller parsing
          writeSerial(dataToSend);
          lastSentLabel = currentLabel; // Update lastSentLabel with the *string* classification (lowercase)
          lastClassificationSendTime = millis();
        } else {
          console.warn(`Unrecognized or default label "${currentLabel}". Not sending data.`);
        }
      }
    } else if (!classifier.ready) {
      // Display loading message if the model is not yet ready (should only happen briefly after OK)
      textSize(32);
      textAlign(CENTER, CENTER);
      fill(0);
      text('Loading model...', width / 2, height / 2);
    }
  }
}
Line-by-line explanation (13 lines)

🔧 Subcomponents:

conditional Application State Check if (appState === 'input')

Branches rendering logic based on whether we're waiting for a model URL or actively classifying

calculation Aspect-Ratio-Preserving Video Sizing let previewHeight = height / 2 - 2 * videoMargin; let previewWidth = (previewHeight / video.height) * video.width;

Calculates video preview dimensions that maintain the original aspect ratio while fitting the layout

switch-case Label to Number Mapping switch (currentLabel) { case 'ihminen': numberToSend = 1; break;

Converts text labels (e.g., 'ihminen') to numeric codes (1, 2, 3) for sending to the microcontroller

conditional Serial Send Logic if (port && writer && currentLabel !== lastSentLabel.toLowerCase() && millis() - lastClassificationSendTime > classificationSendInterval)

Sends the mapped number to the device only when connected, label changed, and interval elapsed

background(220);
Clears the canvas to a light gray each frame, removing the previous frame's content
if (appState === 'input')
Checks whether we're in the model-loading phase or the active-classification phase
let previewHeight = height / 2 - 2 * videoMargin;
Sets video preview height to half the canvas height, minus margins on top and bottom
let previewWidth = (previewHeight / video.height) * video.width;
Calculates width to maintain the video's original aspect ratio given the new height
if (previewWidth > availableWidthForVideo)
Checks if the calculated width is too wide for the canvas—on very narrow screens, revert to width-first calculation
image(video, videoMargin, videoMargin, previewWidth, previewHeight);
Draws the live video stream at the top-left corner with the calculated dimensions
fill(255, 200);
Sets the fill color to white with 200 alpha (semi-transparent) for the results panel background
text(`Label: ${currentClassification.label}`, textRectX + 10, textYOffset - 20);
Displays the top-predicted label (e.g., 'ihminen') in the results panel
text(`Confidence: ${nf(currentClassification.confidence * 100, 0, 2)}%`, textRectX + 10, textYOffset + 20);
Displays confidence as a percentage with 2 decimal places (e.g., '87.45%')
const currentLabel = currentClassification.label.toLowerCase();
Converts the label to lowercase for case-insensitive switch comparison
switch (currentLabel)
Enters a switch statement to map recognized labels to numeric codes
if (port && writer && currentLabel !== lastSentLabel.toLowerCase() && millis() - lastClassificationSendTime > classificationSendInterval)
Four conditions: serial connected, a number is mapped, label changed since last send, and send interval elapsed
const dataToSend = numberToSend + '\n';
Converts the number to a string and appends a newline—microcontroller-friendly format for line-based parsing

windowResized()

windowResized() is called automatically by p5.js whenever the browser window is resized. It recalculates all element positions to maintain the responsive layout. The key pattern here is calculating positions based on video size and margins, ensuring the UI adapts smoothly to any screen size. This is essential for sketches that run on phones, tablets, and desktops.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight); // Adjust canvas size on window resize
  console.log('windowResized() called. appState:', appState);

  if (appState === 'input') {
    // When in input state, position input/button centrally below the initial text
    let inputWidth = min(width - 2 * videoMargin, 400); // Max 400px wide, or less if canvas is narrow
    if (modelURLInput) modelURLInput.style('width', `${inputWidth - modelURLLoadButton.width - 20}px`); // Adjust for button width and spacing
    if (modelURLInput) modelURLInput.position(width / 2 - inputWidth / 2, height / 2 + 50); // Below initial text
    if (modelURLLoadButton) modelURLLoadButton.position(width / 2 + inputWidth / 2 - modelURLLoadButton.width, height / 2 + 50); // To the right of input
  } else { // In classifier state, use the standard layout
    // Recalculate video preview size based on new window dimensions
    let previewHeight = height / 2 - 2 * videoMargin;
    let previewWidth = (previewHeight / video.height) * video.width;
    let availableWidthForVideo = width - 2 * videoMargin;
    if (previewWidth > availableWidthForVideo) {
      previewWidth = availableWidthForVideo;
      previewHeight = (previewWidth / video.width) * video.height;
    }

    // Calculate the new starting y-position for the DOM elements below the video
    // It's the bottom of the video (videoMargin + previewHeight) + an additional videoMargin for spacing
    let domElementsStartY = videoMargin + previewHeight + videoMargin;

    // Re-position model URL input and button (now below video, not centered)
    if (modelURLInput) modelURLInput.style('width', 'calc(100% - 100px - 2 * ' + videoMargin + 'px)'); // Reset width
    if (modelURLInput) modelURLInput.position(videoMargin, domElementsStartY);
    if (modelURLLoadButton) modelURLLoadButton.position(videoMargin + modelURLInput.width + 10, domElementsStartY); // 10px spacing

    // Re-position other p5 DOM elements
    // Adjust starting Y for other elements based on the model URL input/button height
    let otherDomElementsStartY = domElementsStartY + 40; // Assuming button height is around 30-35px + 10px spacing
    
    if (serialStatusP) serialStatusP.position(videoMargin, otherDomElementsStartY); // Adjusted y
    if (receivedSerialDataP) receivedSerialDataP.position(videoMargin, otherDomElementsStartY + 30); // Adjusted y
    if (serialConnectButton) serialConnectButton.position(videoMargin, otherDomElementsStartY + 70); // Adjusted y
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

function-call Canvas Resize resizeCanvas(windowWidth, windowHeight);

Adjusts the p5.js canvas to match the new window dimensions when the user resizes the browser

conditional Layout State Branch if (appState === 'input')

Applies different positioning logic depending on whether we're in input or classifier state

resizeCanvas(windowWidth, windowHeight);
p5.js calls windowResized() automatically when the browser window is resized—this line updates the canvas size
let inputWidth = min(width - 2 * videoMargin, 400);
Sets input field width to at most 400px, but shrinks on narrow screens to leave margins
modelURLInput.position(width / 2 - inputWidth / 2, height / 2 + 50);
Centers the input field horizontally and places it below the instruction text
let domElementsStartY = videoMargin + previewHeight + videoMargin;
Calculates the Y position where DOM elements should start—just below the video preview with a margin
if (serialStatusP) serialStatusP.position(videoMargin, otherDomElementsStartY);
Repositions the serial status text with the correct spacing below the video and input field

connectSerial()

connectSerial() is the entry point for Web Serial communication. It opens a browser dialog letting users select a physical USB serial device, establishes the connection at a specified baud rate, and sets up text encoder/decoder streams for easy string handling. The key detail: baud rate must match your microcontroller's configuration, or data will be garbled. The async/await pattern ensures we wait for the port to be selected and opened before proceeding.

/**
 * Connects to a serial port selected by the user.
 */
async function connectSerial() {
  try {
    console.log('connectSerial() called.');
    // Request a serial port from the user
    port = await navigator.serial.requestPort();
    serialStatusP.html('Web Serial: Connecting...');
    console.log('Serial port requested and granted.');

    // Open the port with a specified baud rate
    // IMPORTANT: This baud rate (9600) must match the one used by your microcontroller (e.g., Arduino Serial.begin(9600);)
    await port.open({ baudRate: 9600 });
    serialStatusP.html('Web Serial: Connected!');
    serialConnectButton.html('Disconnect Serial Port'); // Change button text
    serialConnectButton.mousePressed(disconnectSerial); // Change button action to disconnect
    console.log('Serial port opened with baudRate 9600.');

    // Create TextDecoderStream and TextEncoderStream for easy text handling
    const textDecoder = new TextDecoderStream();
    const textEncoder = new TextEncoderStream();
    reader = textDecoder.readable.getReader(); // Reader for incoming text data
    writer = textEncoder.writable.getWriter(); // Writer for outgoing text data
    console.log('TextDecoderStream and TextEncoderStream created.');

    // Pipe the port's readable stream through the decoder
    port.readable.pipeTo(textDecoder.writable);
    // TÄMÄ ON OIKEIN: Pipe the encoder's readable stream (containing encoded bytes) to the port's writable stream
    textEncoder.readable.pipeTo(port.writable); // THIS IS THE CORRECTION
    console.log('Serial streams piped.');

    readSerial(); // Start continuously reading data from the port
  } catch (error) {
    console.error('Error connecting to serial port:', error);
    serialStatusP.html('Web Serial: Connection failed. ' + error.message);
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

function-call Serial Port Request port = await navigator.serial.requestPort();

Opens a browser dialog letting the user select a serial port to connect to

function-call Port Open await port.open({ baudRate: 9600 });

Opens the selected port at 9600 baud—this must match the microcontroller's baud rate

calculation Text Stream Setup const textDecoder = new TextDecoderStream(); const textEncoder = new TextEncoderStream();

Creates encoder/decoder streams to convert between text and bytes for easy serialization

async function connectSerial()
Declares an async function—needed because Web Serial operations are asynchronous (they take time and don't block)
port = await navigator.serial.requestPort();
Shows a browser dialog prompting the user to select a USB serial device—waits for their choice
await port.open({ baudRate: 9600 });
Opens the port at 9600 baud—CRITICAL: this must match your Arduino/Micro:bit's Serial.begin() rate
serialConnectButton.mousePressed(disconnectSerial);
Reassigns the button's click handler to disconnectSerial—toggles button behavior between connect and disconnect
const textDecoder = new TextDecoderStream();
Creates a stream transformer that converts incoming bytes into text strings
const textEncoder = new TextEncoderStream();
Creates a stream transformer that converts outgoing text strings into bytes
reader = textDecoder.readable.getReader();
Gets a reader object to pull data from the decoder—used in readSerial() to retrieve incoming text
writer = textEncoder.writable.getWriter();
Gets a writer object to push data to the encoder—used in writeSerial() to send outgoing text
port.readable.pipeTo(textDecoder.writable);
Connects the raw byte stream from the port to the text decoder—incoming bytes become text
textEncoder.readable.pipeTo(port.writable);
Connects the text encoder's output to the port—outgoing text becomes bytes sent to the device
readSerial();
Starts the continuous reading loop—listens for incoming data from the microcontroller

disconnectSerial()

disconnectSerial() cleanly closes the Web Serial connection. It cancels the read loop, closes streams, and closes the port—each step is wrapped in error handling. Finally, portDisconnected() resets all state variables and UI elements, returning the sketch to a disconnected state so the user can connect to a different device if needed.

/**
 * Disconnects from the currently connected serial port.
 */
async function disconnectSerial() {
  try {
    console.log('disconnectSerial() called.');
    if (reader) await reader.cancel(); // Cancel any pending read operations
    if (writer) await writer.close(); // Close the writer stream
    if (port) await port.close(); // Close the serial port

    portDisconnected(); // Reset variables and UI
    console.log('Serial port successfully disconnected.');
  } catch (error) {
    console.error('Error disconnecting serial port:', error);
    serialStatusP.html('Web Serial: Disconnection failed. ' + error.message);
  }
}
Line-by-line explanation (4 lines)
if (reader) await reader.cancel();
Cancels any pending read operation—stops listening for incoming data from the device
if (writer) await writer.close();
Closes the writer stream—prevents new data from being sent
if (port) await port.close();
Closes the serial port itself—releases the USB connection
portDisconnected();
Calls the helper function to reset variables and update the UI

readSerial()

readSerial() is an infinite loop that listens for incoming data from the microcontroller. It waits on reader.read(), which returns the next message the device sent. The code includes a special case for detecting '1' (device acknowledgment) and displays other data as-is. The loop exits gracefully if the port closes or an error occurs, triggering portDisconnected() to clean up state.

/**
 * Continuously reads data from the serial port.
 */
async function readSerial() {
  console.log('readSerial() started.');
  while (port && port.readable) {
    try {
      const { value, done } = await reader.read();
      if (done) {
        console.log('Reader closed.');
        break; // Exit loop if reader is closed
      }
      
      // Check if the received value is "1" (and trim whitespace for robustness)
      if (value.trim() === '1') {
        receivedSerialDataP.html('Received: remote device connected');
        // You might want to update serialStatusP too, but currentStatusP is already "Connected!"
      } else {
        receivedSerialDataP.html(`Received: ${value}`); // Display received data
      }
      
      // For more complex data, you might parse 'value' here (e.g., JSON.parse(value))
    } catch (error) {
      console.error('Error reading from serial port:', error);
      serialStatusP.html('Web Serial: Read error. ' + error.message);
      break; // Exit loop on error
    }
  }
  portDisconnected(); // Assume disconnected if loop exits (e.g., due to error or port closure)
  console.log('readSerial() stopped.');
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

while-loop Continuous Read Loop while (port && port.readable)

Loops indefinitely while the port is connected and readable, waiting for incoming data

conditional Acknowledgment Detection if (value.trim() === '1')

Checks if incoming data is the special '1' acknowledgment code from the device

async function readSerial()
Declares an async function—required because reader.read() is asynchronous
while (port && port.readable)
Loops continuously as long as the port exists and is readable—exits if port is closed or null
const { value, done } = await reader.read();
Waits for data from the serial port—destructures result into value (the text received) and done (true if stream closed)
if (done)
Checks if the reader stream is closed—if so, exit the loop to stop reading
if (value.trim() === '1')
Checks if the received data (after removing whitespace) is exactly '1'—a special code from the device
receivedSerialDataP.html('Received: remote device connected');
Displays a human-friendly message when the device sends '1'
receivedSerialDataP.html(`Received: ${value}`);
For any other incoming data, displays it as-is on screen
portDisconnected();
Calls cleanup if the loop exits—resets variables and UI

writeSerial()

writeSerial() is the counterpart to readSerial()—it sends data from the browser to the microcontroller. It takes a string, checks that the connection is active, and pushes the string through the text encoder to the port. The function includes defensive checks (port && writer) to prevent crashes, and error handling to gracefully handle write failures. This function is called from draw() when a new classification is detected.

/**
 * Writes data to the serial port.
 * @param {string} data - The string data to send.
 */
async function writeSerial(data) {
  if (port && writer) {
    try {
      await writer.write(data);
      console.log('Sent number:', data.trim()); // Log sent data (the number) without the trailing newline
    } catch (error) {
      console.error('Error writing to serial port:', error);
      serialStatusP.html('Web Serial: Write error. ' + error.message);
      portDisconnected(); // Assume disconnected on write error
    }
  } else {
    console.warn('Cannot write: Serial port not connected.');
    serialStatusP.html('Web Serial: Cannot write, not connected.');
  }
}
Line-by-line explanation (5 lines)
if (port && writer)
Checks that both the port object and writer object exist—prevents crashes if trying to write when not connected
await writer.write(data);
Sends the text string data through the encoder to the device—waits for the write to complete
console.log('Sent number:', data.trim());
Logs the sent data to the console (trimmed to remove the newline)—useful for debugging and verifying transmission
portDisconnected();
On write error, assumes the port is no longer available and triggers cleanup
console.warn('Cannot write: Serial port not connected.');
Warns the user (in console) if they tried to send data without an active connection

portDisconnected()

portDisconnected() is a cleanup function that runs when the port disconnects (either user-initiated or from unplugging). It sets all port-related variables to undefined, updates the UI to show disconnected status, resets the button to connect mode, and clears the label cache. This ensures a clean state for reconnection and prevents the code from trying to use a dead connection.

/**
 * Resets serial variables and UI when the port disconnects.
 */
function portDisconnected() {
  console.log('Serial port disconnected.');
  port = undefined;
  reader = undefined;
  writer = undefined;
  serialStatusP.html('Web Serial: Disconnected.');
  receivedSerialDataP.html('Received: ');
  if (serialConnectButton) {
    serialConnectButton.html('Connect Serial Port');
    serialConnectButton.mousePressed(connectSerial); // Change button action back to connect
  }
  lastSentLabel = ''; // Reset to ensure label is sent again on reconnect
  lastClassificationSendTime = 0; // Reset timer too
}
Line-by-line explanation (6 lines)
port = undefined;
Clears the port reference, signaling that no connection exists
reader = undefined;
Clears the reader reference so readSerial() won't try to read from a closed stream
writer = undefined;
Clears the writer reference so writeSerial() can't attempt to send data
serialStatusP.html('Web Serial: Disconnected.');
Updates the status message on screen to show the user they're no longer connected
serialConnectButton.mousePressed(connectSerial);
Reassigns the button click handler back to connectSerial—allows the user to reconnect
lastSentLabel = '';
Clears the last sent label so that when the user reconnects, the next classification will be sent immediately instead of waiting

📦 Key Variables

classifier object

Stores the ml5.js image classifier instance—used to analyze video frames and generate predictions

let classifier;
video object

Stores the p5.js video capture object from createCapture()—represents the live webcam stream

let video;
modelURL string

Stores the URL of the Teachable Machine model the user provides

let modelURL = '';
currentClassification object

Stores the latest classification result with label (string) and confidence (number 0-1)

let currentClassification = { label: 'Please enter model URL and click OK', confidence: 0 };
port object

Stores the Web Serial port object when connected—allows reading from and writing to the microcontroller

let port;
reader object

Stores the TextDecoderStream reader—used in readSerial() to pull incoming text data from the port

let reader;
writer object

Stores the TextEncoderStream writer—used in writeSerial() to push outgoing text data to the port

let writer;
appState string

Tracks the current mode: 'input' (waiting for model URL) or 'classifier' (running ML classification)

let appState = 'input';
classificationSendInterval number

Milliseconds to wait between sending classification results to the device (default 3000ms = 3 seconds)

let classificationSendInterval = 3000;
lastSentLabel string

Stores the most recently sent classification label—prevents sending duplicate data if the label hasn't changed

let lastSentLabel = '';
videoMargin number

Pixel spacing around the video preview and UI elements—used for responsive layout calculations

let videoMargin = 20;

🔧 Potential Improvements (5)

Here are some ways this code could be enhanced:

BUG draw() switch statement

Hardcoded Finnish label names ('ihminen', 'koira', 'kissa') are brittle—if the Teachable Machine model uses different labels or the user loads a different model with English labels, no data is sent.

💡 Make label mapping configurable. Store label-to-number mappings in an object (e.g., let labelMap = { 'person': 1, 'ihminen': 1, ... }) that users can edit or load dynamically. This allows the sketch to work with any Teachable Machine model regardless of label language.

BUG draw() video rendering

The console.log statement inside draw() fires every frame (60 times per second), spamming the browser console and slowing performance.

💡 Remove the console.log line: `console.log('Attempting to draw video. Video ready:', ...);` This log is only useful for debugging and should not be in production code. If needed for debugging, wrap it in a debug flag (e.g., if (DEBUG) console.log(...)).

PERFORMANCE readSerial() loop

The while loop in readSerial() blocks and waits indefinitely. If the port disconnects unexpectedly, the loop may not exit promptly, holding resources.

💡 Add a timeout or heartbeat check to detect stale connections. Alternatively, wrap the loop in a try-catch that calls portDisconnected() if the port becomes unavailable, ensuring clean cleanup.

STYLE label mapping

The switch statement uses lowercase() comparison but stores uppercase labels in lastSentLabel, creating inconsistency and potential bugs.

💡 Consistently use lowercase for all label comparisons and storage. Change `lastSentLabel = currentLabel;` to `lastSentLabel = currentLabel.toLowerCase();` to ensure the comparison `currentLabel !== lastSentLabel.toLowerCase()` is always accurate.

FEATURE UI/UX

The sketch provides no visual feedback when data is successfully sent to the device—users can't easily verify the serial connection is working.

💡 Add a visual indicator (e.g., a blinking circle or status text that shows 'Sent: label at time X') when writeSerial() succeeds. This reassures users that the hardware is receiving data and helps debug connection issues.

🔄 Code Flow

Code flow showing preload, setup, videready, setclassifieruivisibility, loadmodelfrominput, modeloaded, classifyvideo, gotresult, draw, windowresized, connectserial, disconnectserial, readserial, writeserial, portdisconnected

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> state-check[Application State Check] state-check -->|input| setclassifieruivisibility[setClassifierUIVisibility] state-check -->|classifier| video-drawing[Aspect-Ratio-Preserving Video Sizing] state-check -->|classifier| serial-send-logic[Serial Send Logic] state-check -->|classifier| video-readiness[Video Readiness Check] video-readiness --> readiness-guard[Readiness Guard] readiness-guard -->|ready| classifyvideo[classifyVideo] readiness-guard -->|not ready| retry-loop[Retry Loop] retry-loop -->|wait| classifyvideo classifyvideo --> gotresult[gotResult] gotresult --> sort-results[Sort by Confidence] sort-results -->|display| draw click setup href "#fn-setup" click draw href "#fn-draw" click setclassifieruivisibility href "#fn-setclassifieruivisibility" click video-drawing href "#sub-video-drawing" click serial-send-logic href "#sub-serial-send-logic" click video-readiness href "#sub-video-readiness" click readiness-guard href "#sub-readiness-guard" click classifyvideo href "#fn-classifyvideo" click gotresult href "#fn-gotresult" click sort-results href "#sub-sort-results" click state-check href "#sub-state-check" click retry-loop href "#sub-retry-loop"

❓ Frequently Asked Questions

What visual elements does the Teachable Machine P5.js sketch display?

The sketch displays a live webcam video feed alongside user interface elements for entering a Teachable Machine model URL and connection buttons.

How can users interact with the Teachable Machine sketch?

Users can enter a Teachable Machine model URL, click a button to load the model, and connect or disconnect from a web serial device to send classification results.

What creative coding concepts are demonstrated in this P5.js sketch?

The sketch showcases real-time image classification using machine learning and web serial communication to send data to external devices.

Preview

Teachable Machine > P5.js > Device (web serial) - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Teachable Machine > P5.js > Device (web serial) - Code flow showing preload, setup, videready, setclassifieruivisibility, loadmodelfrominput, modeloaded, classifyvideo, gotresult, draw, windowresized, connectserial, disconnectserial, readserial, writeserial, portdisconnected
Code Flow Diagram