使用 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…
- 安装
# 我的版本是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);
}
};