Context
I've been trying to build a step sequencer similar to the official example in Tone.js
documentation. Instead of using playing MP3 files with Players
method, however, I wanted to implement PolySynth
to explore a variety of sounds like Jon Oliver's example.
After looking at a few demos with different approaches—like this drum sequencer (using MP3s with Players
), this step sequencer (using several mono Synth
s)—I managed to build a basic functioning sequencer. My working demo in CodeSandbox here.
Problem
While it works as intended—i.e. playing individual notes when toggled on/off, looping the sequence per defined speed, toggling the play/pause mode, etc—the polysynth sound starts to get a little distorted after a few loops and soon becomes completely mute.
Even after the audio disappears, the Sequence
call seems to continue and all other features do work as intended—but it becomes noticeably stuttering and laggy.
I suspect it's the way I'm handling Sequence
call and unintentionally overloading the memory, although I'm not seeing any significant console errors.
What I tried
- Using a
Transport.scheduleRepeat
with a callback: (similar to this Medium article resulting in the same laggy, muting behavior - Using an
async
call to handleSequence
playback toggle: (similar to this CodeSandbox example resulting in the same laggy, muting behavior - Using an
Array.forEach
orfor
loop to manually loop the sequence: failed to keep the timing in sync withoutTone
timing control - Using a
ref
to keep track ofSequence
while continuously disposing the prior beat: (modified on this CodeSandbox example resulting in the same laggy, muting behavior. This is what's currently in the working code.
Code snippet
It's built with React
and TypeScript
. The main App portion is shown below. You can view the full working code at this CodeSandbox.
I am a complete beginner in Tone.js
although fairly proficient in React
. Any guidance and insights will be highly appreciated.
I understand the data handling, DOM handling, and other aspects of the app are also pretty clumsy, and hopefully they won't be what's causing this issue.
function App() {
type Loop = {
dispose: any;
};
const [isPlaying, setIsPlaying] = React.useState(false);
const [currentBeat, setCurrentBeat] = React.useState(0);
const [currentBPM, setCurrentBPM] = React.useState(120);
const [noteMatrix, setNoteMatrix] = React.useState(defaultNoteMatrix);
const [currentScale, setCurrentScale] = React.useState(SCALES.major);
const synth = new Tone.PolySynth().toDestination();
const loop = React.useRef<Loop | null>(null);
const noteMap = noteMatrix.map((beat) =>
beat
.map((note, i) => {
return { note: currentScale[i], on: note };
})
.filter((note) => note.on)
.map((item) => item.note)
);
const playSingleNote = (note = "") => synth.triggerAttackRelease(note, "16n");
const tick = (e: React.MouseEvent<HTMLButtonElement>): void => {
const currentBeat = parseFloat(e.currentTarget.dataset.beat!);
const selectedNote = parseFloat(e.currentTarget.dataset.index!);
const isOn = e.currentTarget.dataset.on === "1" ? 0 : 1;
const updatedMatrix = [...noteMatrix];
playSingleNote(currentScale[selectedNote]);
updatedMatrix[currentBeat][selectedNote] = isOn;
setNoteMatrix(updatedMatrix);
};
const reset = useCallback(() => {
setNoteMatrix([
[...emptyBeat],
[...emptyBeat],
[...emptyBeat],
[...emptyBeat],
[...emptyBeat],
[...emptyBeat],
[...emptyBeat],
[...emptyBeat],
[...emptyBeat],
[...emptyBeat],
[...emptyBeat],
[...emptyBeat],
[...emptyBeat],
[...emptyBeat],
[...emptyBeat],
[...emptyBeat]
]);
setIsPlaying(false);
Tone.Transport.stop();
}, []);
React.useEffect(() => {
Tone.Transport.loop = false;
Tone.Transport.on("stop", () => setCurrentBeat(0));
});
React.useEffect(() => {
Tone.Transport.bpm.value = currentBPM;
}, [currentBPM]);
React.useEffect(() => {
if (loop.current) {
loop.current.dispose();
}
loop.current = new Tone.Sequence(
(time, beat) => {
setCurrentBeat(beat);
synth.triggerAttackRelease(noteMap[beat], "16n", time);
},
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
"16n"
).start(0);
}, [noteMap, isPlaying]);
const togglePlay = React.useCallback(() => {
Tone.context.resume();
Tone.Transport.toggle();
setIsPlaying((isPlaying) => !isPlaying);
}, []);
return (
<>
<GridStyle>
<div className="beat">
{currentScale.map((note, i) => (
<div className="head" key={i}>
{note}
</div>
))}
</div>
{noteMatrix.map((beat, i) => (
<div className="beat" key={i} data-active={currentBeat === i ? 1 : 0}>
{currentScale.map((note, j) => (
<button
className="note"
key={j}
onClick={tick}
data-note={note}
data-on={beat[j]}
data-beat={i}
data-index={j}
/>
))}
</div>
))}
</GridStyle>
<button onClick={togglePlay}>{isPlaying ? "Stop" : "Play"}</button>
<button onClick={reset}>Clear</button>
Scale
<select
name="scale"
id="scale"
defaultValue="major"
onChange={(e) => setCurrentScale(SCALES[e.currentTarget.value])}
>
{["major", "minor", "suspended"].map((scale) => (
<option value={scale} key={scale}>
{scale}
</option>
))}
</select>
BPM
<select
name="bpm"
id="bpm"
onChange={(e) => setCurrentBPM(parseFloat(e.currentTarget.value))}
defaultValue={currentBPM}
>
{[80, 100, 120, 140, 160, 180, 200].map((bpm) => (
<option value={bpm} key={bpm}>
{bpm}
</option>
))}
</select>
</>
);
}
export default App;
CodePudding user response:
The synth
object is created on every render. You can move it out of App
(or useRef
) and everything works fine.
Another thing I noticed that no dependency list here:
React.useEffect(() => {
Tone.Transport.loop = false;
Tone.Transport.on("stop", () => setCurrentBeat(0));
}, []); // <--
P.S. What a nice project!