0

Here is my component code (it is called on a page with <DetectPitch />);

import { useEffect, useState } from 'react';

export default function DetectPitch() {
    const [detect, setDetect] = useState(false);
    useEffect(() => {
        document.getElementById("mute-button").addEventListener("click", () => setDetect(detect => !detect))
      }, []);
      useEffect(() => {
        function update(random) {
          if (detect != false) {
            console.log("updating", random)
            window.setTimeout(() => update(random), 100);
          }
        }
        const audioContext = new window.AudioContext();
        if (detect) {
          audioContext.resume()
          navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
            update(Math.random());
          });
        } else {
            audioContext.suspend()
        }
        }, [detect]);
    return (
        <button id="mute-button">{detect ? "Mute" : "Unmute"}</button>
    )
}

This component renders a button that when pressed toggles between mute/unmute based on the value of a react state, detect. It also sets up listening to a users audio input device (I believe the audioContext is being set multiple times but thats a seperate issue right now). I would like the browser to stop listening to the user audio input device when the button mute is pressed and stop logging "updating" to the console.

With the code as it currently is the audioContext never stops and the message continues to log, this means that multiple presses of the button creates new timeouts that are looped infinitely at an increasing rate (this is demonstrated by the random number printed to the console, depending on how many times you have clicked the button the console displays multiple different random numbers).

I think this is happening because javascript is passing by value rather than reference and therefore the state never changes internally to the random() function. I have tried using a getter function for detect but that doesn't change anything and i've considered creating an object to hold the state but that makes the code more complex. I feel like there is a simpler options that i'm missing.

For now I would like to be able to get the timeout to stop printing so that I can continue debugging the functionality to use a single instance of audioContext.

glend
  • 1,592
  • 1
  • 17
  • 36
  • try moving `const audioContext = new window.AudioContext();` out of DetectPitch function. – Vitalii Jun 05 '21 at 14:12
  • @Vitalii that returns a `window is not defined` error – glend Jun 06 '21 at 12:49
  • I see, I suppose it happens during server-side rendering. There should be other way. – Vitalii Jun 06 '21 at 13:11
  • 1
    I've tried several options and I always have this issue with stale `detect`/`audioContext`. Maybe hooks are not fit for this use-case. May I suggest to use class components? Class component gets instantiated and `update` function can easily access `this.state.detect` anytime which should solve this. Should I post a solution based on class components? – Vitalii Jun 06 '21 at 15:04
  • @Vitalii if you want, any idea why they go stale? – glend Jun 06 '21 at 15:31
  • Hooks look to me like a hacky way to add state into stateless functions. They only work properly if [rules of Hooks](https://reactjs.org/docs/hooks-rules.html) are followed, which suggests that they should be used with caution under those specific conditions. Frankly I don't have much experience with using hooks, so it's just my initial impression. – Vitalii Jun 06 '21 at 16:09
  • 1
    I'll see if I can do both class component and functional component, so it can be compared. I think if `useState()` is called with an object it'll be passed by reference `useState({ detect: false })`, I'll try that. – Vitalii Jun 06 '21 at 16:10
  • @Vitalii I considered that but then I found this. https://stackoverflow.com/a/57102754/1807486 and now I feel like that while it may work it would be potentially buggy. Its also kind of annoying if you only want to update the state of a single value in a state object. – glend Jun 06 '21 at 16:16
  • the trick was to call `setDetect()` to read the latest value – Vitalii Jun 06 '21 at 17:41

2 Answers2

1

The issue seems to be that update function which is called periodically does not have access to the latest detect state from useState() hook.

Some changes in functionality compared to the original code:

  • AudioContext has it's own state - one of 'suspended', 'running', 'closed' or 'interrupted'. So mirroring has to be setup to update detect React state so React can re-render every time AudioContext state changes.
  • click handler was changed according to React's event handling
  • setTimeout was replaced with setInterval for convenience
  • cleanup added closing AudioContext when component is unmounted
  • loading state displayed till user grants access to a microphone

For update function to get latest detect value I'm calling setDetect with a callback. This looks hacky to me but it works, maybe a class component implementation is better (see bellow).

import { useEffect, useState } from 'react';

export default function DetectPitch() {
    const [detect, setDetect] = useState(false);
    // audioContext created after first render, initially set to null
    const [audioContext, setAudioContext] = useState(null);

    function update(random) {
        // access current value of 'detect' by calling 'setDetect'
        setDetect(detect => {
            if (detect) {
                console.log("updating", random)
            }
            return detect;
        });
    }

    useEffect(() => {
        const context = new window.AudioContext();

        // Update 'detect' every time audiocontext changes state
        //   true - if running
        //   false - if not running (suspended, closed or interrupted (if in Safari))
        context.addEventListener('statechange', (event) => {
            console.log('audioContext changed state to: ' + event.target.state);
            const isRunning = event.target.state === 'running'
            setDetect(isRunning);
        });

        setAudioContext(context);

        // start calling 'update'
        const rand = Math.random();
        window.setInterval(() => update(rand), 1000);

        // cleanup when component is unmounted
        return () => {
            if (audioContext) {
                // close if audioContext was created
                audioContext.close();
            }
        }
    }, []); // create audioContext only once on initial render


    function onClickHandler() {
        if (detect) {
            audioContext.suspend();
        } else {
            navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
                audioContext.createMediaStreamSource(stream);
                audioContext.resume();
            });
        }
    }

    // show Loading state while we create audioContext
    if (!audioContext) {
        return 'Loading...';
    }

    return (
        <button onClick={onClickHandler}>
            {detect ? "Mute" : "Unmute"}
        </button>
    )
}

Same implementation using class component:

import React from "react";

export default class DetectPitchClass extends React.Component {
    constructor(props) {
        // boilerplate
        super(props);
        this.update = this.update.bind(this);
        this.onClickHandler = this.onClickHandler.bind(this);

        // initial state
        this.state = {
            audioContext: null,
            detect: false
        };
    }

    componentDidMount() {
        // initialised only once
        const audioContext = new window.AudioContext();

        // 'detect' mirrors state of audioContext
        //    true - if 'running'
        //    false - if not running (suspended, closed or interrupted)
        //      Safari changes state to interrupted if user switches to another tab
        audioContext.addEventListener('statechange', (event) => {
            console.log('audioContext changed state to: ' + event.target.state);
            this.setState({ detect: event.target.state === 'running' });
        });

        this.setState({ audioContext });

        // start calling 'update'
        const rand = Math.random();
        window.setInterval(() => this.update(rand), 1000);
    }

    componentWillUnmount() {
        if (this.state.audioContext) {
            // close if audioContext was created
            this.state.audioContext.close();
        }
    }

    // runs periodically, can always read 'detect' state
    update(random) {
        if (this.state.detect) {
            console.log("updating", random)
        }
    }

    onClickHandler() {
        if (this.state.audioContext) {
            if (this.state.detect) {
                this.state.audioContext.suspend();
            } else {
                navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
                    this.state.audioContext.createMediaStreamSource(stream);
                    this.state.audioContext.resume();
                });
            }
        }
    }

    render() {
        // show Loading state while we create audioContext
        if (!this.state.audioContext) {
            return 'Loading...';
        }

        return (
            <button onClick={this.onClickHandler}>
                {this.state.detect ? "Mute" : "Unmute"}
            </button>
        )
    }
}
Vitalii
  • 2,071
  • 1
  • 4
  • 5
0

For completion sake, after doing more research. I have discovered that these sorts of problems are that Redux and advanced React Hooks (useContext and useReducer) set out to solve.

glend
  • 1,592
  • 1
  • 17
  • 36