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

Safari/Chrome阻止AudioContext,Tone.js循环回调失效问题求助

Tone.js节拍器循环回调不执行及音频上下文阻塞问题

我正在开发一款基于Tone.js的节拍器,通过Tone.js的loop函数触发音效,并控制摆锤、节拍条等视觉元素。已尝试通过直接点击按钮恢复AudioContext,但每次循环回调后音频上下文仍会被阻塞。我考虑过预缓冲音效并单次启动,但这种方式无法动态修改音效(会产生卡顿)。目前无可见报错,代码中的getMetronomeLoopCallback函数始终无法执行。

相关代码如下:

let audioUnlocked = false; // 首次解锁上下文标记

const StartStopButton = observer(({metronomeManager}) => {
    const onClick = async () => {
        if (!audioUnlocked) {
            try {
                // 解锁音频上下文
                await Tone.start();
                await Tone.getContext().rawContext.resume();

                // 播放静音 - Safari专属解锁方式
                const silentSynth = new Tone.Synth().toDestination();
                silentSynth.triggerAttackRelease("C0", "1ms", Tone.now() + 0.05);

                audioUnlocked = true;
                console.log("🔓 音频上下文已解锁");
            } catch (e) {
                console.error("⚠️ 解锁音频上下文失败", e);
                return;
            }
        }

        if (metronomeManager.isPlaying) {
            // metronomeManager.stopMetronome();
        } else {
            metronomeManager.startMetronome();
        }
    };

    useHotkeys({
        " ": onClick,
    });

    return (
        <button
            id="start-stop-button"
            onClick={onClick}
        >
            {metronomeManager.isPlaying ? "停止" : "开始"}
        </button>
    );
});

startMetronome() {
    this._isPlaying = true;

    Tone.getTransport().bpm.value = this.bpm * 3;
    this._sequence = this.generateFixedMetronomeSequence();
    this._skipper = 0;

    this._loop = new Tone.Loop((time) => this.getMetronomeLoopCallback(time), '64n');
    this._loop.start(0);

    //TODO: 控制摆锤移动!
    //    this.elementsManager.movePendulum();
}

getMetronomeLoopCallback(time) {
    console.log("循环回调已启动");
    this._currentStep = this._count % this._sequence.length;
    this._isStartOfLoop = this._currentStep === 0;

    //TODO: 恢复训练模式
    // if (this.trainingModeManager.getIsTrainingMode()) {
    //     if (this._isStartOfLoop &&
    //         (this.trainingModeManager.getIsFirstLoop() || Math.random() < this.trainingModeManager.getLoopSkipProbability())) {
    //         this._skipper = this._sequence.length;
    //     }
    // }

    if (this._skipper > 0) {
        this._skipper--;
        if (this.trainingModeManager.getIsFirstLoop()) {
            this.playMetronomeStep(this._sequence, this._currentStep, time);
        }
        if (this._skipper === 0) {
            this.trainingModeManager.setIsFirstLoop(false);
        }
    } else {
        this.playMetronomeStep(this._sequence, this._currentStep, time);
    }

    if (this._isStartOfLoop) {
         this._loopCount += 1;
    }

    this._count++;
}

playMetronomeStep(sequence, currentStep, time) {
    const currentNote = sequence[currentStep];
    if (!currentNote || !currentNote.sound) return;
    if (!(this.trainingModeManager.getIsTrainingMode() && Math.random() < this.trainingModeManager.getNoteSkipProbability() && !this.trainingModeManager.getIsFirstLoop())) {
        console.log("执行到此处");
        const {sound, settings} = currentNote;

        // 动态将settings中的参数应用到sound
        for (const key in settings) {
            if (settings.hasOwnProperty(key)) {
                if (key in sound) {
                    // 参数存在于sound对象中(比如volume)
                    sound[key].value = settings[key];
                } else if (key in sound.oscillator) {
                    // 参数属于振荡器(比如frequency, detune, phase)
                    sound.oscillator[key] = settings[key];
                } else if (key in sound.envelope) {
                    // 参数属于包络(比如attack, decay, sustain, release)
                    sound.envelope[key] = settings[key];
                } else if (key in sound.filter) {
                    // 参数属于滤波器(比如filterFrequency, filterQ, filterType)
                    sound.filter[key] = settings[key];
                }
            }
        }

        // 触发音效
        sound.triggerAttackRelease('C4', '64n', time);

        // 视觉效果
        elements.flashingBar.style.opacity = 1;
        setTimeout(() => elements.flashingBar.style.opacity = 0, 100);

        const beatElement = document.querySelector(`.beat[data-beat="${currentNote.beatIndex}"]`);
        beatElement.classList.add('playing');
        setTimeout(() => beatElement.classList.remove('playing'), 100);
    }
}

问题修复方案

1. 启动Tone.Transport

Tone.Loop依赖Tone.Transport驱动运行,你的代码中仅启动了Loop但未启动Transport,这是回调不执行的关键原因。在startMetronome方法中添加:

Tone.getTransport().start();

放在this._loop.start(0);之后。

2. 简化音频上下文解锁逻辑

Tone.start()已经封装了恢复AudioContext的操作,无需额外调用rawContext.resume(),简化后的解锁代码:

await Tone.start();
const silentSynth = new Tone.Synth().toDestination();
silentSynth.triggerAttackRelease("C0", "1ms", Tone.now() + 0.05);
audioUnlocked = true;

3. 避免动态修改音效的卡顿

循环中直接修改Synth参数容易导致卡顿,建议提前创建不同预设的Synth实例,在需要时直接调用:

// 初始化时创建预设音效
this.soundPresets = {
  accent: new Tone.Synth({
    envelope: { attack: 0.01, release: 0.1 },
    oscillator: { type: 'sine' }
  }).toDestination(),
  normal: new Tone.Synth({
    envelope: { attack: 0.05, release: 0.2 },
    oscillator: { type: 'square' }
  }).toDestination()
};

// 在playMetronomeStep中替换动态修改逻辑
const preset = currentNote.preset || 'normal';
this.soundPresets[preset].triggerAttackRelease('C4', '64n', time);

4. 确保上下文绑定正确

虽然用箭头函数包裹了Loop回调,但为了避免this指向异常,建议在类的构造函数中绑定方法:

constructor() {
  this.getMetronomeLoopCallback = this.getMetronomeLoopCallback.bind(this);
  this.playMetronomeStep = this.playMetronomeStep.bind(this);
}

5. 调试辅助

在startMetronome中添加日志,确认Transport和Loop的运行状态:

console.log('Transport状态:', Tone.getTransport().state);
console.log('Loop状态:', this._loop.state);

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.13 12:32:02