2026-01-15 20:10:19 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="spectrogram-viz">
|
|
|
|
|
|
<el-card shadow="never">
|
|
|
|
|
|
<div class="viz-layout">
|
|
|
|
|
|
<!-- Left: Waveform -->
|
|
|
|
|
|
<div class="viz-box">
|
|
|
|
|
|
<div class="viz-header">
|
|
|
|
|
|
<span class="viz-title">🌊 波形 (Waveform)</span>
|
2026-02-18 17:38:10 +08:00
|
|
|
|
<el-tag
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
type="success"
|
|
|
|
|
|
>
|
|
|
|
|
|
Time Domain
|
|
|
|
|
|
</el-tag>
|
2026-01-15 20:10:19 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="viz-content waveform-container">
|
|
|
|
|
|
<div class="wave-bars">
|
2026-01-16 19:10:21 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-for="n in 30"
|
|
|
|
|
|
:key="n"
|
|
|
|
|
|
class="wave-bar"
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
height: 20 + Math.random() * 60 + '%',
|
|
|
|
|
|
animationDelay: n * 0.05 + 's'
|
|
|
|
|
|
}"
|
2026-02-18 17:38:10 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="axis-label x-axis">
|
|
|
|
|
|
时间 (Time) →
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="axis-label y-axis">
|
|
|
|
|
|
振幅 (Amplitude) ↑
|
2026-01-15 20:10:19 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="transform-arrow">
|
|
|
|
|
|
<div class="arrow-content">
|
|
|
|
|
|
<span class="fft-text">FFT 变换</span>
|
|
|
|
|
|
<el-icon><Right /></el-icon>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Right: Spectrogram -->
|
|
|
|
|
|
<div class="viz-box">
|
|
|
|
|
|
<div class="viz-header">
|
|
|
|
|
|
<span class="viz-title">🎨 频谱图 (Spectrogram)</span>
|
2026-02-18 17:38:10 +08:00
|
|
|
|
<el-tag
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
type="warning"
|
|
|
|
|
|
>
|
|
|
|
|
|
Freq Domain
|
|
|
|
|
|
</el-tag>
|
2026-01-15 20:10:19 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="viz-content spectrogram-container">
|
2026-02-18 17:38:10 +08:00
|
|
|
|
<canvas
|
|
|
|
|
|
ref="canvasRef"
|
|
|
|
|
|
width="200"
|
|
|
|
|
|
height="100"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="axis-label x-axis">
|
|
|
|
|
|
时间 (Time) →
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="axis-label y-axis">
|
|
|
|
|
|
频率 (Freq) ↑
|
|
|
|
|
|
</div>
|
2026-01-15 20:10:19 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<el-divider />
|
|
|
|
|
|
|
|
|
|
|
|
<el-alert
|
|
|
|
|
|
title="像看乐谱一样看声音"
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
show-icon
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #default>
|
|
|
|
|
|
<div class="legend">
|
|
|
|
|
|
<div class="legend-item">
|
2026-02-18 17:38:10 +08:00
|
|
|
|
<div class="color-box low" />
|
2026-01-16 19:10:21 +08:00
|
|
|
|
低能量 (安静)
|
2026-01-15 20:10:19 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="legend-item">
|
2026-02-18 17:38:10 +08:00
|
|
|
|
<div class="color-box high" />
|
2026-01-16 19:10:21 +08:00
|
|
|
|
高能量 (响亮)
|
2026-01-15 20:10:19 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-16 19:10:21 +08:00
|
|
|
|
<p>
|
|
|
|
|
|
频谱图将一维的声音信号变成了二维图像,这样我们就可以用
|
|
|
|
|
|
<strong>CNN (卷积神经网络)</strong> 等图像模型来处理声音了!
|
|
|
|
|
|
</p>
|
2026-01-15 20:10:19 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</el-alert>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { ref, onMounted } from 'vue'
|
|
|
|
|
|
import { Right } from '@element-plus/icons-vue'
|
|
|
|
|
|
|
|
|
|
|
|
const canvasRef = ref(null)
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
drawSpectrogram()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const drawSpectrogram = () => {
|
|
|
|
|
|
const canvas = canvasRef.value
|
|
|
|
|
|
if (!canvas) return
|
|
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
|
|
|
|
const width = canvas.width
|
|
|
|
|
|
const height = canvas.height
|
|
|
|
|
|
|
|
|
|
|
|
// Draw heatmap
|
|
|
|
|
|
for (let x = 0; x < width; x += 4) {
|
|
|
|
|
|
for (let y = 0; y < height; y += 4) {
|
|
|
|
|
|
// Simulate frequency energy distribution
|
|
|
|
|
|
// Low frequencies (bottom) have more energy generally
|
|
|
|
|
|
// High frequencies (top) have less
|
|
|
|
|
|
const normalizedY = 1 - y / height
|
|
|
|
|
|
const baseEnergy = normalizedY * 0.8
|
|
|
|
|
|
const noise = Math.random() * 0.2
|
|
|
|
|
|
const timeVar = Math.sin(x * 0.1) * 0.2 // Time variation
|
2026-01-16 19:10:21 +08:00
|
|
|
|
|
2026-01-15 20:10:19 +08:00
|
|
|
|
let intensity = baseEnergy + noise + timeVar
|
|
|
|
|
|
intensity = Math.max(0, Math.min(1, intensity))
|
2026-01-16 19:10:21 +08:00
|
|
|
|
|
2026-01-15 20:10:19 +08:00
|
|
|
|
const hue = 240 - intensity * 240 // Blue (low) to Red (high)
|
|
|
|
|
|
ctx.fillStyle = `hsl(${hue}, 80%, 50%)`
|
|
|
|
|
|
ctx.fillRect(x, height - y - 4, 4, 4)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.spectrogram-viz {
|
|
|
|
|
|
margin: 20px 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.viz-layout {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-around;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 15px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.viz-box {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 250px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.viz-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.viz-title {
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
font-size: 0.9em;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.viz-content {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
background: #1a1a1a;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
height: 140px;
|
|
|
|
|
|
padding: 10px 10px 20px 25px; /* Space for axis labels */
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.waveform-container {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.wave-bars {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 2px;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.wave-bar {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
background: var(--el-color-success);
|
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
|
animation: wave 1.5s ease-in-out infinite;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes wave {
|
2026-01-16 19:10:21 +08:00
|
|
|
|
0%,
|
|
|
|
|
|
100% {
|
|
|
|
|
|
height: 20%;
|
|
|
|
|
|
opacity: 0.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
50% {
|
|
|
|
|
|
height: 90%;
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
}
|
2026-01-15 20:10:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.transform-arrow {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.arrow-content {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
font-size: 1.2em;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.fft-text {
|
|
|
|
|
|
font-size: 0.7em;
|
|
|
|
|
|
margin-bottom: 5px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.spectrogram-container canvas {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.axis-label {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
font-size: 9px;
|
|
|
|
|
|
color: #666;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.x-axis {
|
|
|
|
|
|
bottom: 2px;
|
|
|
|
|
|
right: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.y-axis {
|
|
|
|
|
|
top: 10px;
|
|
|
|
|
|
left: 2px;
|
|
|
|
|
|
writing-mode: vertical-rl;
|
|
|
|
|
|
transform: rotate(180deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.legend {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 15px;
|
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
|
font-size: 0.8em;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.legend-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 5px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.color-box {
|
|
|
|
|
|
width: 12px;
|
|
|
|
|
|
height: 12px;
|
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.color-box.low {
|
|
|
|
|
|
background: hsl(240, 80%, 50%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.color-box.high {
|
|
|
|
|
|
background: hsl(0, 80%, 50%);
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|