动态

详情 返回 返回

一個手寫的vue3錄音組件,支持錄音波紋、外觀自定義調節 - 动态 详情

該組件有兼容問題,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>
user avatar yishidemeihao_5b9ce075877c9 头像
点赞 1 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.