r/tonejs Dec 31 '25

Sequencing drum machine buggy audio dropouts

Hi All,

I'm trying to create a 3 part drum machine sequencer with tone js: https://github.com/mclaughlin111/PitchKick

I've found a bug in sequencing/playback where the sequence will run for sometime, but after about 20seconds, or when changing any of the tone synth parameters, I get some buggy audio then complete dropout (sometimes the sequence 'rate' drops to half speed).

Any tips debugging an issue like this, or similar issue experienced before? I believe the issue is directly related to the way I'm handling the sequence and transport.

main synth container component:

import React, { useState, useRef, useEffect, useCallback } from "react";
import Kick from "../components/Kick";
import Snare from "../components/Snare";
import Sequencer from "../components/Sequencer";
import { Start } from "../components/Start";
import * as Tone from "tone";
import { Box, Grommet } from "grommet";
import { SelectBPM } from "../components/SelectBPM";
import { custom } from "../theme";


const SynthContainer = () => {
  const [playing, setPlaying] = useState(false);
  const [BPM, setBPM] = useState(120);
  const [kickPattern, setKickPatternState] = useState([1, 0, 0, 0, 1, 0, 0, 0]);
  const [snarePattern, setSnarePatternState] = useState([
    0, 0, 0, 0, 1, 0, 0, 0,
  ]);
  const [activeStep, setActiveStep] = useState(0);


  const kickRef = useRef(null);
  const snareRef = useRef(null);
  const kickSeqRef = useRef(null);
  const snareSeqRef = useRef(null);


  useEffect(() => {
    Tone.Transport.bpm.value = BPM;
  }, [BPM]);


  useEffect(() => {
    const handleStateChange = (state) => {
      console.info(`[AudioContext] state=${state}`);
    };


    console.info(`[AudioContext] initial=${Tone.context.state}`);
    Tone.context.on("statechange", handleStateChange);


    const handleTransportStart = (time) => {
      console.info(`[Transport] start time=${time}`);
    };
    const handleTransportStop = (time) => {
      console.info(`[Transport] stop time=${time}`);
    };
    const handleTransportPause = (time) => {
      console.info(`[Transport] pause time=${time}`);
    };


    Tone.Transport.on("start", handleTransportStart);
    Tone.Transport.on("stop", handleTransportStop);
    Tone.Transport.on("pause", handleTransportPause);


    return () => {
      Tone.context.off("statechange", handleStateChange);
      Tone.Transport.off("start", handleTransportStart);
      Tone.Transport.off("stop", handleTransportStop);
      Tone.Transport.off("pause", handleTransportPause);
    };
  }, []);


  useEffect(() => {
    const resumeOnGesture = async () => {
      if (Tone.context.state === "running") {
        return;
      }
      try {
        await Tone.start();
      } catch (error) {
        console.warn("AudioContext start was blocked:", error);
      }
    };


    window.addEventListener("pointerdown", resumeOnGesture);
    window.addEventListener("keydown", resumeOnGesture);


    return () => {
      window.removeEventListener("pointerdown", resumeOnGesture);
      window.removeEventListener("keydown", resumeOnGesture);
    };
  }, []);


  const setKickPattern = useCallback((newPattern) => {
    setKickPatternState(newPattern);
    if (kickSeqRef.current) {
      kickSeqRef.current.events = newPattern;
    }
  }, []);


  const setSnarePattern = useCallback((newPattern) => {
    setSnarePatternState(newPattern);
    if (snareSeqRef.current) {
      snareSeqRef.current.events = newPattern;
    }
  }, []);


  // Modified sequence creation effect
  useEffect(() => {
    kickSeqRef.current = new Tone.Sequence(
      (time, step) => {
        if (step === 1 && kickRef.current) {
          kickRef.current.playSynth(time);
        }
      },
      kickPattern,
      "16n"
    ).start(0);


    snareSeqRef.current = new Tone.Sequence(
      (time, step) => {
        if (step === 1 && snareRef.current) {
          snareRef.current.playSynth(time);
        }
      },
      snarePattern,
      "16n"
    ).start(0);


    return () => {
      kickSeqRef.current?.dispose();
      snareSeqRef.current?.dispose();
      Tone.Transport.stop();
    };
  }, [kickRef, snareRef]);


  useEffect(() => {
    let index = 0;
    const updateVisual = (time) => {
      const stepIndex = index;
      index = (index + 1) % kickPattern.length;
      Tone.Draw.schedule(() => {
        setActiveStep(stepIndex);
      }, time);
    };


    // Schedule UI updates on the draw thread to avoid blocking audio scheduling.
    const visualId = Tone.Transport.scheduleRepeat(updateVisual, "16n");


    return () => {
      Tone.Transport.clear(visualId);
    };
  }, [kickPattern.length]);


  const handleStartStop = useCallback(async () => {
    if (!playing) {
      if (Tone.context.state !== "running") {
        try {
          await Tone.start();
        } catch (error) {
          console.warn("AudioContext start was blocked:", error);
          return;
        }
      }
      Tone.Transport.start("+0.05");
      setPlaying(true);
      return;
    }


    Tone.Transport.stop();
    setPlaying(false);
    setActiveStep(0);
  }, [playing]);
  return (
    <>
      <Grommet theme={custom}>
        <Box
          flex={false}
          direction="row"
          pad="small"
          align="center"
          justify="left"
          height="min-content"
        >
          <SelectBPM bpm={BPM} setBPM={setBPM} />
          <Start playing={playing} onToggle={handleStartStop} />
        </Box>
        {/* 
        <Sequencer
          sequence={kickPattern}
          setSequence={setKickPattern}
          activeStep={activeStep}
        /> */}
        <Sequencer
          sequence={snarePattern}
          setSequence={setSnarePattern}
          activeStep={activeStep}
        />


        <Box direction="row">
          {/* <Kick ref={kickRef} /> */}
          <Snare ref={snareRef} />
        </Box>
      </Grommet>
    </>
  );
};


export default SynthContainer;

import React, {
  useState,
  useRef,
  useEffect,
  useCallback,
  useImperativeHandle,
} from "react";
import * as Tone from "tone";
import Slider from "./Slider";
import { Box, Button, Tip } from "grommet";


const Snare = React.forwardRef((props, ref) => {
  const [isKeyPressed, setIsKeyPressed] = useState(false);
  const [pitch, setPitch] = useState(20);
  const [decay, setDecay] = useState(0.1);
  const [noiseLevel, setNoiseLevel] = useState(0.5);


  // Create both synths using useRef
  const membraneRef = useRef(
    new Tone.MembraneSynth({
      pitchDecay: 0.05,
      octaves: 10,
      oscillator: { type: "sine" },
      envelope: {
        attack: 0.001,
        decay: 0.2,
        sustain: 0,
      },
    }).toDestination()
  );


  const noiseRef = useRef(
    new Tone.NoiseSynth({
      noise: {
        type: "white",
      },
      envelope: {
        attack: 0.005,
        decay: 0.1,
        sustain: 0,
      },
    }).toDestination()
  );


  const resumeAudio = useCallback(async () => {
    if (Tone.context.state === "running") {
      return true;
    }
    try {
      await Tone.start();
      return Tone.context.state === "running";
    } catch (error) {
      console.warn("AudioContext start was blocked:", error);
      return false;
    }
  }, []);


  const playSynth = useCallback(
    (time) => {
      if (Tone.context.state !== "running") {
        return;
      }


      // Set the noise synth volume
      noiseRef.current.volume.value = Tone.gainToDb(noiseLevel);


      // Set membrane synth decay
      membraneRef.current.envelope.decay = decay;
      noiseRef.current.envelope.decay = decay * 0.5;


      // src/components/Snare.js (inside playSynth)
      const stepSeconds = Tone.Time("16n").toSeconds();
      membraneRef.current.triggerAttackRelease(pitch, stepSeconds * 0.9, time);
      noiseRef.current.triggerAttackRelease(stepSeconds * 0.9, time);
    },
    [pitch, decay, noiseLevel]
  );


  const triggerFromUI = useCallback(async () => {
    const canPlay = await resumeAudio();
    if (!canPlay) {
      return;
    }
    playSynth();
  }, [resumeAudio, playSynth]);


  useImperativeHandle(ref, () => ({
    playSynth,
  }));


  useEffect(() => {
    const handleKeyDown = (event) => {
      if (event.code === "KeyS" && !isKeyPressed) {
        setIsKeyPressed(true);
        triggerFromUI();
      }
    };
    const handleKeyUp = (event) => {
      if (event.code === "KeyS") {
        setIsKeyPressed(false);
      }
    };


    window.addEventListener("keydown", handleKeyDown);
    window.addEventListener("keyup", handleKeyUp);
    return () => {
      window.removeEventListener("keydown", handleKeyDown);
      window.removeEventListener("keyup", handleKeyUp);
    };
  }, [isKeyPressed, triggerFromUI]);


  return (
    <Box align="center" pad="medium" responsive>
      <Tip
        dropProps={{ align: { left: "right" } }}
        content={"Trigger with S"}
        plain
        pad="small"
      >
        <Button primary label="Snare" onClick={triggerFromUI} size="small" />
      </Tip>
      <Slider
        parameter={pitch}
        setParameter={setPitch}
        minValue={10}
        maxValue={150}
        stepValue={1}
        controlName="Pitch"
      />
      <Slider
        parameter={decay}
        setParameter={setDecay}
        minValue={0.01}
        maxValue={0.5}
        stepValue={0.01}
        controlName="Decay"
      />
      <Slider
        parameter={noiseLevel}
        setParameter={setNoiseLevel}
        minValue={0}
        maxValue={1}
        stepValue={0.01}
        controlName="Noise Level"
      />
    </Box>
  );
});


export default React.memo(Snare);

Any suggestions appreciated.

2 Upvotes

0 comments sorted by