使用 Tone.js 实现一个基本的音频播放器,该播放器不仅支持基本的播放控制,如播放、暂停、进度显示等等,还具备倍速播放和音高调整等功能

上一篇文章Antd Audio 自定义音频播放器基于 HTML5 的 Audio,实现了基本的音频播放器,也通过 Tone.js 实现了音高调整功能,但是该功能的实现没有和 Audio 的其他控制关联起来。

本文介绍如何使用 Tone.js 构建一个完整的音频播放器,并实现音高调整功能。

1. 安装及引入

Tone.js 是一个功能强大且易于使用的 Web Audio 库,专为创作交互式音乐和声音设计而设计。它提供了许多抽象层,使得在浏览器中制作复杂的音频应用程序变得更加容易,适合任何希望在网页上实现高质量音频和音乐体验的开发者。

Tone.js 的目标是为音乐家和音频工程师提供一种熟悉的工具集,类似于传统的数字音频工作站(DAW)软件。

Tone.js 官方文档:tonejs.github.io/docs/15.0.4…

  1. 安装
 1
 2npm install tone
  1. 引入
 1import * as Tone from "tone";

2. 创建播放器

  • Tone.Player: 是一个多功能组件,旨在播放音频文件,并具备开始、循环和停止播放等功能
    • onload:加载完成回调。自动加载音频文件,加载完成后,才能开始播放
    • onstop:停止播放回调。(注意:暂停/拖动进度条时,也会触发,无法当作播放结束事件处理)
    • start:开始播放事件
    • stop:停止播放事件
    • dispose:释放资源,当不再需要播放器时,记得调用 .dispose() 方法来释放资源
    • playbackRate:设置播放速率
    • volume:设置播放音量
    • mute:设置禁音/非禁音状态
    • seek:设置当前播放位置
    • duration:获取音频文件的总时长(player.current.buffer.duration)
    • loop:设置是否循环播放
  • Tone.PitchShift:Tone.js 中的一个效果器,对输入信号进行近乎实时的音调转换。该效果是通过调整 DelayNode 的延迟时间来实现的,具体来说,是使用锯齿波来周期性地加速或减速延迟时间,从而产生音高变化的效果。
    • dispose:释放资源
    • pitch:设置音调偏移量。参数可以是正数(升高音高)也可以是负数(降低音高)。例如,参数 0.5 表示升高半音,-1 表示降低一个全音。通过改变这个参数,可以实现实时的音高变换效果。
 1const player = useRef<Tone.Player | null>(null); 
 2const pitchShift = useRef<Tone.PitchShift | null>(null); 
 3const [playPitch, setPlayPitch] = useState(0); 
 4
 5useEffect(() => {
 6  if (!audioSrc) return;
 7
 8  
 9  player.current = new Tone.Player({
10    url: audioSrc, 
11    onload: () => {
12      
13      if (!player.current) return;
14      
15      setAllTime(player.current.buffer.duration);
16    },
17    onstop: (data) => {
18      
19      
20      console.log("Tone onstop:");
21    },
22    onerror: (error) => {
23      
24      console.error("Tone onerror:", error);
25    },
26  });
27  
28  
29  pitchShift.current = new Tone.PitchShift(0);
30  
31  
32  player.current.connect(pitchShift.current);
33  
34  
35  pitchShift.current.toDestination();
36
37  return () => {
38    
39    player.current?.dispose();
40    pitchShift.current?.dispose();
41  };
42}, [audioSrc]);

3. 播放/暂停

3.1 Player 处理

Player提供了startstop方法,分别用于开始和停止播放音频。

 1
 2const pauseOrPlay = () => {
 3  if (isPlay) {
 4    player.current.stop();
 5    setIsPlay(false);
 6  } else {
 7    player.current.start();
 8    setIsPlay(true);
 9  }
10};

3.2 Transport 处理

但是 Player 没有找到暂停的方法,start()方法每次都是从头开始播放的。后来查了资料,发现可以用Tone.getTransport()处理

Tone.Transport 通常与 Player 或 Synth 等其他 Tone.js 组件结合使用,以实现更复杂的音频同步和控制。例如,可以让多个音轨或音效同步启动和停止,或者根据节拍和时间签名来安排音符和效果。

  • Tone.getTransport()方法返回的是 Transport 实例,这个实例可以用来控制整个音频应用的节奏和同步,包括启动、停止、暂停、跳转以及节拍和时间签名的管理。
    • start([time]) - 开始播放,如果提供了 time 参数,它将从指定的时间开始播放。
    • stop([time]) - 停止播放
    • pause([time]) - 暂停播放
    • clear([eventId]) - 清除事件
    • position:控制当前播放位置

注意:使用前要先同步一下,player.current.sync().start(0)

注意:重新播放前,需要重置position

 1player.current = new Tone.Player({
 2  url: audioSrc,
 3  onload: () => {
 4    
 5    
 6    
 7    
 8    player.current.sync().start(0);
 9  },
10});
11
12const pauseOrPlay = () => {
13  if (isPlay) {
14    Tone.getTransport().pause();
15    setIsPlay(false);
16  } else {
17    
18    if (currentTime >= allTime) {
19      Tone.getTransport().position = 0;
20    }
21    Tone.getTransport().start();
22    setIsPlay(true);
23  }
24};

4. 禁音/取消禁音

 1
 2const onMuteAudio = () => {
 3  if (!player.current) return;
 4  setIsMuted(!isMuted);
 5  player.current.mute = !isMuted;
 6};

5. 音量控制

  • value / 100:将 value(介于 0 到 100 之间的百分比值)转换为 0 到 1 之间的范围,这是 Tone.Gain 节点期望的增益值范围。

  • Tone.gainToDb:这个函数将线性的增益值转换为分贝值。在内部,它使用以下公式:dB = 20 * log10(gain)

为什么使用分贝?因为分贝能够更好地反映人耳对音量变化的感知。例如,将音量增加一倍(线性增益从 1 增加到 2)在分贝中大约相当于增加了 6dB,而将音量增加到原来的十分之一(线性增益从 1 减少到 0.1)则相当于减少了 20dB。这种对数关系使得分贝成为描述音量变化的更直观的单位。

 1
 2const changeVolume = (value: number) => {
 3  if (!player.current) return;
 4  
 5  player.current.volume.value = Tone.gainToDb(value / 100);
 6  setVolume(value);
 7  setIsMuted(!value);
 8};

6. 倍速控制

 1
 2const changePlayRate = (num: number) => {
 3  if (!player.current) return;
 4  setPlayRate(num);
 5  player.current.playbackRate = num;
 6};

7. 音高控制

 1
 2const changePlayPitch = (num: number) => {
 3  if (pitchShift.current) {
 4    setPlayPitch(num);
 5    pitchShift.current.pitch = num;
 6  }
 7};

8. 播放进度条

8.1 Transport 处理

Player 播放器没有找到监听播放时间的事件,onstop 也无法确认播放结束,所以使用 Tone.Transport 处理

scheduleRepeat:用于按指定的时间间隔重复执行一个回调函数;interval 参数是 “16n”,表示每十六分音符执行一次回调。

 1useEffect(() => {
 2  const eventId = Tone.getTransport().scheduleRepeat((time) => {
 3    const currentTime = Tone.Time(time).toSeconds();
 4    console.log("Tone currentTime:", currentTime);
 5    
 6  }, "16n");
 7
 8  return () => {
 9    
10    Tone.getTransport().clear(eventId);
11  };
12}, [audioSrc]);

使用时,发现这个不准,停止时也在变动,无法作为进度条的控制。也没找到其他方式,就暂时放弃了

8.2 Audio timeupdate 处理

主要是文档太少了,可参考的资料也少,确实没找到其他方式处理进度条,但是功能还是得实现的呀。。。

最后无奈的处理方式,通过隐藏的 Audio 控件处理。使用了 Audio API 的 timeupdate 事件,通过监听音频的播放时间,实时更新进度条。具体处理逻辑可以看下上一篇文章进度条控制

注意:Audio 一直保持 muted 禁音处理,只用作进度条同步

所以其他事件(播放/暂停、禁音、音量、倍速等)中,也需要添加 audioRef.current 的处理逻辑,进行播放进度同步,我就不一一添加了。如下以 changeTime 为例:

 1
 2const changeTime = (value: number) => {
 3  if (!player.current) return;
 4  audioRef.current!.currentTime = value;
 5  
 6  player.current.seek(value);
 7  setCurrentTime(value);
 8  if (
 9    value === player.current.buffer.duration || 
10    value === audioRef.current!.duration 
11  ) {
12    
13    setIsPlay(false);
14  }
15};

9. 播放/暂停 bug 修复

(1)问题:测试时,如果同时初始化多个 MAudio 组件,会出现播放时,其他组件也会同时播放的情况。

(2)原因:

Tone.Transport是 Tone.js 中的全局控制器,它允许以一种统一的方式控制音频的播放、暂停、停止以及各种时间相关的操作。

使用player.current.sync().start(0)来同步播放器时,实际上是在告诉播放器与 Tone.Transport 的节奏和时间线保持一致。在同步之后,都是从时间点 0 开始的,按照 Transport 的节奏同时开始播放、暂停。

(3)解决:找了好久,没找到解决方法。。。,Transport 也没法用了

皇天不负有心人,最后终于发现了一个解决方案,还是使用 Player 来处理

  • start(time?, offset?, duration?):用于指定何时开始播放音频缓冲区(buffer),并且可以指定从缓冲区的哪个位置开始播放,以及播放的持续时间。
    • time:表示开始播放的时间
    • offset:从音频样本的开始位置偏移多少时间开始播放
    • duration:表示播放的持续时间
 1
 2const pauseOrPlay = () => {
 3  if (!player.current) return;
 4  if (isPlay) {
 5    player.current.stop();
 6    audioRef.current!.pause();
 7    setIsPlay(false);
 8  } else {
 9    if (
10      currentTime >= allTime ||
11      currentTime >= player.current.buffer.duration
12    ) {
13      
14      player.current.start(0);
15    } else {
16      
17      player.current.start(0, currentTime);
18    }
19    audioRef.current!.play();
20    setIsPlay(true);
21  }
22};
个人笔记记录 2021 ~ 2025