4

I created an Audio visualizer using p5.js and React and i need some help.

I am trying to change the state of my button input text from Play to Stop when the song starts playing & from Stop back to Play when the user decides to stop the song at that moment.

I am using react-p5 library and everything works great until i setState when a user clicks the play button. Since that causes a re render it breaks my canvas.

Is there a way to only re-render the button element? I am not too sure how i would go about changing the inner text of my button without a state change?

import React, { useState } from 'react';
import Sketch from 'react-p5';
import 'p5/lib/addons/p5.sound';
import Beat from '../../instrumental.mp3';

const App = () => {

  let width = 900;
  let height = 600;
  let song;
  let fft;

  const [flag, setFlag] = useState(true);

  const preload = (p) => {
    p.soundFormats('mp3');
    song = p.loadSound(Beat);
  }

  const setup = (p, canvasParentRef) => {
    // react-p5 conveniently initializes window.p5 thats why all the other p5's
    // have been changed to p in order to create an FFT object
    fft = new p5.FFT();
    // console.log(p.point)
    // use parent to render the canvas in this ref
        // without it p5 will render the canvas outside of this component
    const canvas = p.createCanvas(width, height).parent(canvasParentRef);
  }

  const playStop = () => {
    if (song.isPlaying()) {
      song.pause();
      setFlag(true);
      //p.noLoop();
    } else {
      song.play();
      setFlag(false);
      //p.loop();
    }
  }

  const draw = p => {
    p.background(0);
    // by default the stroke color is black
    // we need to change this in order to see the wave
    p.stroke(255, 204, 0);

    // no fill in between waves
    p.noFill();
    // returns an array with 1024 elements
    let wave = fft.waveform();

    p.beginShape();
    // By looping through the waveform data, we are able
    // to draw the waveform across the canvas
    for (let i = 0; i < width; i++) {
      // create an index that maps the for loop variable
      // to the index of the wave we want
      // value must be integer thats we we use floor
      let index = p.floor(p.map(i, 0, width, 0, wave.length));

      let x = i;
      let y = wave[index] * 100 + height / 2;
      p.vertex(x, y);
    }
    p.endShape();
  }

  return (
    <div className='outerbox'>
      <h1>Audio Visualizer</h1>
      <Sketch preload={preload} setup={setup} draw={draw}/>
      {flag ? <button onClick={playStop}>Play</button> : <button onClick={playStop}>Stop</button>}
    </div>
  );
}

export default App;

The sad thing is there aren't many resources available that includes react + p5.js

If anyone would like to take the time and clone this repository down in order to see what the problem might be, i would very much appreciate that.

Repo link: https://github.com/imperium11/audio-visualizer

  1. npm i
  2. npm run dev-build
  3. npm start
  • 1
    All of the code in the function body will run every render, so `let song;` song will become undefined every time. – windowsill Feb 07 '22 at 05:23

1 Answers1

3

The issue here is that each time you update state in a functional component the function gets called again. As a result each time state changes you re-declare your preload/setup/draw, because of the way react-p5 work, the running sketch will start using your updated draw function. However, the updated draw function expects fft to be defined, but the version of the fft variable referenced by the new draw function is undefined.

In order to fix this you can make any local variables that you sketch uses into state variables. In this example I've packed all the locals into one object:

const { useState } = React;
const Sketch = reactP5;

let v = 0;

const App = () => {
  const width = 500;
  const height = 300;

  const [flag, setFlag] = useState(true);
  const [locals, setLocals] = useState({});

  const preload = (p) => {
    p.soundFormats('mp3');
    setLocals({
      song: p.loadSound('https://www.paulwheeler.us/files/Ipu.wav')
    });
  }

  const setup = (p, canvasParentRef) => {
    // react-p5 conveniently initializes window.p5 thats why all the other p5's
    // have been changed to p in order to create an FFT object
    setLocals({
      ...locals,
      fft: new p5.FFT()
    });
    // console.log(p.point)
    // use parent to render the canvas in this ref
        // without it p5 will render the canvas outside of this component
    p.createCanvas(width, height).parent(canvasParentRef);
  };
  
  setup.version = v++;

  const playStop = () => {
    if (locals.song.isPlaying()) {
      locals.song.pause();
      setFlag(true);
      //p.noLoop();
    } else {
      locals.song.play();
      setFlag(false);
      //p.loop();
    }
  }

  const draw = p => {
    p.background(0);
    p.text(setup.version.toString(), 20, 20);
    // by default the stroke color is black
    // we need to change this in order to see the wave
    p.stroke(255, 204, 0);

    // no fill in between waves
    p.noFill();
    // returns an array with 1024 elements
    let wave = locals.fft.waveform();

    p.beginShape();
    // By looping through the waveform data, we are able
    // to draw the waveform across the canvas
    for (let i = 0; i < width; i++) {
      // create an index that maps the for loop variable
      // to the index of the wave we want
      // value must be integer thats we we use floor
      let index = p.floor(p.map(i, 0, width, 0, wave.length));

      let x = i;
      let y = wave[index] * 100 + height / 2;
      p.vertex(x, y);
    }
    p.endShape();
  }

  return (
    <div className='outerbox'>
      <span>Audio Visualizer</span>
      {flag ? <button onClick={playStop}>Play</button> : <button onClick={playStop}>Stop</button>}
      <Sketch preload={preload} setup={setup} draw={draw}/>
    </div>
  );
}

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-p5@1.3.27/build/index.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.3.1/addons/p5.sound.min.js"></script>

<div id="root"></div>

Another way to go would be to actually make the functions that use those local variables into state variables. That way they would only be declared the first time your component function is called for each instance of it. This may be a bit of a hack, but it has the advantage of working for rapidly updating local variables (if you were changing local variables in your draw() function, my understanding is that you would not want to make those into state variables, since high frequency state updates may impact performance.

const { useState } = React;
const Sketch = reactP5;

const App = () => {
  const width = 500;
  const height = 300;
  let song;
  let fft;

  const [flag, setFlag] = useState(true);
  
  const [sketchFns] = useState({
    preload: (p) => {
      p.soundFormats('mp3');
      song = p.loadSound('https://www.paulwheeler.us/files/Ipu.wav');
    },
    setup: (p, canvasParentRef) => {
      // react-p5 conveniently initializes window.p5 thats why all the other p5's
      // have been changed to p in order to create an FFT object
      fft = new p5.FFT();
      // console.log(p.point)
      // use parent to render the canvas in this ref
        // without it p5 will render the canvas outside of this component
      p.createCanvas(width, height).parent(canvasParentRef);
    },
    playStop: () => {
      if (song.isPlaying()) {
        song.pause();
        setFlag(true);
        //p.noLoop();
      } else {
        song.play();
        setFlag(false);
        //p.loop();
      }
    },
    draw: p => {
      p.background(0);
      // by default the stroke color is black
      // we need to change this in order to see the wave
      p.stroke(255, 204, 0);

      // no fill in between waves
      p.noFill();
      // returns an array with 1024 elements
      let wave = fft.waveform();

      p.beginShape();
      // By looping through the waveform data, we are able
      // to draw the waveform across the canvas
      for (let i = 0; i < width; i++) {
        // create an index that maps the for loop variable
        // to the index of the wave we want
        // value must be integer thats we we use floor
        let index = p.floor(p.map(i, 0, width, 0, wave.length));

        let x = i;
        let y = wave[index] * 100 + height / 2;
        p.vertex(x, y);
      }
      p.endShape();
    }
  });

  return (
    <div className='outerbox'>
      <span>Audio Visualizer</span>
      {flag ? <button onClick={sketchFns.playStop}>Play</button> : <button onClick={sketchFns.playStop}>Stop</button>}
      <Sketch preload={sketchFns.preload} setup={sketchFns.setup} draw={sketchFns.draw}/>
    </div>
  );
}

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-p5@1.3.27/build/index.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.3.1/addons/p5.sound.min.js"></script>

<div id="root"></div>
Paul Wheeler
  • 18,988
  • 3
  • 28
  • 41
  • Thank you for the long explanation. It makes sense. But when i run the first example that you gave i get an error : `Uncaught TypeError: Cannot read properties of undefined (reading 'waveform') at Object.draw at sketch.i.a.h.forEach.e.props..t.`. Do you have any idea why this could be? The code works perfectly fine. Also do you happen to know how i would get rid of the warning: `audio context was not allowed to start. It must be resumed (or created)`? Sorry for all the questions i am very new to p5.js and using it with react has been a bit challenging. – Poyraz Akay Feb 09 '22 at 07:53
  • I cannot reproduce the TypeError you report. Usually the audio context warning results from attempting to start playing audio (or recording) without the user triggering it through user interaction. I do see that warning but it doesn't stop anything from working. – Paul Wheeler Feb 09 '22 at 08:14
  • p5.sound does generate another anomalous error: `TypeError: Cannot read properties of undefined (reading 'length')`. This is due to some sort of bug in the library, but it also doesn't prevent things from working. – Paul Wheeler Feb 09 '22 at 08:17