实现方式一:
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>

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/diannao/93779.shtml
繁体地址,请注明出处:http://hk.pswp.cn/diannao/93779.shtml
英文地址,请注明出处:http://en.pswp.cn/diannao/93779.shtml

如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【Python系列】Flask 应用中的主动垃圾回收

博客目录一、Python 内存管理基础二、Flask 中手动触发 GC 的基本方法三、高级 GC 策略实现1. 使用装饰器进行请求级别的 GC2. 定期 GC 的实现四、Flask 特有的 GC 集成方式1. 使用 teardown_request 钩子2. 结合应用上下文管理五、智能 GC 策略六、注意事项与最佳实践七、替代…

Linux和shell

最快入门的方式是使用苹果系统。此外&#xff0c;累计补充学习&#xff1a;一、目录结构/bin&#xff0c;二进制文件 /boot&#xff0c;启动文件 /dev&#xff0c;设备文件 /home&#xff0c;主目录&#xff0c;一般外接包、安装包放在这里 /lib&#xff0c;库文件 /opt&#x…

告别内存泄漏:你的Rust语言30天征服计划

欢迎踏上Rust学习之旅&#xff01;第一周&#xff1a;奠定基础 (Week 1: Laying the Foundation)第1天&#xff1a;环境搭建与 “Hello, World!”核心概念: 安装Rust工具链 (rustup)&#xff0c;它包含了编译器rustc和包管理器Cargo。Cargo是你的好朋友&#xff0c;用于创建项目…

乱删文件,电脑不能开机,怎么办

相信不少朋友在清理电脑、释放空间时&#xff0c;都做过一件“后悔一整年”的事——乱删系统文件。本来只是想让电脑快点、干净点&#xff0c;结果第二天一开机&#xff1a;黑屏了、蓝屏了、无限重启了&#xff0c;甚至连桌面都见不到了&#xff01;很多用户在删文件时&#xf…

ICODE SLIX2有密钥保护的物流跟踪、图书馆管理ISO15693标签读写Delphi源码

本示例使用设备&#xff1a;https://item.taobao.com/item.htm?spma21dvs.23580594.0.0.6781645eXF3tm5&ftt&id959258149468 一、密钥认证 procedure TForm1.Button21Click(Sender: TObject); varctrlword:byte;passwordid:byte; //密钥类型status:byte; //存…

核环境特种机器人设备的抗辐照芯片选型方案

摘要&#xff1a;核能作为国家能源安全的重要组成部分&#xff0c;对工业自动化设备的稳定性和可靠性提出了极高的要求。机器人设备在涉核环境下的日常巡检、设备维护、应急响应等任务中发挥着不可替代的作用。然而&#xff0c;涉核环境&#xff0c;尤其是高能粒子的辐照效应&a…

Linux权限系统完全指南:从本质到安全实践

一、权限的本质&#xff1a;Linux安全的核心逻辑在Linux的多用户环境中&#xff0c;权限系统通过三个关键维度实现资源隔离&#xff1a;用户标识 (UID)&#xff1a;系统通过数字ID识别用户&#xff0c;root用户的UID固定为0组标识 (GID)&#xff1a;用户组机制实现批量权限管理…

养老院跌倒漏报率↓78%!陌讯多模态算法在智慧照护中的边缘计算优化

​摘要​​&#xff1a; 针对养老场景中复杂光照与遮挡导致的跌倒漏报问题&#xff0c;陌讯视觉算法通过多模态融合与边缘计算优化&#xff0c;实测显示在RK3588 NPU硬件上实现​​mAP0.5达89.3%​​&#xff0c;较基线模型提升28.5%&#xff0c;功耗降低至7.2W。本文解析其动态…

老年护理实训室建设方案:打造安全、规范、高效的实践教学核心平台

在老龄化社会加速发展的背景下&#xff0c;培养高素质、技能过硬的老年护理专业人才迫在眉睫。一个设计科学、功能完备的老年护理实训室&#xff0c;正是院校提升实践教学质量&#xff0c;对接行业需求的核心平台。本方案旨在构建一个安全、规范、高效的现代化实训环境。点击获…

OpenCv中的 KNN 算法实现手写数字的识别

目录 一.案例&#xff1a;手写数字的识别 1.安装opencv-python库 2.将大图分割成10050个小图&#xff0c;每份对应一个手写数字样品 3.训练集和测试集 4.为训练集和测试集准备结果标签 5.模型训练与预测 6.计算准确率 7.完整代码实现 一.案例&#xff1a;手写数字的识别…

TCP/IP 传输层详解

TCP/IP 传输层详解 传输层&#xff08;Transport Layer&#xff09;是 TCP/IP 模型的第四层&#xff08;对应 OSI 模型的传输层&#xff09;&#xff0c;核心功能是实现 端到端&#xff08;进程到进程&#xff09;的可靠通信。主要协议包括&#xff1a; TCP&#xff08;传输控制…

深度学习笔记:Overview

本文根据吴恩达老师的深度学习课程整理而来&#xff0c;在此表示感知。 文章目录1.课程笔记2.编程作业1.课程笔记 1&#xff09;深度学习笔记&#xff08;1&#xff09;&#xff1a;神经网络基础 2&#xff09;深度学习笔记&#xff08;2&#xff09;&#xff1a;浅层神经网络…

LLM之RAG理论(十八)| ChatGPT DeepResearch 深度研究功能全面技术分析报告

一、背景与行业环境1.1 DeepResearch 的诞生与战略意义ChatGPT DeepResearch&#xff08;深度研究&#xff09;是 OpenAI 于 2025 年 2 月 3 日正式发布的全新 AI 智能体产品&#xff0c;是继 o3-mini 模型发布后&#xff0c;OpenAI 在 AI 研究领域的又一重大突破。这一功能的推…

数据库学习--------数据库日志类型及其与事务特性的关系

在数据库系统中&#xff0c;日志是保证数据可靠性和一致性的重要组成部分&#xff0c;尤其与事务的特性紧密相连。无论是事务的原子性、一致性&#xff0c;还是持久性&#xff0c;都离不开日志的支持。数据库日志&#xff08;Database Log&#xff09;是数据库系统记录自身操作…

如何在 Ubuntu 24.04 或 22.04 LTS 上安装 OpenShot 视频编辑器

OpenShot 视频编辑器是一款轻量级工具,不需要高性能硬件即可编辑视频。它最初是一个爱好项目,后来成为一款拥有简单干净用户界面的流行免费编辑工具。这款直观的视频编辑器可以剪辑影片,并添加额外的视频和音频素材。最终,您可以将作品导出为您选择的格式。本教程将向您展示…

SpringMVC核心原理与实战指南

什么是MVC&#xff1f; MVC英文是Model View Controller&#xff0c;是模型(model)&#xff0d;视图(view)&#xff0d;控制器(controller)的缩写&#xff0c;一种软件设计规范。 MVC是用一种业务逻辑、数据、界面显示分离的方法&#xff0c;将业务逻辑聚集到一个部件里面&am…

【JavaEE】(7) 网络原理 TCP/IP 协议

一、应用层 应用层是程序员最关心的一层&#xff0c;需要自定义数据传输的格式&#xff0c;即前&#xff08;客户端&#xff09;后&#xff08;服务器&#xff09;端交互的接口&#xff0c;然后调用传输层的 socket api 来实现网络通信。 自定义数据传输的协议&#xff0c;主要…

深入理解 Slab / Buddy 分配器与 MMU 映射机制

&#x1f4d6; 推荐阅读&#xff1a;《Yocto项目实战教程:高效定制嵌入式Linux系统》 &#x1f3a5; 更多学习视频请关注 B 站&#xff1a;嵌入式Jerry 深入理解 Slab / Buddy 分配器与 MMU 映射机制 在现代 Linux 内核中&#xff0c;物理内存的管理和虚拟地址的映射是系统性能…

Layui核心语法快速入门指南

Layui 基本语法学习指南 Layui 是一个经典的模块化前端框架&#xff0c;以其轻量易用、组件丰富著称。以下是 Layui 的核心语法结构和使用方法&#xff1a; 一、模块加载机制&#xff08;核心基础&#xff09; // 标准模块加载语法 layui.use([module1, module2], function()…