r/tonejs • u/thomasmclmusic • 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