使用 Tone.js 实现一个基本的音频播放器,该播放器不仅支持基本的播放控制,如播放、暂停、进度显示等等,还具备倍速播放和音高调整等功能
本文介绍如何使用 Tone.js 构建一个完整的音频播放器,并实现音高调整功能。
1. 安装及引入
Tone.js 是一个功能强大且易于使用的 Web Audio 库,专为创作交互式音乐和声音设计而设计。它提供了许多抽象层,使得在浏览器中制作复杂的音频应用程序变得更加容易,适合任何希望在网页上实现高质量音频和音乐体验的开发者。
Tone.js 的目标是为音乐家和音频工程师提供一种熟悉的工具集,类似于传统的数字音频工作站(DAW)软件。
Tone.js 官方文档:tonejs.github.io/docs/15.0.4…
安装
# 我的版本是v15.0.4 npm install tone
引入
import * 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 表示降低一个全音。通过改变这个参数,可以实现实时的音高变换效果。
const player = useRef<Tone.Player | null>(null); // 播放器 const pitchShift = useRef<Tone.PitchShift | null>(null); // 音高效果器 const [playPitch, setPlayPitch] = useState(0); // 音高控制 useEffect(() => { if (!audioSrc) return; // 1. 创建 Tone.Player 实例,只在首次调用时创建 player.current = new Tone.Player({ url: audioSrc, // 音频文件的 URL onload: () => { // 当音频加载完成时执行的回调函数 if (!player.current) return; // 获取音频总时长 setAllTime(player.current.buffer.duration); }, onstop: (data) => { // 当播放停止时执行的回调函数 // 注意:暂停/拖动进度条时,也会触发,无法当作播放结束事件处理 console.log("Tone onstop:"); }, onerror: (error) => { // 当加载音频文件出错时执行的回调函数 console.error("Tone onerror:", error); }, }); // 2. 创建 PitchShift 节点,参数为音高偏移量 // 初始音高偏移量为 0,意味着没有音高变化 pitchShift.current = new Tone.PitchShift(0); // 3. 将 Player 的输出连接到 PitchShift 节点 // 这样音频数据会先经过 PitchShift 处理,再输出 player.current.connect(pitchShift.current); // 4. 将 PitchShift 节点的输出连接到音频上下文的目的地 // 这是音频输出的最终目的地,通常是用户的扬声器 pitchShift.current.toDestination(); return () => { // 清理资源,释放 Player 和 PitchShift 实例占用的资源 player.current?.dispose(); pitchShift.current?.dispose(); }; }, [audioSrc]);
3. 播放/暂停
3.1 Player 处理
Player
提供了start
和stop
方法,分别用于开始和停止播放音频。
// 播放或者暂停 const pauseOrPlay = () => { if (isPlay) { player.current.stop(); setIsPlay(false); } else { player.current.start(); setIsPlay(true); } };
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
player.current = new Tone.Player({ url: audioSrc, onload: () => { // 设置循环播放 // player.current.loop = true; // 在音频加载完成后,与Transport同步 // 注意:要加该代码,Tone.getTransport().start()才能起作用 player.current.sync().start(0); }, }); const pauseOrPlay = () => { if (isPlay) { Tone.getTransport().pause(); setIsPlay(false); } else { // 注意:确保在开始播放前,position被重置为0,才能开始重新播放 if (currentTime >= allTime) { Tone.getTransport().position = 0; } Tone.getTransport().start(); setIsPlay(true); } };
4. 禁音/取消禁音
//禁音/取消禁音 const onMuteAudio = () => { if (!player.current) return; setIsMuted(!isMuted); player.current.mute = !isMuted; };
5. 音量控制
value / 100
:将 value(介于 0 到 100 之间的百分比值)转换为 0 到 1 之间的范围,这是 Tone.Gain 节点期望的增益值范围。Tone.gainToDb
:这个函数将线性的增益值转换为分贝值。在内部,它使用以下公式:dB = 20 * log10(gain)
为什么使用分贝?因为分贝能够更好地反映人耳对音量变化的感知。例如,将音量增加一倍(线性增益从 1 增加到 2)在分贝中大约相当于增加了 6dB,而将音量增加到原来的十分之一(线性增益从 1 减少到 0.1)则相当于减少了 20dB。这种对数关系使得分贝成为描述音量变化的更直观的单位。
// 改变音量 const changeVolume = (value: number) => { if (!player.current) return; // 将百分比音量值转换为分贝,就可以在Tone.js的音频处理链中使用了 player.current.volume.value = Tone.gainToDb(value / 100); setVolume(value); setIsMuted(!value); };
6. 倍速控制
// 播放倍数 const changePlayRate = (num: number) => { if (!player.current) return; setPlayRate(num); player.current.playbackRate = num; };
7. 音高控制
// 播放音高 const changePlayPitch = (num: number) => { if (pitchShift.current) { setPlayPitch(num); pitchShift.current.pitch = num; } };
8. 播放进度条
8.1 Transport 处理
Player 播放器没有找到监听播放时间的事件,onstop 也无法确认播放结束,所以使用 Tone.Transport
处理
scheduleRepeat
:用于按指定的时间间隔重复执行一个回调函数;interval 参数是 "16n",表示每十六分音符执行一次回调。
useEffect(() => { const eventId = Tone.getTransport().scheduleRepeat((time) => { const currentTime = Tone.Time(time).toSeconds(); console.log("Tone currentTime:", currentTime); // setCurrentTime(currentTime); }, "16n"); return () => { // 清理资源 Tone.getTransport().clear(eventId); }; }, [audioSrc]);
使用时,发现这个不准,停止时也在变动,无法作为进度条的控制。也没找到其他方式,就暂时放弃了
8.2 Audio timeupdate 处理
主要是文档太少了,可参考的资料也少,确实没找到其他方式处理进度条,但是功能还是得实现的呀。。。
最后无奈的处理方式,通过隐藏的 Audio 控件处理。使用了 Audio API 的 timeupdate 事件,通过监听音频的播放时间,实时更新进度条。
注意:Audio 一直保持 muted 禁音
处理,只用作进度条同步
所以其他事件(播放/暂停、禁音、音量、倍速等)中,也需要添加 audioRef.current 的处理逻辑,进行播放进度同步,我就不一一添加了。如下以 changeTime 为例:
// 修改播放时间 const changeTime = (value: number) => { if (!player.current) return; audioRef.current!.currentTime = value; // 控制播放进度 player.current.seek(value); setCurrentTime(value); if ( value === player.current.buffer.duration || // Tone.js播放器总时长 value === audioRef.current!.duration // 音频播放器总时长 ) { // 上述两个总时长不是完全相等的,有些误差 setIsPlay(false); } };
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:表示播放的持续时间
// 播放或者暂停 const pauseOrPlay = () => { if (!player.current) return; if (isPlay) { player.current.stop(); audioRef.current!.pause(); setIsPlay(false); } else { if ( currentTime >= allTime || currentTime >= player.current.buffer.duration ) { // 重新播放 player.current.start(0); } else { // 继续播放,使用offset控制 player.current.start(0, currentTime); } audioRef.current!.play(); setIsPlay(true); } };