實現方式一:
vue中使用wavesurfer.js繪制波形圖和頻譜圖
安裝colorMap:
npm install --save colormap
1、單個頻譜圖
效果:
源碼:
<template><div class="spectrogram-container"><canvas ref="canvas" width="1140" height="150" style="background: #000"></canvas><div class="audio-controls"><audio ref="audioPlayer" controls @play="startPlay" controlsList="nodownload noplaybackrate"></audio></div></div>
</template><script>
import axios from 'axios'
import Vue from 'vue'
import { ACCESS_TOKEN } from '@/store/mutation-types'
import colormap from 'colormap'// 簡易 FFT 實現(Hamming 窗 + 蝶形運算)
// 簡易 FFT 實現(Hamming 窗 + 蝶形運算)
function createFFT(size) {const table = new Float32Array(size)for (let i = 0; i < size; i++) {table[i] = 0.54 - 0.46 * Math.cos((2 * Math.PI * i) / (size - 1)) // Hamming Window}return (input) => {const n = input.lengthconst logN = Math.log2(n)if (n !== 1 << logN) throw new Error('FFT length must be power of 2')const re = new Float32Array(n)const im = new Float32Array(n)for (let i = 0; i < n; i++) {re[i] = input[i] * table[i]im[i] = 0}// 位逆序置換for (let i = 1, j = 0; i < n - 1; i++) {let k = n >> 1while (j >= k) {j -= kk >>= 1}j += kif (i < j) {;[re[i], re[j]] = [re[j], re[i]];[im[i], im[j]] = [im[j], im[i]]}}// 蝶形計算for (let size = 2; size <= n; size <<= 1) {const half = size >> 1const angle = (-2 * Math.PI) / sizeconst w = [1, 0]const wStep = [Math.cos(angle), Math.sin(angle)]for (let i = 0; i < half; i++) {for (let j = i; j < n; j += size) {const l = j + halfconst tRe = w[0] * re[l] - w[1] * im[l]const tIm = w[0] * im[l] + w[1] * re[l]re[l] = re[j] - tReim[l] = im[j] - tImre[j] += tReim[j] += tIm}const tmp = w[0] * wStep[0] - w[1] * wStep[1]w[1] = w[0] * wStep[1] + w[1] * wStep[0]w[0] = tmp}}// ? 修復:使用 n/2,而不是外部不存在的 halfconst spectrum = new Float32Array(n / 2)for (let i = 0; i < n / 2; i++) {const mag = Math.sqrt(re[i] ** 2 + im[i] ** 2)spectrum[i] = Math.log10(mag + 1) * 100}return spectrum}
}export default {name: 'AudioWaveform',props: ['audioUrl'],data() {return {fileData: new Int8Array(0),isPlaying: false,sampleRate: 8000, // ? 改為你的實際采樣率interval: 100,index: 0,mWidth: 0,mHeight: 0,audio: null,animationId: null,// 頻譜相關spectrogram: [], // 頻譜數據:每一列是一個時間幀的頻譜colorMap: [], // colormap 生成的顏色fftSize: 1024, // FFT 大小(必須是 2 的冪)xSize: 300, // 頻譜圖最大列數(由 canvas 寬度決定)barWidth: 1, // 每一列的寬度binHeight: 1, // 每個頻率 bin 的高度}},watch: {audioUrl(newVal) {this.handleAudioUrl(newVal)},},mounted() {this.mWidth = this.$refs.canvas.widththis.mHeight = this.$refs.canvas.height// this.xSize = Math.ceil(this.mWidth / 2) // 最大列數this.xSize = Math.max(130, this.mWidth / 2) // 確保至少能顯示13秒的數據this.barWidth = Math.max(1, Math.floor(this.mWidth / this.xSize))this.binHeight = this.mHeight / (this.fftSize / 2)// 初始化 colormapthis.colorMap = colormap({colormap: 'magma', // 替換為上述任意名稱nshades: 256, // 顏色分段數format: 'rgbaString', // 輸出格式alpha: 1, // 透明度})this.audio = this.$refs.audioPlayerthis.handleAudioUrl(this.audioUrl)},methods: {// 計算使3.2秒音頻剛好撐滿屏幕的xSizecalculateXSizeFor3_2Seconds() {const targetDuration = 3.2 // 3.2秒const totalFrames = (this.sampleRate * targetDuration) / this.fftSize// 兩種計算方式確保精度:// 方式1:基于總樣本數this.xSize = Math.floor((this.sampleRate * targetDuration) / (this.fftSize / 2))// 方式2:基于畫布寬度和期望的時間分辨率// const timePerPixel = (targetDuration * 1000) / this.mWidth; // ms/px// this.xSize = Math.floor((targetDuration * 1000) / timePerPixel);// 限制最小和最大值this.xSize = Math.max(10, Math.min(this.xSize, this.mWidth))// 重新計算每列寬度this.barWidth = this.mWidth / this.xSizeconsole.log(`3.2秒顯示優化: 采樣率=${this.sampleRate}Hz, FFT大小=${this.fftSize}, 畫布寬度=${this.mWidth}px,最終xSize=${this.xSize},每列寬度=${this.barWidth}px`)},handleAudioUrl(audioUrl) {console.log('加載音頻:', audioUrl)if (!audioUrl) return// 停止當前播放并重置狀態this.resetComponent()if (audioUrl.endsWith('.pcm')) {this.loadPcmAudio(audioUrl)} else {this.downloadAudio(audioUrl)}},resetComponent() {this.stopPlayback()this.fileData = new Float32Array(0)this.index = 0this.spectrogram = []this.clearCanvas()// 釋放之前的音頻URLif (this.audio.src) {URL.revokeObjectURL(this.audio.src)this.audio.src = ''}},loadPcmAudio(url) {fetch(url, {method: 'GET',headers: { 'X-Mintti-Web-Token': Vue.ls.get(ACCESS_TOKEN) },}).then((res) => res.arrayBuffer()).then((buffer) => this.initAudioPlayer(buffer)).catch((err) => {console.error('PCM 加載失敗:', err)this.$message.warning('音頻加載失敗')})},downloadAudio(url) {axios.get(url, { responseType: 'arraybuffer' }).then((res) => this.initAudioPlayer(res.data)).catch((err) => {console.error('下載失敗:', err)this.$message.warning('音頻下載失敗')})},initAudioPlayer(arraybuffer) {const uint8 = new Uint8Array(arraybuffer)const isWav = uint8[0] === 82 && uint8[1] === 73 && uint8[2] === 70 // 'RIFF'const dataStart = isWav ? 44 : 0// 處理16位PCM數據const pcmData = new Int16Array(arraybuffer.slice(dataStart))this.fileData = new Float32Array(pcmData.length)// 16位PCM轉Float32 (-32768~32767 -> -1~1)for (let i = 0; i < pcmData.length; i++) {this.fileData[i] = pcmData[i] / 32768.0}// 創建WAV文件用于播放const wavHeader = this.createWavHeader(pcmData.length)const wavData = new Uint8Array(wavHeader.byteLength + arraybuffer.byteLength - dataStart)wavData.set(new Uint8Array(wavHeader), 0)wavData.set(uint8.subarray(dataStart), wavHeader.byteLength)const blob = new Blob([wavData], { type: 'audio/wav' })const url = URL.createObjectURL(blob)this.audio.src = urlthis.audio.load()// 監聽事件this.audio.addEventListener('play', () => (this.isPlaying = true))this.audio.addEventListener('pause', () => (this.isPlaying = false))this.audio.addEventListener('ended', () => this.stopPlayback())this.audio.addEventListener('seeked', () => {this.index = Math.floor((this.audio.currentTime * 1000) / this.interval)this.spectrogram = []})// 加載完成后計算優化參數this.calculateXSizeFor3_2Seconds()},// 創建WAV文件頭createWavHeader(dataLength) {const buffer = new ArrayBuffer(44)const view = new DataView(buffer)// RIFF標識this.writeString(view, 0, 'RIFF')// 文件長度view.setUint32(4, 36 + dataLength * 2, true)// WAVE標識this.writeString(view, 8, 'WAVE')// fmt子塊this.writeString(view, 12, 'fmt ')// fmt長度view.setUint32(16, 16, true)// 編碼方式: 1表示PCMview.setUint16(20, 1, true)// 聲道數view.setUint16(22, 1, true)// 采樣率view.setUint32(24, this.sampleRate, true)// 字節率view.setUint32(28, this.sampleRate * 2, true)// 塊對齊view.setUint16(32, 2, true)// 位深度view.setUint16(34, 16, true)// data標識this.writeString(view, 36, 'data')// data長度view.setUint32(40, dataLength * 2, true)return buffer},writeString(view, offset, string) {for (let i = 0; i < string.length; i++) {view.setUint8(offset + i, string.charCodeAt(i))}},startPlay() {if (this.audio && this.fileData.length > 0) {// 重置狀態if (this.audio.ended || this.index * this.interval >= this.fileData.length / (this.sampleRate / 1000)) {this.stopPlayback()}// 同步起始時間this.lastUpdateTime = performance.now()this.audio.play()this.isPlaying = truethis.timer()}},timer() {if (!this.isPlaying) return// 計算基于音頻時間的理想幀數const targetFrame = Math.floor((this.audio.currentTime * 1000) / this.interval)const maxCatchUpFrames = 5 // 最大追趕幀數,避免卡頓// 適度追趕,避免一次性處理太多幀導致卡頓let framesToUpdate = Math.min(targetFrame - this.index, maxCatchUpFrames)while (framesToUpdate > 0) {this.refreshData()framesToUpdate--}// 正常情況下一幀一幀更新if (framesToUpdate === 0 && this.index < targetFrame) {this.refreshData()}this.animationId = requestAnimationFrame(() => this.timer())},refreshData() {// 計算每幀推進的樣本數,使3.2秒剛好撐滿const samplesPerFrame = Math.floor((this.sampleRate * 3.2) / this.xSize)const start = this.index * samplesPerFrameconst end = start + this.fftSizeif (start >= this.fileData.length) {this.stopPlayback()return}let segmentif (end > this.fileData.length) {segment = new Float32Array(this.fftSize)segment.set(this.fileData.slice(start))} else {segment = this.fileData.slice(start, end)}// 執行FFTconst fft = createFFT(this.fftSize)const spectrum = fft(segment)// 歸一化const maxDB = 0,minDB = -80const normalized = spectrum.map((v) => {const dbValue = 20 * Math.log10(v + 1e-6)return Math.max(0, Math.min(255, Math.floor(((dbValue - minDB) / (maxDB - minDB)) * 255)))})// 更新頻譜數據if (this.spectrogram.length >= this.xSize) {this.spectrogram.shift()}this.spectrogram.push(normalized)this.drawSpectrogram()this.index += 1},drawSpectrogram() {const ctx = this.$refs.canvas.getContext('2d')const { width, height } = ctx.canvasctx.clearRect(0, 0, width, height)const dx = width / Math.max(this.xSize, this.spectrogram.length)for (let x = 0; x < this.spectrogram.length; x++) {const spec = this.spectrogram[x]const canvasX = width - (this.spectrogram.length - x) * dxfor (let y = 0; y < spec.length; y++) {// 使用對數縮放增強低頻顯示const freqIndex = Math.floor(Math.pow(y / spec.length, 0.7) * spec.length)const colorIdx = Math.max(0, Math.min(255, spec[freqIndex]))ctx.fillStyle = this.colorMap[colorIdx]// 低頻在底部,高頻在頂部const pixelY = height - y * (height / spec.length)ctx.fillRect(canvasX, pixelY, dx, height / spec.length)}}},stopPlayback() {this.isPlaying = falsethis.audio.pause()this.audio.currentTime = 0 // 重置播放位置this.index = 0 // 重置頻譜索引this.spectrogram = [] // 清空頻譜數據this.clearAnimation()this.clearCanvas()},clearAnimation() {if (this.animationId) {cancelAnimationFrame(this.animationId)this.animationId = null}},clearCanvas() {const ctx = this.$refs.canvas.getContext('2d')ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)},},beforeDestroy() {this.stopPlayback()if (this.audio.src) {URL.revokeObjectURL(this.audio.src)}},
}
</script><style scoped>
.spectrogram-container {display: flex;flex-direction: column;align-items: center;padding: 10px;font-family: Arial, sans-serif;
}
canvas {border: 1px solid #333;border-radius: 4px;
}
.audio-controls {margin-top: 10px;
}
</style>
2、波形圖+ 頻譜圖
波形圖參考:心電波形圖EcgView
效果圖:
源碼:
<template><div class="audio-visualizer"><div class="visualization-container"><div class="waveform-container"><canvas ref="waveformCanvas" width="1140" height="150"></canvas></div><div class="spectrogram-container"><canvas ref="spectrogramCanvas" width="1140" height="150" style="background: #000"></canvas></div></div><div class="audio-controls"><audio ref="audioPlayer" controls @play="startPlay" controlsList="nodownload noplaybackrate"></audio></div></div>
</template><script>
import axios from 'axios'
import Vue from 'vue'
import { ACCESS_TOKEN } from '@/store/mutation-types'
import colormap from 'colormap'export default {name: 'AudioWaveform',props: ['audioUrl'],data() {return {// 音頻數據相關fileData: new Int8Array(0),pcmData: new Float32Array(0),isPlaying: false,sampleRate: 8000,interval: 100, // 統一的時間間隔index: 0,// 波形圖相關waveformData: [],waveformCtx: null,waveformWidth: 1140,waveformHeight: 150,zoom: 0,gapX: 0.2,xSize: 0,maxMillimeter: 5 * 5,STEP_SIZE: 50,gain: 5,maxMidScopeY: 0,// 頻譜圖相關spectrogramCtx: null,spectrogramWidth: 1140,spectrogramHeight: 150,spectrogram: [],colorMap: [],fftSize: 1024,barWidth: 1,binHeight: 1,// 播放控制audio: null,animationId: null,lastTime: 0,}},watch: {audioUrl(newVal) {this.handleAudioUrl(newVal)},},mounted() {// 初始化波形圖this.waveformCtx = this.$refs.waveformCanvas.getContext('2d')this.waveformWidth = this.$refs.waveformCanvas.widththis.waveformHeight = this.$refs.waveformCanvas.heightthis.drawBg(this.waveformCtx)this.initWaveformParams()// 初始化頻譜圖this.spectrogramCtx = this.$refs.spectrogramCanvas.getContext('2d')this.spectrogramWidth = this.$refs.spectrogramCanvas.widththis.spectrogramHeight = this.$refs.spectrogramCanvas.heightthis.xSize = Math.max(130, this.spectrogramWidth / 2)this.barWidth = Math.max(1, Math.floor(this.spectrogramWidth / this.xSize))this.binHeight = this.spectrogramHeight / (this.fftSize / 2)// 初始化 colormapthis.colorMap = colormap({colormap: 'magma',nshades: 256,format: 'rgbaString',alpha: 1,})this.audio = this.$refs.audioPlayerif (this.audioUrl) {this.handleAudioUrl(this.audioUrl)}},methods: {// 初始化波形圖參數initWaveformParams() {this.zoom = this.waveformHeight / this.maxMillimeter// 計算每像素對應的秒數const secondsPerPixel = 0.04 / this.zoom// 計算gapX確保波形填滿畫布const samplesPerPixel = this.sampleRate * secondsPerPixelthis.gapX = Math.max(1, samplesPerPixel / this.STEP_SIZE)this.xSize = Math.ceil(this.waveformWidth / this.gapX)console.log(`波形參數:gapX=${this.gapX}, xSize=${this.xSize}`)},// 處理音頻URLhandleAudioUrl(audioUrl) {if (!audioUrl) returnthis.resetComponent()if (audioUrl.endsWith('.pcm')) {this.loadPcmAudio(audioUrl)} else {this.downloadAudio(audioUrl)}},// 重置組件狀態resetComponent() {this.stopPlayback()this.fileData = new Int8Array(0)this.pcmData = new Float32Array(0)this.index = 0this.waveformData = []this.spectrogram = []this.clearCanvas()if (this.audio && this.audio.src) {URL.revokeObjectURL(this.audio.src)this.audio.src = ''}},// 加載PCM音頻loadPcmAudio(url) {fetch(url, {method: 'GET',headers: { 'X-Mintti-Web-Token': Vue.ls.get(ACCESS_TOKEN) },}).then((res) => res.arrayBuffer()).then((buffer) => this.initAudioPlayer(buffer)).catch((err) => {console.error('PCM 加載失敗:', err)this.$message.warning('音頻加載失敗')})},// 下載音頻downloadAudio(url) {axios.get(url, { responseType: 'arraybuffer' }).then((res) => this.initAudioPlayer(res.data)).catch((err) => {console.error('下載失敗:', err)this.$message.warning('音頻下載失敗')})},// 初始化音頻播放器initAudioPlayer(arraybuffer) {const uint8 = new Uint8Array(arraybuffer)const isWav = uint8[0] === 82 && uint8[1] === 73 && uint8[2] === 70 // 'RIFF'const dataStart = isWav ? 44 : 0// 存儲原始文件數據this.fileData = new Int8Array(arraybuffer.slice(dataStart))// 處理16位PCM數據const pcmData = new Int16Array(arraybuffer.slice(dataStart))this.pcmData = new Float32Array(pcmData.length)// 16位PCM轉Float32 (-32768~32767 -> -1~1)for (let i = 0; i < pcmData.length; i++) {this.pcmData[i] = pcmData[i] / 32768.0}// 創建WAV文件用于播放const wavHeader = this.createWavHeader(pcmData.length)const wavData = new Uint8Array(wavHeader.byteLength + arraybuffer.byteLength - dataStart)wavData.set(new Uint8Array(wavHeader), 0)wavData.set(uint8.subarray(dataStart), wavHeader.byteLength)const blob = new Blob([wavData], { type: 'audio/wav' })const url = URL.createObjectURL(blob)this.audio.src = urlthis.audio.load()// 監聽事件this.audio.addEventListener('play', () => (this.isPlaying = true))this.audio.addEventListener('pause', () => (this.isPlaying = false))this.audio.addEventListener('ended', () => this.stopPlayback())this.audio.addEventListener('seeked', () => {this.index = Math.floor((this.audio.currentTime * 1000) / this.interval)this.waveformData = []this.spectrogram = []})},// 創建WAV文件頭createWavHeader(dataLength) {const buffer = new ArrayBuffer(44)const view = new DataView(buffer)// RIFF標識this.writeString(view, 0, 'RIFF')// 文件長度view.setUint32(4, 36 + dataLength * 2, true)// WAVE標識this.writeString(view, 8, 'WAVE')// fmt子塊this.writeString(view, 12, 'fmt ')// fmt長度view.setUint32(16, 16, true)// 編碼方式: 1表示PCMview.setUint16(20, 1, true)// 聲道數view.setUint16(22, 1, true)// 采樣率view.setUint32(24, this.sampleRate, true)// 字節率view.setUint32(28, this.sampleRate * 2, true)// 塊對齊view.setUint16(32, 2, true)// 位深度view.setUint16(34, 16, true)// data標識this.writeString(view, 36, 'data')// data長度view.setUint32(40, dataLength * 2, true)return buffer},writeString(view, offset, string) {for (let i = 0; i < string.length; i++) {view.setUint8(offset + i, string.charCodeAt(i))}},// 開始播放startPlay() {if (this.audio && this.fileData.length > 0) {// 重置狀態if (this.audio.ended || this.index * this.interval >= this.pcmData.length / (this.sampleRate / 1000)) {this.stopPlayback()}// 同步起始時間this.lastTime = performance.now()this.audio.play()this.isPlaying = truethis.timer()}},// 定時器timer() {if (!this.isPlaying) return// 計算基于音頻時間的理想幀數const targetFrame = Math.floor((this.audio.currentTime * 1000) / this.interval)const maxCatchUpFrames = 5 // 最大追趕幀數,避免卡頓// 適度追趕,避免一次性處理太多幀導致卡頓let framesToUpdate = Math.min(targetFrame - this.index, maxCatchUpFrames)while (framesToUpdate > 0) {this.refreshData()framesToUpdate--}// 正常情況下一幀一幀更新if (framesToUpdate === 0 && this.index < targetFrame) {this.refreshData()}this.animationId = requestAnimationFrame(() => this.timer())},// 刷新數據refreshData() {// 處理波形圖數據 - 每次更新1600字節數據const start = this.index * 1600const end = start + 1600if (start >= this.fileData.length) {this.stopPlayback()return}const byteArray = this.fileData.slice(start, end)const shortArray = new Int16Array(byteArray.length / 2)//遍歷 byteArray,將每兩個字節合并成一個短整型for (let i = 0; i < byteArray.length; i += 2) {shortArray[i / 2] = (byteArray[i] & 0xff) | ((byteArray[i + 1] & 0xff) << 8)}// 修改波形數據處理部分for (let i = 0; i < shortArray.length; i += this.STEP_SIZE) {// 限制波形數據長度,避免內存增長if (this.waveformData.length >= this.xSize * 2) {// 適當增加緩沖區this.waveformData.shift()}this.waveformData.push(shortArray[i])}// 處理頻譜圖數據 - 每次更新fftSize/2個樣本const fftStart = this.index * (this.fftSize / 2)const fftEnd = fftStart + this.fftSizelet segmentif (fftEnd > this.pcmData.length) {segment = new Float32Array(this.fftSize)segment.set(this.pcmData.slice(fftStart))} else {segment = this.pcmData.slice(fftStart, fftEnd)}// 執行FFTconst spectrum = this.fft(segment)// 歸一化const maxDB = 0,minDB = -80const normalized = spectrum.map((v) => {const dbValue = 20 * Math.log10(v + 1e-6)return Math.max(0, Math.min(255, Math.floor(((dbValue - minDB) / (maxDB - minDB)) * 255)))})// 更新頻譜數據if (this.spectrogram.length >= this.xSize) {this.spectrogram.shift()}this.spectrogram.push(normalized)// 繪制this.drawWaveform()this.drawSpectrogram()this.index += 1},// FFT實現fft(input) {const n = input.lengthconst logN = Math.log2(n)if (n !== 1 << logN) throw new Error('FFT length must be power of 2')// 應用Hamming窗const windowed = new Float32Array(n)for (let i = 0; i < n; i++) {windowed[i] = input[i] * (0.54 - 0.46 * Math.cos((2 * Math.PI * i) / (n - 1)))}const re = new Float32Array(n)const im = new Float32Array(n)for (let i = 0; i < n; i++) {re[i] = windowed[i]im[i] = 0}// 位逆序置換for (let i = 1, j = 0; i < n - 1; i++) {let k = n >> 1while (j >= k) {j -= kk >>= 1}j += kif (i < j) {;[re[i], re[j]] = [re[j], re[i]];[im[i], im[j]] = [im[j], im[i]]}}// 蝶形計算for (let size = 2; size <= n; size <<= 1) {const half = size >> 1const angle = (-2 * Math.PI) / sizeconst w = [1, 0]const wStep = [Math.cos(angle), Math.sin(angle)]for (let i = 0; i < half; i++) {for (let j = i; j < n; j += size) {const l = j + halfconst tRe = w[0] * re[l] - w[1] * im[l]const tIm = w[0] * im[l] + w[1] * re[l]re[l] = re[j] - tReim[l] = im[j] - tImre[j] += tReim[j] += tIm}const tmp = w[0] * wStep[0] - w[1] * wStep[1]w[1] = w[0] * wStep[1] + w[1] * wStep[0]w[0] = tmp}}// 計算幅度譜const spectrum = new Float32Array(n / 2)for (let i = 0; i < n / 2; i++) {const mag = Math.sqrt(re[i] ** 2 + im[i] ** 2)spectrum[i] = Math.log10(mag + 1) * 100}return spectrum},// 繪制波形圖drawWaveform() {const ctx = this.waveformCtxctx.clearRect(0, 0, this.waveformWidth, this.waveformHeight)this.drawBg(ctx)ctx.beginPath()ctx.lineWidth = 1ctx.strokeStyle = '#48a1e0'const len = this.waveformData.lengthconst mCenterY = this.waveformHeight / 2const maxPoints = Math.ceil(this.waveformWidth / this.gapX)// 計算可見數據點的起始索引const startIndex = Math.max(0, len - maxPoints)// 從右向左繪制所有可見點for (let i = startIndex; i < len; i++) {const y = Math.floor(this.calcRealMv(this.maxMidScopeY - this.waveformData[i]) * this.gain * this.zoom + mCenterY)// 關鍵修改:計算x坐標,確保波形可以從最右移動到最左const x = this.waveformWidth - (len - i) * this.gapXif (i === startIndex) {ctx.moveTo(x, y)} else {ctx.lineTo(x, y)}}ctx.stroke()},// 繪制頻譜圖drawSpectrogram() {const ctx = this.spectrogramCtxconst { width, height } = ctx.canvasctx.clearRect(0, 0, width, height)const dx = width / Math.max(this.xSize, this.spectrogram.length)for (let x = 0; x < this.spectrogram.length; x++) {const spec = this.spectrogram[x]const canvasX = width - (this.spectrogram.length - x) * dxfor (let y = 0; y < spec.length; y++) {// 使用對數縮放增強低頻顯示const freqIndex = Math.floor(Math.pow(y / spec.length, 0.7) * spec.length)const colorIdx = Math.max(0, Math.min(255, spec[freqIndex]))ctx.fillStyle = this.colorMap[colorIdx]// 低頻在底部,高頻在頂部const pixelY = height - y * (height / spec.length)ctx.fillRect(canvasX, pixelY, dx, height / spec.length)}}},// 繪制背景網格drawBg(ctx) {ctx.lineWidth = 1this.drawGrid(ctx, this.maxMillimeter)ctx.lineWidth = 2this.drawGrid(ctx, this.maxMillimeter / 5)},// 繪制網格drawGrid(ctx, cols) {const { width, height } = ctx.canvasctx.strokeStyle = '#ccc'const rowSpace = height / cols// 畫豎線for (let i = 0; i * rowSpace <= width; i++) {ctx.beginPath()ctx.moveTo(i * rowSpace, 0)ctx.lineTo(i * rowSpace, height)ctx.stroke()}// 畫橫線for (let i = 0; i <= cols; i++) {ctx.beginPath()ctx.moveTo(0, i * rowSpace)ctx.lineTo(width, i * rowSpace)ctx.stroke()}},// 計算實際電壓值calcRealMv(point) {return (point * 3.3) / 32767},// 停止播放stopPlayback() {this.isPlaying = falseif (this.audio) {this.audio.pause()this.audio.currentTime = 0}this.index = 0this.waveformData = []this.spectrogram = []this.clearAnimation()this.clearCanvas()},// 清除動畫clearAnimation() {if (this.animationId) {cancelAnimationFrame(this.animationId)this.animationId = null}},// 清除畫布clearCanvas() {this.waveformCtx.clearRect(0, 0, this.waveformWidth, this.waveformHeight)this.drawBg(this.waveformCtx)this.spectrogramCtx.clearRect(0, 0, this.spectrogramWidth, this.spectrogramHeight)},},beforeDestroy() {this.stopPlayback()if (this.audio && this.audio.src) {URL.revokeObjectURL(this.audio.src)}},
}
</script><style scoped>
.audio-visualizer {display: flex;flex-direction: column;align-items: center;padding: 2px;font-family: Arial, sans-serif;
}.visualization-container {width: 100%;display: flex;flex-direction: column;gap: 2px;
}.waveform-container canvas {border: 1px solid #333;border-radius: 4px;
}.spectrogram-container canvas {border: 1px solid #333;border-radius: 4px;
}.audio-controls {margin-top: 5px;width: 100%;
}audio {width: 100%;
}
</style>