使用 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); } };