typescriptvuejs3midiaudiocontexttone.js

Tone.js v15.0.4 - how do I change tempo during playback when playing a MIDI file


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.


Solution

  • 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.