該組件有兼容問題,MAC上不可用,需注意,這裏僅用來做記錄,如需音頻功能,可使用Recorder.js,方案很成熟。
組件如下:
<template>
<div class="container" ref="container">
<canvas id="visualizer" ref="canvas"></canvas>
</div>
</template>
<script setup lang="ts">
const config = defineModel({
type: Object,
default: () => ({
barSpacing: 3, // 間距
barRoundness: 4, // 圓角
barWidth: 8, // 柱寬
defaultHeightMultiplier: 0.01, // 默認高度
barHeightMultiplier: 1.0, // 振幅 0.4 ~ 2
sensitivity: 0.5, // 靈敏度 0~1
audioSource: "local", // 音頻源 mic麥克風 local本地音源
mode: "sine", // sine正弦波 peak峯值波 pulse脈衝波
colorMode: {
top: "#52B852",
center: "#52B852",
bottom: "#52B852"
} // 顏色
})
});
const emit = defineEmits(["audio"]);
interface LocalConfig {
isAnimating: boolean;
animationId: number | null;
audioContext: AudioContext | null;
analyser: AnalyserNode | null;
mediaStream: MediaStream | null;
mediaRecorder: MediaRecorder | null;
recordedChunks: Blob[];
}
const localConfig = ref<LocalConfig>({
isAnimating: false, // 是否動畫開啓中
animationId: null, // requestAnimationFrame的id
audioContext: null, // 音頻上下文
analyser: null, // 音頻解析器
mediaStream: null, // 保存從getUserMedia獲取的媒體流
mediaRecorder: null, // 媒體錄製器
recordedChunks: [] // 錄製的音頻數據塊
});
const container = ref<HTMLDivElement | null>();
const canvas = ref<HTMLCanvasElement | null>();
const ctx = ref<CanvasRenderingContext2D | null>();
// 設置canvas大小為容器大小
const resizeCanvas = () => {
if (canvas.value && container.value) {
canvas.value.width = container.value.offsetWidth;
canvas.value.height = container.value.offsetHeight;
}
};
const onReset = () => {
onClose();
config.value = {
barSpacing: 3,
barRoundness: 4,
barWidth: 8,
defaultHeightMultiplier: 0.01,
barHeightMultiplier: 1.0,
sensitivity: 0.5,
audioSource: "mic",
mode: "sine",
colorMode: {
top: "#52B852",
center: "#52B852",
bottom: "#52B852"
}
};
localConfig.value = {
isAnimating: false,
animationId: null,
audioContext: null,
analyser: null,
mediaStream: null,
mediaRecorder: null,
recordedChunks: []
};
// 清除 canvas 畫布
if (canvas.value && ctx.value) {
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
}
init();
};
// 創建對稱柱狀音頻可視化
const drawBars = (dataArray: any, barCount: any) => {
if (!ctx.value || !canvas.value) {
console.warn("Canvas context or canvas element is not available.");
return;
}
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
// 計算可以容納的柱數
const totalBarWidth = config.value.barWidth + config.value.barSpacing;
barCount = Math.min(barCount, Math.floor(canvas.value.width / totalBarWidth));
const centerX = canvas.value.width / 2;
const centerY = canvas.value.height / 2;
const halfBarCount = Math.floor(barCount / 2);
const colorMode = config.value.colorMode;
// 計算默認高度(容器高度 * multiplier)
const defaultMinHeight = canvas.value.height * config.value.defaultHeightMultiplier;
// 創建從中心向兩邊展開的柱子
for (let i = 0; i < barCount; i++) {
const barIndex = i < halfBarCount ? halfBarCount - i - 1 : i - halfBarCount;
let value = dataArray[barIndex] / 256;
// 應用靈敏度調整
let barHeight =
Math.pow(value, 1 + (1 - config.value.sensitivity) * 2) * canvas.value.height * config.value.barHeightMultiplier * 0.6;
// 應用最小高度(默認高度)
barHeight = Math.max(barHeight, defaultMinHeight);
// 從中心向兩邊高度遞減效果
const distanceFromCenter = Math.abs(i - halfBarCount);
barHeight = barHeight * (1 - (0.4 * distanceFromCenter) / halfBarCount);
// 跳過太矮的柱子
if (barHeight < 1) continue;
const barWidth = config.value.barWidth;
const barRoundness = Math.min(config.value.barRoundness, barHeight / 2);
// 從中心開始的位置計算
const x = centerX - halfBarCount * totalBarWidth + i * totalBarWidth;
// 柱子頂部位置(從中心向上延伸)
const topY = centerY - barHeight / 2;
// 創建漸變顏色(能量越大中間越亮)
const gradient = ctx.value.createLinearGradient(x, topY, x, topY + barHeight);
gradient.addColorStop(0, colorMode.top);
gradient.addColorStop(0.5, colorMode.center);
gradient.addColorStop(1, colorMode.bottom);
// 繪製對稱柱體
ctx.value.fillStyle = gradient;
if (barRoundness > 0) {
// 繪製圓角矩形(對稱)
const r = barRoundness;
ctx.value.beginPath();
ctx.value.moveTo(x + r, topY);
ctx.value.lineTo(x + barWidth - r, topY);
ctx.value.arcTo(x + barWidth, topY, x + barWidth, topY + r, r);
ctx.value.lineTo(x + barWidth, topY + barHeight - r);
ctx.value.arcTo(x + barWidth, topY + barHeight, x + barWidth - r, topY + barHeight, r);
ctx.value.lineTo(x + r, topY + barHeight);
ctx.value.arcTo(x, topY + barHeight, x, topY + barHeight - r, r);
ctx.value.lineTo(x, topY + r);
ctx.value.arcTo(x, topY, x + r, topY, r);
ctx.value.closePath();
ctx.value.fill();
} else {
// 直接繪製矩形
ctx.value.fillRect(x, topY, barWidth, barHeight);
}
}
};
// 生成正弦波測試數據
const generateSineData = () => {
const data = [];
const barsCount = 128;
const time = Date.now() * 0.002;
for (let i = 0; i < barsCount; i++) {
// 正弦波基礎 + 噪聲 + 峯值
let value = Math.sin(i * 0.2 + time);
value += Math.sin(i * 0.1 + time * 0.5) * 0.3;
value += Math.sin(i * 0.05 + time * 0.2) * 0.2;
value += Math.random() * 0.1;
// 轉換到0-255範圍
const normalized = (value + 2) * 40; // 縮放值
data.push(Math.min(255, Math.max(0, normalized)));
}
return data;
};
// 生成峯值波測試數據
const generatePeakData = () => {
const data = [];
const barsCount = 128;
const time = Date.now() * 0.001;
const beat = Math.sin(time * 2) > 0.8 ? Math.sin(time * 30) * 0.3 + 0.7 : 0;
for (let i = 0; i < barsCount; i++) {
// 中心位置峯值
const centerValue = Math.max(0, 1 - Math.abs(i - barsCount / 2) / (barsCount / 4));
let value = centerValue;
// 添加節奏點
if (i > barsCount / 2 - 6 && i < barsCount / 2 + 6) {
value += beat;
}
// 添加噪聲
value += Math.sin(i * 0.2 + time) * 0.1;
value += Math.random() * 0.05;
const normalized = value * 200;
data.push(Math.min(255, Math.max(0, normalized)));
}
return data;
};
// 生成脈衝波測試數據
const generatePulseData = () => {
const data = [];
const barsCount = 128;
const time = Date.now() * 0.001;
const pulse = Math.abs(Math.sin(time)) > 0.9 ? 1 : 0;
for (let i = 0; i < barsCount; i++) {
// 基礎波
let value = Math.sin(i * 0.3) * 0.5 + 0.5;
// 中心位置
if (Math.abs(i - barsCount / 2) < 8) {
value = Math.min(1, value + pulse * 0.7);
}
// 隨機脈衝
if (Math.random() > 0.99) {
value = 1;
}
const normalized = value * 200;
data.push(Math.min(255, Math.max(0, normalized)));
}
return data;
};
// 基於選擇的模式生成數據
const generateVisualizerData = () => {
switch (config.value.mode) {
case "peak":
return generatePeakData();
case "pulse":
return generatePulseData();
case "sine":
default:
return generateSineData();
}
};
// 開始
const onStart = () => {
if (!localConfig.value.isAnimating) {
localConfig.value.isAnimating = true;
animate();
}
};
// 結束
const onClose = () => {
localConfig.value.isAnimating = false;
if (localConfig.value.animationId) {
cancelAnimationFrame(localConfig.value.animationId);
localConfig.value.animationId = null;
}
// 停止音頻上下文
if (localConfig.value.audioContext) {
if (config.value.audioSource === "mic" && localConfig.value.audioContext.state !== "closed") {
localConfig.value.audioContext.close();
}
localConfig.value.audioContext = null;
}
// 直接停止媒體流
if (localConfig.value.mediaStream) {
const tracks = localConfig.value.mediaStream.getTracks();
tracks.forEach((track: any) => {
track.stop();
});
localConfig.value.mediaStream = null;
}
// 停止錄製
if (localConfig.value.mediaRecorder && localConfig.value.mediaRecorder.state === "recording") {
localConfig.value.mediaRecorder.stop();
localConfig.value.mediaRecorder.onstop = () => {
const blob = new Blob(localConfig.value.recordedChunks, { type: "audio/webm; codecs=opus" });
localConfig.value.mediaRecorder = null;
localConfig.value.recordedChunks = [];
emit("audio", blob);
};
}
};
// 動畫循環
const animate = () => {
if (!localConfig.value.isAnimating) return;
let dataArray;
const barCount = 64; // 64個柱狀條(左右各32個)
if (config.value.audioSource === "mic") {
// 真實音頻數據處理
if (!localConfig.value.audioContext) {
initAudio();
}
if (localConfig.value.analyser) {
const bufferLength = localConfig.value.analyser.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
localConfig.value.analyser.getByteFrequencyData(dataArray);
} else {
dataArray = generateVisualizerData();
}
} else {
// 模擬數據
dataArray = generateVisualizerData();
}
drawBars(dataArray, barCount);
localConfig.value.animationId = requestAnimationFrame(animate);
};
// 音頻初始化(麥克風)
const initAudio = () => {
localConfig.value.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
localConfig.value.analyser = localConfig.value.audioContext.createAnalyser();
localConfig.value.analyser.fftSize = 256;
if (config.value.audioSource === "mic") {
// 獲取麥克風權限
navigator.mediaDevices
.getUserMedia({ audio: true })
.then(function (stream) {
localConfig.value.mediaStream = stream;
// 根據音頻流創建一個媒體流音頻源對象
const source = localConfig.value.audioContext!.createMediaStreamSource(stream);
// 連接到音頻分析器
source.connect(localConfig.value.analyser!);
// 初始化 MediaRecorder
localConfig.value.mediaRecorder = new MediaRecorder(stream);
localConfig.value.recordedChunks = [];
// 監聽數據可用事件
localConfig.value.mediaRecorder.ondataavailable = (event: any) => {
if (event.data.size > 0) {
localConfig.value.recordedChunks.push(event.data);
}
};
// 開始錄製
localConfig.value.mediaRecorder.start();
})
.catch(function (err) {
console.error("麥克風訪問失敗:", err);
config.value.audioSource = "local";
});
}
};
// 繪製初始的靜態波形預覽
const drawInitialPreview = () => {
const dataArray = generateVisualizerData();
drawBars(dataArray, 64);
};
const init = () => {
ctx.value = canvas.value!.getContext("2d");
// 添加窗口大小變化監聽
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
drawInitialPreview();
};
onMounted(() => {
init();
});
onUnmounted(() => {
window.removeEventListener("resize", resizeCanvas);
});
defineExpose({
onStart,
onClose,
onReset
});
</script>
<style lang="scss" scoped>
.container {
width: 100%;
height: 100%;
}
</style>
使用:
<script setup lang="ts">
import { isSecureEnvironment } from "@/utils/index";
interface ColorMode {
top: string;
center: string;
bottom: string;
}
interface VisualizerConfig {
barSpacing: number;
barRoundness: number;
barWidth: number;
defaultHeightMultiplier: number;
barHeightMultiplier: number;
sensitivity: number;
audioSource: "mic" | "local";
mode: "sine" | "peak" | "pulse";
colorMode: ColorMode;
}
interface AudioSourceOption {
value: "mic" | "local";
label: string;
}
interface AudioModeOption {
value: "sine" | "peak" | "pulse";
label: string;
}
type AudioVisualizerInstance = any;
// 配置
const config = ref<VisualizerConfig>({
barSpacing: 3, // 間距
barRoundness: 4, // 圓角
barWidth: 8, // 柱寬
defaultHeightMultiplier: 0.01, // 默認高度
barHeightMultiplier: 1.0, // 振幅 0.4 ~ 2
sensitivity: 0.5, // 靈敏度 0~1
audioSource: "mic", // 音頻源 local本地音源 mic麥克風
mode: "sine", // 正弦波
colorMode: {
top: "#52B852",
center: "#52B852",
bottom: "#52B852"
} // 顏色
});
const audioSource = ref<AudioSourceOption[]>([
{
value: "mic",
label: "麥克風"
},
{
value: "local",
label: "本地音源"
}
]);
const audioMode = ref<AudioModeOption[]>([
{
value: "sine",
label: "正弦波"
},
{
value: "peak",
label: "峯值波"
},
{
value: "pulse",
label: "脈衝波"
}
]);
const AudioVisualizerRef = ref<AudioVisualizerInstance | null>(null);
const onStart = () => {
audioUrl.value = null;
AudioVisualizerRef.value.onStart();
};
const onClose = () => {
AudioVisualizerRef.value.onClose();
};
const onReset = () => {
audioUrl.value = null;
AudioVisualizerRef.value.onReset();
};
const audioUrl = ref<any>();
const onAudio = (blob: Blob) => {
audioUrl.value = URL.createObjectURL(blob);
console.log("音頻", audioUrl);
};
</script>
<template>
<div class="snow-page">
<div class="snow-inner">
<a-alert>本地錄音需要https或localhost安全環境,是否為安全環境:{{ isSecureEnvironment() }}</a-alert>
<a-space direction="vertical" fill>
<div class="audio-box">
<AudioVisualizer ref="AudioVisualizerRef" v-model="config" @audio="onAudio" />
</div>
<a-space direction="vertical" fill>
<a-grid :cols="3" :colGap="12" :rowGap="12">
<a-grid-item class="demo-item">
<a-card title="波形設置">
<a-space direction="vertical" fill>
<a-space direction="vertical" fill>
<div>柱間距({{ config.barSpacing }}px)</div>
<a-slider :step="1" :min="0" :max="10" v-model="config.barSpacing" />
</a-space>
<a-space direction="vertical" fill>
<div>圓角半徑({{ config.barRoundness }}px)</div>
<a-slider :step="1" :min="0" :max="12" v-model="config.barRoundness" />
</a-space>
<a-space direction="vertical" fill>
<div>柱寬({{ config.barWidth }}px)</div>
<a-slider :step="1" :min="2" :max="20" v-model="config.barWidth" />
</a-space>
<a-space direction="vertical" fill>
<div>默認高度({{ parseInt(String(config.defaultHeightMultiplier * 100)) }}%)</div>
<a-slider :step="0.01" :min="0" :max="0.5" v-model="config.defaultHeightMultiplier" />
</a-space>
</a-space>
</a-card>
</a-grid-item>
<a-grid-item class="demo-item">
<a-card title="外觀設置">
<a-space direction="vertical" fill>
<a-space direction="vertical" fill>
<div>振幅({{ parseInt(String(config.barHeightMultiplier * 100)) }}%)</div>
<a-slider :step="0.1" :min="0.4" :max="2" v-model="config.barHeightMultiplier" />
</a-space>
<a-space direction="vertical" fill>
<div>顏色模式</div>
<a-space>
上
<a-color-picker v-model="config.colorMode.top" show-text disabled-alpha size="mini" />
中
<a-color-picker v-model="config.colorMode.center" show-text disabled-alpha size="mini" />
下
<a-color-picker v-model="config.colorMode.bottom" show-text disabled-alpha size="mini" />
</a-space>
</a-space>
<a-space direction="vertical" fill>
<div>靈敏度({{ parseInt(String(config.sensitivity * 100)) }}%)</div>
<a-slider :step="0.1" :min="0.2" :max="1" v-model="config.sensitivity" />
</a-space>
</a-space>
</a-card>
</a-grid-item>
<a-grid-item class="demo-item">
<a-card title="音頻源">
<a-space direction="vertical" fill>
<a-space direction="vertical" fill>
<div>選擇音頻源</div>
<a-select placeholder="請選擇" v-model="config.audioSource">
<a-option v-for="item of audioSource" :key="item.value" :value="item.value" :label="item.label">{{
item.label
}}</a-option>
</a-select>
</a-space>
<a-space direction="vertical" fill>
<div>波形</div>
<a-select placeholder="請選擇" v-model="config.mode">
<a-option v-for="item of audioMode" :key="item.value" :value="item.value" :label="item.label">{{
item.label
}}</a-option>
</a-select>
</a-space>
</a-space>
</a-card>
</a-grid-item>
</a-grid>
<a-divider />
<audio v-if="audioUrl" :src="audioUrl" controls autoplay style="width: 100%" />
<div class="flex-center">
<a-space>
<a-button type="primary" @click="onStart">開始</a-button>
<a-button type="primary" @click="onClose">結束</a-button>
<a-button @click="onReset">重置</a-button>
</a-space>
</div>
</a-space>
</a-space>
</div>
</div>
</template>
<style lang="scss" scoped>
.audio-box {
width: 100%;
height: 200px;
}
</style>