新的實現方式:vue使用Canvas繪制頻譜圖
安裝wavesurfer.js
npm install wavesurfer.js
第一版:
組件特點:
- 一次性加載好所有的數據;
<template><div class="audio-visualizer-container"><div class="visualization-container"><div ref="waveform" class="waveform"></div><div ref="spectrogram" class="spectrogram"></div><div v-if="loading" class="loading-indicator">音頻加載中...</div><div v-if="error" class="error-message">{{ error }}</div></div><div class="audio-controls"><audioref="audioPlayer"controls@play="startPlay"@pause="stopPlay"@seeked="handleSeek"controlsList="nodownload noplaybackrate"></audio></div></div>
</template><script>
import axios from 'axios'
import Vue from 'vue'
import { ACCESS_TOKEN } from '@/store/mutation-types'
import WaveSurfer from 'wavesurfer.js'
import Spectrogram from 'wavesurfer.js/dist/plugins/spectrogram.esm.js'
// https://juejin.cn/post/6979191645916889095export default {name: 'AudioWaveform',props: {audioUrl: {type: String,required: true,},},data() {return {wavesurfer: null,spectrogramPlugin: null,isPlaying: false,audioBlobUrl: null,loading: false,error: null,isUserInteraction: false, // 標記是否是用戶交互}},watch: {audioUrl(newVal) {this.handleAudioUrl(newVal)},},mounted() {this.initWaveSurfer()this.handleAudioUrl(this.audioUrl)},beforeDestroy() {this.cleanup()},methods: {async initWaveSurfer() {try {this.wavesurfer = WaveSurfer.create({container: this.$refs.waveform,waveColor: '#48a1e0',progressColor: '#25ebd7',cursorColor: '#333',// cursorWidth: 1,// barWidth: 2,// barRadius: 3,height: 150,sampleRate: 8000, // 明確指定采樣率// normalize: true,// backend: 'WebAudio',// renderFunction: (channels, ctx) => {// console.log('Custom render function called!') // 確保執行// // this.drawWaveform(ctx, channels[0]) // 使用第一個聲道數據繪制波形// const { width, height } = ctx.canvas// const channelData = channels[0] // 使用左聲道數據// const dataLength = channelData.length// const step = Math.max(1, Math.floor(dataLength / width)) // 確保步長≥1,避免除零// ctx.beginPath()// ctx.lineWidth = 1// ctx.strokeStyle = '#48a1e0' // 波形顏色// // 中心線位置(對稱波形)// const centerY = height / 2// for (let i = 0; i < width; i++) {// // 使用 step 控制數據采樣間隔// const dataIndex = Math.min(Math.floor(i * step), dataLength - 1) // 防止數組越界// const value = channelData[dataIndex] // 獲取振幅值(-1 到 1)// // 映射振幅到 Canvas 高度// const amplitude = value * centerY// console.log(`繪制點: x=${i},value=${value} amplitude=${amplitude} realMv=${this.calcRealMv(value)}`) // 調試輸出// const x = i// const y = centerY - amplitude // 向上為正,向下為負// if (i === 0) {// ctx.moveTo(x, y)// } else {// ctx.lineTo(x, y)// }// }// ctx.stroke() // 繪制路徑// ctx.closePath()// },})// 初始化頻譜圖插件this.spectrogramPlugin = this.wavesurfer.registerPlugin(Spectrogram.create({container: this.$refs.spectrogram,// labels: true,// labelsBackground: 'rgba(0,0,0,0.1)', //頻率標簽的背景height: 150,fftSamples: 1024,frequencyMax: 8000, //最大顯示頻率frequencyMin: 0, //顯示最小頻率colorMap: 'roseus',windowFunc: 'hann', // 使用漢寧窗函數alpha: 1, // 完全不透明}))this.wavesurfer.on('ready', () => {console.log('WaveSurfer ready')// this.$refs.spectrogram.style.height = '150px' // 強制設置高度if (this.wavesurfer && this.wavesurfer.backend) {this.wavesurfer.backend.setAudioElement(this.$refs.audioPlayer)}})this.wavesurfer.on('error', (err) => {console.error('WaveSurfer error:', err)this.error = '音頻處理錯誤: ' + err})// 監聽用戶交互事件this.wavesurfer.on('interaction', () => {this.isUserInteraction = true})// 監聽波形圖進度變化this.wavesurfer.on('timeupdate', (currentTime) => {if (this.isUserInteraction) {this.$refs.audioPlayer.currentTime = currentTimethis.isUserInteraction = false // 重置標志}})} catch (err) {console.error('初始化失敗:', err)this.error = '初始化失敗: ' + err.message}},calcRealMv(point) {return (point * 3.3) / 32767},async handleAudioUrl(audioUrl) {if (!audioUrl) returntry {this.loading = truethis.error = nullthis.resetPlayer()const arrayBuffer = audioUrl.endsWith('.pcm')? await this.loadPcmAudio(audioUrl): await this.loadRegularAudio(audioUrl)await this.loadAudio(arrayBuffer)} catch (err) {console.error('加載音頻失敗:', err)this.error = '加載音頻失敗: ' + err.message} finally {this.loading = false}},async loadPcmAudio(url) {try {const response = await fetch(url, {headers: {'X-Mintti-Web-Token': Vue.ls.get(ACCESS_TOKEN),},})if (!response.ok) throw new Error('HTTP錯誤: ' + response.status)const pcmData = await response.arrayBuffer()return this.convertPcmToWav(pcmData)} catch (err) {console.error('PCM轉換失敗:', err)throw new Error('PCM音頻處理失敗')}},async loadRegularAudio(url) {try {const response = await axios({method: 'get',url,responseType: 'arraybuffer',timeout: 10000,})return response.data} catch (err) {console.error('音頻下載失敗:', err)throw new Error('音頻下載失敗')}},async loadAudio(arrayBuffer) {return new Promise((resolve, reject) => {try {if (this.audioBlobUrl) {URL.revokeObjectURL(this.audioBlobUrl)}const blob = new Blob([arrayBuffer], { type: 'audio/wav' })this.audioBlobUrl = URL.createObjectURL(blob)this.$refs.audioPlayer.src = this.audioBlobUrlthis.wavesurfer.loadBlob(blob).then(() => {console.log('音頻加載完成')resolve()}).catch((err) => {console.error('WaveSurfer加載失敗:', err)reject(new Error('音頻解析失敗'))})} catch (err) {reject(err)}})},resetPlayer() {if (this.isPlaying) {this.stopPlay()}if (this.$refs.audioPlayer) {this.$refs.audioPlayer.src = ''}},cleanup() {if (this.audioBlobUrl) {URL.revokeObjectURL(this.audioBlobUrl)}if (this.wavesurfer) {this.wavesurfer.destroy()}},startPlay() {if (this.wavesurfer) {this.isPlaying = truethis.wavesurfer.play()}},stopPlay() {if (this.wavesurfer) {this.isPlaying = falsethis.wavesurfer.pause()}},handleSeek() {if (this.wavesurfer && this.$refs.audioPlayer) {const currentTime = this.$refs.audioPlayer.currentTimeconst duration = this.$refs.audioPlayer.durationif (duration > 0) {this.wavesurfer.seekTo(currentTime / duration)}}},convertPcmToWav(pcmData) {const sampleRate = 8000 // 使用標準采樣率const numChannels = 1const bitsPerSample = 16const byteRate = (sampleRate * numChannels * bitsPerSample) / 8const blockAlign = (numChannels * bitsPerSample) / 8const dataLength = pcmData.byteLengthconst buffer = new ArrayBuffer(44 + dataLength)const view = new DataView(buffer)// WAV頭部this.writeString(view, 0, 'RIFF')view.setUint32(4, 36 + dataLength, true)this.writeString(view, 8, 'WAVE')this.writeString(view, 12, 'fmt ')view.setUint32(16, 16, true)view.setUint16(20, 1, true) // PCM格式view.setUint16(22, numChannels, true)view.setUint32(24, sampleRate, true)view.setUint32(28, byteRate, true)view.setUint16(32, blockAlign, true)view.setUint16(34, bitsPerSample, true)this.writeString(view, 36, 'data')view.setUint32(40, dataLength, true)// 填充PCM數據const pcmView = new Uint8Array(pcmData)const wavView = new Uint8Array(buffer, 44)wavView.set(pcmView)return buffer},writeString(view, offset, string) {for (let i = 0; i < string.length; i++) {view.setUint8(offset + i, string.charCodeAt(i))}},},
}
</script><style scoped>
.audio-visualizer-container {position: relative;width: 100%;height: 100%;display: flex;flex-direction: column;
}.visualization-container {position: relative;flex: 1;display: flex;flex-direction: column;background-color: #f5f5f5;border-radius: 4px;overflow: hidden;
}.waveform,
.spectrogram {width: 100%;height: 150px;background-color: #fff;border-radius: 4px;box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}.loading-indicator,
.error-message {position: absolute;top: 50%;left: 50%;transform: translate(-50%, -50%);padding: 10px 20px;background-color: rgba(0, 0, 0, 0.7);color: white;border-radius: 4px;z-index: 10;font-size: 16px;text-align: center;
}.error-message {background-color: rgba(255, 0, 0, 0.7);
}.audio-controls {margin-top: 10px;
}audio {width: 100%;
}
</style>
改進版:
- 顯示加載進度;
- 先加載pcm文件,然后繪制波形圖,再繪制頻譜圖;
- 代碼更健壯,確保數據有效性;
<template><div class="audio-visualizer-container"><div class="visualization-container"><div ref="waveform" class="waveform"></div><div ref="spectrogram" class="spectrogram"></div><div v-if="loading" class="loading-indicator">音頻加載中... {{ progress }}%<div class="progress-bar"><div class="progress-fill" :style="{ width: progress + '%' }"></div></div></div><div v-if="error" class="error-message">{{ error }}</div></div><div class="audio-controls"><audioref="audioPlayer"controls@play="startPlay"@pause="stopPlay"@seeked="handleSeek"controlsList="nodownload noplaybackrate"></audio></div></div>
</template><script>
import axios from 'axios'
import Vue from 'vue'
import { ACCESS_TOKEN } from '@/store/mutation-types'
import WaveSurfer from 'wavesurfer.js'
import Spectrogram from 'wavesurfer.js/dist/plugins/spectrogram.esm.js'export default {name: 'AudioWaveform',props: {audioUrl: {type: String,required: true,},},data() {return {wavesurfer: null,spectrogramPlugin: null,isPlaying: false,audioBlobUrl: null,loading: false,error: null,isUserInteraction: false,progress: 0, // 新增加載進度百分比// 新增:請求控制器currentRequestController: null,}},watch: {audioUrl(newVal) {this.handleAudioUrl(newVal)},},mounted() {this.initWaveSurfer()this.handleAudioUrl(this.audioUrl)},beforeDestroy() {this.cleanup()},methods: {async initWaveSurfer() {// 銷毀舊實例if (this.wavesurfer) {this.wavesurfer.destroy()this.wavesurfer = null}// 創建新的 WaveSurfer 實例this.wavesurfer = WaveSurfer.create({container: this.$refs.waveform,waveColor: '#48a1e0',progressColor: '#25ebd7',cursorColor: '#333',height: 150,sampleRate: 8000,})// 創建并注冊頻譜圖插件const spectrogramPlugin = Spectrogram.create({container: this.$refs.spectrogram,height: 150,fftSamples: 1024,frequencyMax: 8000,frequencyMin: 0,colorMap: 'roseus',windowFunc: 'hann',alpha: 1,})await this.wavesurfer.registerPlugin(spectrogramPlugin)// 綁定事件this.wavesurfer.on('ready', () => {console.log('WaveSurfer 和 Spectrogram 加載完成')})this.wavesurfer.on('error', (err) => {console.error('WaveSurfer error:', err)this.error = '音頻處理錯誤: ' + err})// 監聽用戶交互事件this.wavesurfer.on('interaction', () => {this.isUserInteraction = true})// 監聽波形圖進度變化this.wavesurfer.on('timeupdate', (currentTime) => {if (this.isUserInteraction) {this.$refs.audioPlayer.currentTime = currentTimethis.isUserInteraction = false // 重置標志}})},calcRealMv(point) {return (point * 3.3) / 32767},async handleAudioUrl(audioUrl) {if (!audioUrl) returntry {// 1. 中止之前的請求if (this.currentRequestController) {this.currentRequestController.abort()}// 2. 創建新的控制器const controller = new AbortController()this.currentRequestController = controller// 3. 重置狀態this.resetComponentState()// 4. 初始化 WaveSurferawait this.initWaveSurfer()this.loading = truethis.progress = 0this.error = null// 5. 加載音頻const arrayBuffer = audioUrl.endsWith('.pcm')? await this.loadPcmAudio(audioUrl, controller): await this.loadRegularAudio(audioUrl, controller)await this.loadAudio(arrayBuffer)} catch (err) {if (err.name === 'AbortError') {console.log('請求已中止')return}console.error('加載音頻失敗:', err)this.error = '加載音頻失敗: ' + err.message} finally {this.loading = false}},async loadPcmAudio(url) {try {const response = await fetch(url, {headers: {'X-Mintti-Web-Token': Vue.ls.get(ACCESS_TOKEN),},})if (!response.ok) throw new Error('HTTP錯誤: ' + response.status)const pcmBlob = await response.blob()return new Promise((resolve, reject) => {const reader = new FileReader()reader.onload = () => resolve(reader.result)reader.onerror = () => reject(new Error('讀取PCM失敗'))reader.readAsArrayBuffer(pcmBlob)})} catch (err) {console.error('PCM轉換失敗:', err)throw new Error('PCM音頻處理失敗')}},async loadRegularAudio(url, controller) {try {const response = await axios({method: 'get',url,responseType: 'arraybuffer',timeout: 60000,signal: controller.signal,onDownloadProgress: (progressEvent) => {this.progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)},})return response.data} catch (err) {if (err.name === 'AbortError') {throw err}console.error('音頻下載失敗:', err)throw new Error('音頻下載失敗')}},async loadPcmAudio(url, controller) {try {const response = await fetch(url, {headers: {'X-Mintti-Web-Token': Vue.ls.get(ACCESS_TOKEN),},signal: controller.signal,})if (!response.ok) throw new Error('HTTP錯誤: ' + response.status)const pcmBlob = await response.blob()return new Promise((resolve, reject) => {const reader = new FileReader()reader.onload = () => resolve(reader.result)reader.onerror = () => reject(new Error('讀取PCM失敗'))reader.readAsArrayBuffer(pcmBlob)})} catch (err) {if (err.name === 'AbortError') {throw err}console.error('PCM轉換失敗:', err)throw new Error('PCM音頻處理失敗')}},async loadAudio(arrayBuffer) {return new Promise((resolve, reject) => {try {if (this.audioBlobUrl) {URL.revokeObjectURL(this.audioBlobUrl)}const blob = new Blob([arrayBuffer], { type: 'audio/wav' })this.audioBlobUrl = URL.createObjectURL(blob)this.$refs.audioPlayer.src = this.audioBlobUrlthis.wavesurfer.loadBlob(blob).then(() => {console.log('音頻加載完成')resolve()}).catch((err) => {console.error('WaveSurfer加載失敗:', err)reject(new Error('音頻解析失敗'))})} catch (err) {reject(err)}})},resetComponentState() {// 停止播放if (this.isPlaying) {this.stopPlay()}// 清空音頻源if (this.$refs.audioPlayer) {this.$refs.audioPlayer.src = ''}// 清空波形圖if (this.wavesurfer) {this.wavesurfer.empty()}// 重置狀態this.progress = 0this.error = nullthis.isUserInteraction = false// 如果你希望每次都重新初始化 WaveSurfer(可選)// this.cleanup()// this.initWaveSurfer()},resetPlayer() {if (this.isPlaying) {this.stopPlay()}if (this.$refs.audioPlayer) {this.$refs.audioPlayer.src = ''}},cleanup() {if (this.audioBlobUrl) {URL.revokeObjectURL(this.audioBlobUrl)}if (this.wavesurfer) {this.wavesurfer.destroy()}},startPlay() {if (this.wavesurfer) {this.isPlaying = truethis.wavesurfer.play()}},stopPlay() {if (this.wavesurfer) {this.isPlaying = falsethis.wavesurfer.pause()}},handleSeek() {if (this.wavesurfer && this.$refs.audioPlayer) {const currentTime = this.$refs.audioPlayer.currentTimeconst duration = this.$refs.audioPlayer.durationif (duration > 0) {this.wavesurfer.seekTo(currentTime / duration)}}},convertPcmToWav(pcmData) {const sampleRate = 8000const numChannels = 1const bitsPerSample = 16const byteRate = (sampleRate * numChannels * bitsPerSample) / 8const blockAlign = (numChannels * bitsPerSample) / 8const dataLength = pcmData.byteLengthconst buffer = new ArrayBuffer(44 + dataLength)const view = new DataView(buffer)this.writeString(view, 0, 'RIFF')view.setUint32(4, 36 + dataLength, true)this.writeString(view, 8, 'WAVE')this.writeString(view, 12, 'fmt ')view.setUint32(16, 16, true)view.setUint16(20, 1, true)view.setUint16(22, numChannels, true)view.setUint32(24, sampleRate, true)view.setUint32(28, byteRate, true)view.setUint16(32, blockAlign, true)view.setUint16(34, bitsPerSample, true)this.writeString(view, 36, 'data')view.setUint32(40, dataLength, true)const pcmView = new Uint8Array(pcmData)const wavView = new Uint8Array(buffer, 44)wavView.set(pcmView)return buffer},writeString(view, offset, string) {for (let i = 0; i < string.length; i++) {view.setUint8(offset + i, string.charCodeAt(i))}},},
}
</script><style>
.loading-indicator {position: absolute;top: 50%;left: 50%;transform: translate(-50%, -50%);color: #333;background: rgba(255, 255, 255, 0.9);padding: 10px 20px;border-radius: 8px;font-size: 14px;text-align: center;
}.progress-bar {width: 100%;height: 6px;background: #eee;margin-top: 8px;border-radius: 3px;overflow: hidden;
}.progress-fill {height: 100%;background: #48a1e0;transition: width 0.2s;
}.error-message {color: red;font-size: 14px;padding: 10px;background: #ffe5e5;border-radius: 4px;
}
</style>