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

Tone.js多音符播放与Loop节拍器不同步问题求助

问题描述

我尝试将乐谱导入Tone.js,但同时启用节拍器时,tempo听起来不准确。我发现这与乐谱的音符数量有关:音符数量少时正常;实际场景中我使用了4n、8n.、t16等复杂节拍细分。为简化问题,我创建了仅使用8n的测试脚本仍能复现该问题,说明并非节拍细分计算错误。

测试代码基于官方示例修改:

节拍器函数

function metronome() {
  // 创建两个单音合成器
  const synthA = new Tone.Synth().toDestination();
  const synthB = new Tone.Synth().toDestination();
  // 每小节播放一个音符
  const loopA = new Tone.Loop((time) => {
    synthA.triggerAttackRelease("C5b", "8n", time);
  }, "1m").start(0);
  // 每四分音符播放另一个音符,从0时刻开始
  const loopB = new Tone.Loop((time) => {
    synthB.triggerAttackRelease("C4b", "8n", time);
  }, "4n").start(0);
  // 启动Transport时所有循环开始播放
  Tone.getTransport().start();
}

乐谱播放函数

function play() {
  const synth = new Tone.Synth().toDestination();
  const now = Tone.now();
  Array(4)
    .fill(['C3', 'D3', 'E3', 'F3', 'G3', 'A3', 'B3', 'C4'])
    .flat()
    .forEach((note, i) => {
      const subdivision = '8n'
      const duration = Tone.Time(subdivision).toSeconds() * i + now
      synth.triggerAttackRelease(note, "8n", duration);
    })
}

触发逻辑

document.querySelector('#play').addEventListener('click', () => {
  play()
  metronome()
})

当Array(4)生成32个音符时,所有声音与tempo匹配正常,但改为Array(40)生成320个音符时,tempo就不再匹配。请问我在使用Tone.js时哪里出现了错误?


问题原因与解决方案

核心问题

你手动计算音符时间的方式和Tone.js的Transport时序系统不兼容。当大量调用triggerAttackRelease并传入手动计算的时间时,会导致音频调度队列过载,打乱Transport的时序精度。

Tone.now()返回的是音频上下文的当前时间,而Transport的时序是基于自身维护的节拍、速度机制,手动累加时间的方式没有和Transport时钟同步,音符数量越多,调度延迟的累积效应越明显,最终导致节拍器和乐谱播放不同步。

正确实现方式

统一使用Tone.js的Transport系统调度所有音符,用Tone.Part批量管理乐谱事件,让它和节拍器共享同一个时钟:

修改play函数如下:

function play() {
  const synth = new Tone.Synth().toDestination();
  // 创建Part管理所有音符事件
  const notesPart = new Tone.Part((time, note) => {
    synth.triggerAttackRelease(note, "8n", time);
  }, []);

  // 生成音符序列
  const noteSequence = Array(40)
    .fill(['C3', 'D3', 'E3', 'F3', 'G3', 'A3', 'B3', 'C4'])
    .flat();
  
  // 基于Transport节拍位置定义每个音符的触发时间
  noteSequence.forEach((note, i) => {
    notesPart.at(`${i * 0.5}n`, note);
  });

  // 让Part和Transport同步启动
  notesPart.start(0);
}

额外优化点

  1. 复用合成器实例:避免每次调用函数都创建新合成器,减少资源浪费:
  2. 统一启动时机:先初始化所有调度,再启动Transport,确保所有事件同步触发:

优化后的完整代码:

// 全局复用合成器
const metronomeSynthA = new Tone.Synth().toDestination();
const metronomeSynthB = new Tone.Synth().toDestination();
const playSynth = new Tone.Synth().toDestination();

let loopA, loopB, notesPart;

function initMetronome() {
  loopA = new Tone.Loop((time) => {
    metronomeSynthA.triggerAttackRelease("C5b", "8n", time);
  }, "1m").start(0);
  
  loopB = new Tone.Loop((time) => {
    metronomeSynthB.triggerAttackRelease("C4b", "8n", time);
  }, "4n").start(0);
}

function initPlay() {
  notesPart = new Tone.Part((time, note) => {
    playSynth.triggerAttackRelease(note, "8n", time);
  }, []);

  const noteSequence = Array(40)
    .fill(['C3', 'D3', 'E3', 'F3', 'G3', 'A3', 'B3', 'C4'])
    .flat();
  
  noteSequence.forEach((note, i) => {
    notesPart.at(`${i * 0.5}n`, note);
  });

  notesPart.start(0);
}

document.querySelector('#play').addEventListener('click', () => {
  initMetronome();
  initPlay();
  // 统一启动Transport
  Tone.getTransport().start();
});

这样所有音频事件都由Transport统一调度,无论音符数量多少,时序都会完全同步,不会出现tempo不匹配的问题。


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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.18 21:53:18