You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Tone.js v15.0.4播放MIDI时 tempo调整异常问题求助

问题

使用Vue v3.5.12、Tone.js v15.0.4及@tonejs/midi库实现MIDI文件播放控制,已实现播放、暂停、继续、停止、音量控制,但调整tempo时出现两个问题:

  • 调整tempo后停止再重启,要么无法正常启动,要么延迟很久才启动且回到原tempo;
  • 调整tempo后暂停再继续,会回到错误位置,但后续暂停/继续操作正常且tempo正确。

相关代码

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)
    } catch(error) {
        return `Invalid JSON ${error}`
    }
}

// volume control
const gain = new Tone.Gain(1).toDestination(); // Initialize with volume at 0 dB
const setGlobalVolume = (volumeLevel: number) => {
    const volumeDb = volumeLevelToDb(volumeLevel);
    gain.gain.setValueAtTime(Tone.dbToGain(volumeDb), Tone.now());
}

const volumeLevelToDb = (percentage: number): number => {
    return percentage === 0 ? -Infinity : (percentage - 100) * 0.25;
}

// tempo control
const setTempo = (newTempo: number) => {
    Tone.getTransport().bpm.value = newTempo;
    console.log(`Transport tempo set to ${round(Tone.getTransport().bpm.value, 0)}`)
};

const synths: Tone.PolySynth<Tone.Synth<Tone.SynthOptions>>[] = []

const togglePlayMidi = () => {
    isPlayingMidi.value = !isPlayingMidi.value 
    
    if (isPlayingMidi.value) {
        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) => {
                const synth = new Tone.PolySynth(Tone.Synth, {
                    envelope: {
                        attack: 0.02,
                        decay: 0.1,
                        sustain: 0.3,
                        release: 1
                    }
                }).sync().connect(gain)
                synths.push(synth)
                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();
             
        while (synths.length) {
            const synth = synths.shift()
            synth?.unsync()
            synth?.dispose()
        } 
    }
}

let pauseTime = 0;

const pauseContinueMidi = () => {
    const transport = Tone.getTransport();

    if (!isPausedMidi.value) {
        pauseTime = transport.seconds;
        transport.pause();
        console.log("Transport paused at:", round(pauseTime), `, state: ${transport.state}`);
    } else {
        transport.seconds = pauseTime
        transport.start();
        console.log("Transport re-started at:", round(transport.seconds), `, state: ${transport.state}`);
    } 

    isPausedMidi.value = !isPausedMidi.value
}

const round = (n: number, decimals: number = 2) => Math.round(n * Math.pow(10, decimals)) / Math.pow(10, decimals)

关键操作日志

启动:Transport于23.51秒启动,tempo:120bpm
停止:Transport于30.86秒停止
启动:Transport于36.13秒启动,tempo:120bpm
暂停:Transport于42.12秒暂停,状态:paused
继续:Transport于42.12秒重启,状态:started
停止:Transport于45.9秒停止
启动:Transport于51.48秒启动,tempo:120bpm // 至此一切正常
调整tempo:Transport tempo设置为159 // 此处调整tempo
暂停:Transport于62.13秒暂停,状态:paused
继续:Transport于62.13秒重启,状态:started // 从错误位置继续
暂停:Transport于67.85秒暂停,状态:paused
继续:Transport于67.85秒重启,状态:started // 从暂停位置继续
停止:Transport于71.94秒停止
启动:Transport于85.32秒启动,tempo:159bpm // 延迟近15秒才启动,且未使用调整后的tempo
停止:Transport于100.21秒停止


解决方案

问题根源

  1. 重启延迟/原tempo问题:停止播放时仅停止transport并取消事件,但未重置transport内部时间,导致下次启动从之前的时间点继续,结合tempo变化造成时间映射错误;同时启动时用绝对时间Tone.now()调度事件,未与transport时间同步,tempo变化后事件时间未重新计算。
  2. 暂停继续位置错误:暂停时记录的transport.seconds是基于原tempo的时间,调整tempo后该时间对应的实际播放位置已变化,直接赋值会导致位置偏移。

修复代码及说明

1. 修正tempo设置函数(确保bpm立即生效)

const setTempo = (newTempo: number) => {
    // 取消之前的bpm自动化,立即设置新tempo
    Tone.getTransport().bpm.cancel();
    Tone.getTransport().bpm.value = newTempo;
    console.log(`Transport tempo set to ${round(Tone.getTransport().bpm.value, 0)}`)
};

2. 修正播放切换函数(停止时重置状态,启动时同步transport时间)

const togglePlayMidi = () => {
    isPlayingMidi.value = !isPlayingMidi.value 
    
    if (isPlayingMidi.value) {
        isPausedMidi.value = false;
        if (Tone.getTransport().state !== 'started') {
            // 重置transport时间到0,确保从开头启动
            Tone.getTransport().seconds = 0;
            Tone.getTransport().start(0);
            console.log(`Transport started at ${round(Tone.getTransport().seconds)} seconds, tempo: ${Tone.getTransport().bpm.value}bpm`)    
        }
        // 基于transport当前时间调度事件,而非绝对时间
        const transportTime = Tone.getTransport().seconds;
        if (currentMidiFile) {
            currentMidiFile.tracks.forEach((track, trk) => {
                const synth = new Tone.PolySynth(Tone.Synth, {
                    envelope: {
                        attack: 0.02,
                        decay: 0.1,
                        sustain: 0.3,
                        release: 1
                    }
                }).sync().connect(gain);
                synths.push(synth);
                // 只调度当前时间之后的事件,避免重复播放
                track.notes.forEach((note, idx) => {
                    if (note.time >= transportTime) {
                        synth.triggerAttackRelease(note.name, note.duration, note.time, note.velocity);
                    }
                });
            });
        }
    } else {
        if (isPausedMidi.value) {
            pauseContinueMidi();
        }                         
        console.log(`Transport stopped at ${round(Tone.getTransport().seconds)} seconds`);
        // 停止并完全重置transport状态
        Tone.getTransport().stop(0);
        Tone.getTransport().seconds = 0;
        Tone.getTransport().cancel();
             
        while (synths.length) {
            const synth = synths.shift();
            synth?.unsync();
            synth?.dispose();
        } 
    }
}

3. 修正暂停/继续函数(用节拍位置记录暂停点)

let pausePosition = "0:0:0"; // 基于节拍的位置,不受tempo变化影响

const pauseContinueMidi = () => {
    const transport = Tone.getTransport();

    if (!isPausedMidi.value) {
        // 记录节拍位置而非秒数
        pausePosition = transport.position;
        transport.pause();
        console.log("Transport paused at:", pausePosition, `, state: ${transport.state}`);
    } else {
        // 恢复到记录的节拍位置
        transport.position = pausePosition;
        transport.start();
        console.log("Transport re-started at:", transport.position, `, state: ${transport.state}`);
    } 

    isPausedMidi.value = !isPausedMidi.value
}

核心要点

  • 始终基于transport的内部时间或节拍位置调度事件,而非绝对时间,确保tempo变化时事件播放时间自动同步。
  • 停止播放后必须重置transport时间到0,避免下次启动从历史时间点继续。
  • 用position记录暂停点,它是基于MIDI节拍的位置,不受tempo变化影响,比秒数更准确。

内容的提问来源于stack exchange,提问作者apps2go

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 01:18:11