I'm using Vue v3.5.12, tone v15.0.4 and @tonejs/midi. My requirement is to load a MIDI file and convert to a Midi
object, schedule it to playback with Tone.js and implement the following user controls: Play, Pause, Continue, Stop, Loop[from, to]. I also want Volume and Tempo controls which can be used during playback and later on this will be linked to a metronome track. So far I have got Play, Pause, Continue, Stop and Volume working to my satisfaction. However, when I try tp change the tempo up or down it appears to work successfully, but when I stop the playback and start again it either fails to restart or starts after a long pause at the original tempo, not the one that was set when playback was stopped. A further issue is that if I change the tempo and then pause it restarts at the wrong position. Further pauses and continues work OK and the tempo is correct. My code is based on the example in the github repository for [tonejs/midi][1]
. Here is the applicable code (Typescript):
const onSelectedMidiFileChange = async () => {
console.log('Selected example:', selectedMidiFile.value);
try {
if (isPlayingMidi.value) {
togglePlayMidi()
}
currentMidiFile = await Midi.fromUrl(selectedMidiFile.value[1])
console.log(currentMidiFile)
selectedMidiFileJSON.value = JSON.stringify(currentMidiFile, null, 2)
// console.log(selectedMidiFileJSON.value);
} catch(error) {
return `Invalid JSON ${error}`
}
}
// volume control
const gain = new Tone.Gain(1).toDestination(); // Initialize with volume at 0 dB
// Function to set global volume
const setGlobalVolume = (volumeLevel: number) => {
// Convert volumeLevel from a percentage (0-100) to dB
const volumeDb = volumeLevelToDb(volumeLevel);
gain.gain.setValueAtTime(Tone.dbToGain(volumeDb), Tone.now()); // Efficiently set the gain
// console.log(`Global volume set to ${volumeDb} dB, gain =`, Tone.dbToGain(volumeDb)); // Debug log
}
// Convert volume percentage to dB
const volumeLevelToDb = (percentage: number): number => {
return percentage === 0 ? -Infinity : (percentage - 100) * 0.25; // Fit your own scaling
}
// tempo control
const setTempo = (newTempo: number) => {
Tone.getTransport().bpm.value = newTempo; // Set the new tempo
console.log(`Transport tempo set to ${round(Tone.getTransport().bpm.value, 0)}`)
};
const synths: Tone.PolySynth<Tone.Synth<Tone.SynthOptions>>[] = []
// called when `play` button clicked
const togglePlayMidi = () => {
// Toggle state of isPlayingMidi
isPlayingMidi.value = !isPlayingMidi.value
if (isPlayingMidi.value) {
// create a PolySynth for each voice (track) and schedule to play starting in 0.5 seconds
if (Tone.getTransport().state !== 'started') {
Tone.getTransport().start(0)
console.log(`Transport started at ${round(Tone.getTransport().seconds)} seconds, tempo: ${Tone.getTransport().bpm.value}bpm`)
}
const now = Tone.now() + 0.5
if (currentMidiFile) {
currentMidiFile.tracks.forEach((track, trk) => {
//create a synth for each track
const synth = new Tone.PolySynth(Tone.Synth, {
envelope: {
attack: 0.02,
decay: 0.1,
sustain: 0.3,
release: 1
}
}).sync().connect(gain) /* added `.sync()` method which connects the `synth` to the `Transport` */
synths.push(synth)
//schedule all of the events
track.notes.forEach((note, idx) => {
synth.triggerAttackRelease(note.name, note.duration, note.time + now, note.velocity)
})
})
}
} else {
if (isPausedMidi.value) {
pauseContinueMidi()
}
console.log(`Transport stopped at ${round(Tone.getTransport().seconds)} seconds`);
Tone.getTransport().stop(0)
Tone.getTransport().cancel(); // Clear scheduled events and reset the timer
//empty the `synths` array and dispose each one
while (synths.length) {
const synth = synths.shift()
synth?.unsync()
synth?.dispose()
}
}
}
let pauseTime = 0; // Track the time when pausing
const pauseContinueMidi = () => {
const transport = Tone.getTransport();
// console.log("Current transport seconds:", transport.seconds);
if (!isPausedMidi.value) {
// When pausing, capture the current transport time
pauseTime = transport.seconds; // Capture current play time
transport.pause(); // Pause the transport
console.log("Transport paused at:", round(pauseTime), `, state: ${transport.state}`);
} else {
// If it's paused, restart at the `pauseTime`
transport.seconds = pauseTime
transport.start();
console.log("Transport re-started at:", round(transport.seconds), `, state: ${transport.state}`);
}
// Toggle the paused state
isPausedMidi.value = !isPausedMidi.value
}
const round = (n: number, decimals: number = 2) => Math.round(n * Math.pow(10, decimals)) / Math.pow(10, decimals)
And here is a log of significant events:
Start: Transport started at 23.51 seconds, tempo: 120bpm
Stop: Transport stopped at 30.86 seconds
Start: Transport started at 36.13 seconds, tempo: 120bpm
Pause: Transport paused at: 42.12 , state: paused
Continue: Transport re-started at: 42.12 , state: started
Stop: Transport stopped at 45.9 seconds
Start: Transport started at 51.48 seconds, tempo: 120bpm // everything worked fine up to here
Tempo: Transport tempo set to 159 // changed tempo here
Pause: Transport paused at: 62.13 , state: paused
Continue: Transport re-started at: 62.13 , state: started // continued from wrong position
Pause: Transport paused at: 67.85 , state: paused
Continue: Transport re-started at: 67.85 , state: started // continued from pause
Stop: Transport stopped at 71.94 seconds
Start: Transport started at 85.32 seconds, tempo: 159bpm // Didn't start playing for nearly 15 seconds and didn't play at revisrd tempo
Stop: Transport stopped at 100.21 seconds
Tone.js is a fantastic library for handling Web Audio but it's very difficult for a hobbyist like myself to understand how all the moving parts fit together. Can anyone help me get through this stumbling block? Thanks.
I've found a different approach to the issue I posted above. By follwing the example FatOscillator
in the Tonejs
github repository adapted to generate multiple parts from my MIDI file, I was able to control tempo in real time using Transport.bpm.value
property. All is well. 😎
The only issue I had in this approach was controlling volume which I solved by creating a Tone.Gain
node connected to the output destination and then connecting each part's sound source to the gain node.