普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月22日首页

录音与音频可视化

作者 hmh12345
2026年4月22日 10:39

需求背景

实现效果

现在很多AI智能体应用,会向用户开放自定义音色的功能,以便用户能使用自己的声音生成一个角色。实现自定义音色的前提就是采集用户自己的声音,以供后续大模型生成角色的自定义音色。本文将介绍,怎么在web中实现录音以及音频的可视化。

实现效果如下,类似iphone的语音备忘录:

它具有以下特点:

  • 波形随着音量实时变化
  • 从右向左滚动
  • 播放时可以复现录音波形

技术选型

怎么采集声音

浏览器中录音主要有三种方案:MediaRecorder、Web Audio API、Recorder-Core
1. MediaRecorder API:是浏览器提供的高层封装录音接口 用法如下:

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });

const recorder = new MediaRecorder(stream);

recorder.ondataavailable = (e) => {
  const blob = e.data; // 音频文件
};

recorder.start();

这是浏览器原生支持的API,简单高效实现录音,但是只能拿到编码后的blob,无法获取原始的振幅数据,也就无从实现实时可视化了。

2. Web Audio API 这是浏览器提供的音频处理引擎,只提供原始的振幅数据,可以实现音频可视化。可是,这套API不做任何封装,需要自行处理buffer 拼接、多通道处理、文件头封装等问题,实现过于复杂,因此不做考虑

3. Recorder-Core 这个库基于Web Audio API,实现了以下功能:

  • 实时回调输出振幅数据 (实现音频实时可视化的关键)
  • WAV编码
  • 生命周期封装 大大简化了音频采集的实现,并且具有良好的浏览器兼容性,在PC + 主流移动浏览器上都能使用,因此,最终选择recorder-core来实现音频采集

怎么实现音频的实时可视化

高频更新的动画,使用DOM实现会频繁触发回流、重绘,会造成显著的性能浪费,而这种场景使用canvas再合适不过了,用一张独立布局的画布,承接频繁更新的动画

因此,我们选择Recorder-Core + Canvas来实现本次需求。

核心流程

录音与采集音频数据

1️⃣ 初始化录音器

function createRecorder() {
    return RecorderCore({
        type: "wav",
        // 采样率
        sampleRate: 44100,
        onProcess(buffers, powerLevel, duration, sampleRate, newBufferIdx) {
            ...
        },
    });
}

recorder = createRecorder();


  • type: "wav" → 输出 wav 文件
  • onProcess: 实时拿到 PCM(振幅) 数据,以供后续绘制图形

2️⃣ 音频数据处理

在这一步中,原始采集到的音频数据在[-32768, 32767]区间,去掉正负值,并转换成[0, 1]区间的值。

const latest = buffers[newBufferIdx] || buffers[buffers.length - 1];

// 将原始 PCM 采样转换成 0 到 1 之间的能量值,供可视化使用。
function calcEnergyFromPCM(pcm: Int16Array): number {
    if (!pcm || pcm.length === 0) return 0;
    let sum = 0;
    for (let i = 0; i < pcm.length; i++) {
        sum += Math.abs(pcm[i]);
    }
    return Math.min(1, sum / pcm.length / 32768);
}

3️⃣ 实时数据采集

  • liveEnergyQueue,用来存实时展示所需的数据
  • energyTimeline,用来存全量数据,播放回放时使用
const energy = calcEnergyFromPCM(latest);

// 实时显示
liveEnergyQueue.push(energy);
// 播放回放
energyTimeline.push(energy);

4️⃣ 录音控制

开始录音

// 开始录音
await recorder.open(
    () => resolve(),
    (msg: string, _isUserNotAllow: boolean) => reject(new Error(msg)),
);

recorder.start();

结束录音

recorder.stop((blob, duration) => {
    wavBlob.value = blob;
});

注意:开始录音会请求麦克风权限,只有https和localhost能获取,其他环境会报错

音频可视化

1️⃣ 核心数据准备

const liveEnergyQueue: number[] = []; // 实时队列
const energyTimeline: number[] = [];  // 全量数据(播放用)
const visualBuffer: number[] = [];    // 当前屏幕显示

2️⃣ 动画驱动

录音、回放都统一使用startVisual实现绘制,内部通过requestAnimationFrame绘制每一帧动画

function startVisual(mode) {
    function draw() {
        ...
        animationId = requestAnimationFrame(draw);
    }
    draw();
}

3️⃣ 滚动窗口

两种模式energy的取值方式不一样,最终都是加入visualBuffer,visualBuffer会维护最大窗口,超出窗口就不再展示

if (mode === "record") {
    energy = liveEnergyQueue.shift();
} else {
    energy = energyTimeline[playIndex++];
}

visualBuffer.push(energy);

if (visualBuffer.length > MAX_COLUMNS) {
    visualBuffer.shift();
}

4️⃣ 绘制柱状图

从右往左绘制柱状图

const startX = width - barWidth;

for (let i = visualBuffer.length - 1, col = 0; i >= 0; i--, col++) {
    const x = startX - col * step;
    if (x + barWidth < 0) break;

    const e = visualBuffer[i];
    const barHeight = Math.min(
        maxBarHeight,
        Math.max(minBarHeight, e * height * VISUAL_HEIGHT_GAIN),
    );
    const y = (height - barHeight) / 2;
    ctx.fillStyle = "#ff3b30";
    ctx.fillRect(x, y, barWidth, barHeight);
}

5️⃣ Canvas高清适配

现代高清显示屏的dpr通常会大于1,也就是说实际的物理像素会比css像素更大,以dpr=2,css像素200x200为例,浏览器实际的物理像素时400x400,也就是会拉伸canvas尺寸,把一个200x200尺寸画布上绘制的图形,渲染在400x400的画布上,图形就容易模糊,因此需要对canvas根据dpr进行整体缩放

const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
昨天以前首页

鼠标跟随倾斜动效

作者 Mh
2026年4月21日 23:28

前言

最近在 gsap 上看到一个有趣的动效(Cursor-driven perspective tilt),于是决定自己实现一下,下面将介绍实现的过程,希望你能喜欢。

202604111231046.gif

观察动效

  1. 卡片的倾斜角度会随着鼠标的移入在 x 轴和 y 轴上向内进行倾斜。
  2. 卡片上的文字是悬浮在卡片,给人一种悬空在空中的错觉。

技术拆解

要实现这种 3D 的效果,在 css 中你首先想到的是什么?

在 CSS 中有三个属性实现 3D 效果至关重要。它们分别是 perspective、transform-style: preserve-3dtransform: rotateX() rotateY()。下面将详细的介绍他们在 3D 动效中的作用。

  1. perspective (透视/视距):它是 3D 的灵魂,如果没有它,你看到的效果看起来只像是在平面上进行拉伸和缩放。你可以理解它是3维空间中的z轴,定义观察者距离 z = 0平面的距离。通常设定在父容器上,数值越小(如500px),透视畸变越强烈(近大远小极度明显);数值越大(如 2000px),效果越平缓。
  2. transform-style: preserve-3d :它的作用是告诉子元素(文字层)也要保持在 3D 空间中,这样我们看到的容器的内容是有深度的,同时也可以在侧面看到元素与元素之间的距离。当父元素设置了transform-style: preserve-3d 的时候,同时子元素需要设置 transform: translateZ()。
  3. transform: rotateX() rotateY():这个属性相信大家都知道,这也是这次动效能实现的关键。rotateX 控制卡片绕水平轴转动,rotateY 控制卡片绕垂直轴转动。

总结一下

如果把 CSS 3D 比作一场电影:

  • perspective 是摄影机,决定了画面的纵深感。
  • transform-style: preserve-3d 是舞台搭建,决定了演员(元素)能不能在台前幕后来回走动,而不是画在背景板上。
  • transform: rotate / translate 是演员的动作,决定了物体怎么摆放和移动。

效果展示

如果你已经理解了上面属性,相信实现效果只是时间的问题,下面我就提前剧透一下效果吧!同时在浏览器中为你演示各个的属性的具体效果,让你更加深刻的理解上面的属性。

试想一下,如果没有设置 perspective 属性会怎么样呢?

为了更好的演示,我会将卡片绕着它的y轴固定旋转30度。然后对比设置了 perspective 属性和没有设置 perspective 的效果如下。

image.png

在对比了设置 perspective 的作用后,接下来为你演示 transform-style: preserve-3d 的效果,为了更好的演示,接下来调整一下卡片在y轴的旋转角度为-80度,同时对子元素设置 transform: translateZ(50px); 将背景调整为白色,让文字和背景不会重合。对比效果如下:

image.png

从上面的效果可以看出,设置了 transform-style: preserve-3d 的文字和背景卡片是分离的,没有设置 transform-style: preserve-3d 的文字被拍扁在卡片上面。

注意事项: 当容器设置了 transform-style: preserve-3d; 的时候,不能再设置 overflow: hidden; 不然 transform-style: preserve-3d; 不会生效。

经过上面的对比可以帮助我们更好的理解每个属性在具体场景中的使用,下面就使用 vue3 去实现具体的功能。

代码拆解

完整代码

<template>
  <div class="container">
    <div 
      class="card"
      ref="cardRef"
      :style="cardStyle"
      @mousemove="handleMouseMove"
      @mouseleave="handleMouseLeave"
    >
      <div class="content">
        <span>ANIMATION</span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, computed } from 'vue';

const cardRef = ref(null);

// 存储旋转角度
const transform = reactive({
  rotateX: 0,
  rotateY: 0
});

// 计算最终的 CSS 样式
const cardStyle = computed(() => {
  const scale = 1;
  return {
    transform: `rotateX(${transform.rotateX}deg) rotateY(${transform.rotateY}deg)`,
    transition: 'transform 0.5s ease-out'
  };
});

const handleMouseMove = (e) => {
  if (!cardRef.value) return;

  const rect = cardRef.value.getBoundingClientRect();
  const centerX = rect.left + rect.width / 2;
  const centerY = rect.top + rect.height / 2;
  
  // 计算鼠标距离中心点的偏移量 (-1 到 1)
  const percentX = (e.clientX - centerX) / (rect.width / 2);
  const percentY = (e.clientY - centerY) / (rect.height / 2);

  const deg = 25; // 最大旋转角度
  transform.rotateY = percentX * deg;
  transform.rotateX = -percentY * deg; // 取反是因为鼠标向上移动时图片应向下倾斜
};

const handleMouseLeave = () => {
  transform.rotateX = 0;
  transform.rotateY = 0;
};
</script>

<style scoped>
.container {
  /* 3D 透视的关键 */
  perspective: 1000px; 
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100vh;
  background-color: #0f0f0f;
}

.card {
  position: relative;
  width: 320px;
  height: 200px;
  background: linear-gradient(135deg, #6ee7b7, #3b82f6);
  border-radius: 20px;
  transform-style: preserve-3d;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
  /* overflow: hidden; */
}

.content {
  font-family: 'Arial Black', sans-serif;
  font-size: 2.5rem;
  color: #000;
  /* 让文字在 3D 空间悬浮 */
  transform: translateZ(50px); 
  pointer-events: none;
}
</style>

简要分析:

  1. 绑定事件:鼠标移入卡片触发 mousemove 事件,设置卡片旋转。鼠标移除触发 mouseleave 事件将旋转的角度置为0。
  2. 样式动态计算:动态绑定 style,通过计算属性实时更新旋转的角度。
  3. 计算偏移量: 这里主要利用鼠标当前的位置减去卡片中心点计算出偏移距离,然后再除以卡片宽高的一半,等到一个-1到1的偏移值。
  4. 角度映射:通过得到的偏移值乘以 deg (25度),刚好可以映射到对应的角度,比如鼠标移动到最左边,卡片正好偏转 -25度。

优化补充

下面是一些优化的建议,有兴趣的同学可以自己实现一下:

  1. 增加光影变化,跟随鼠标移动的卡片增加渐变层的光影,让整体更加真实。
  2. mousemove 在移动端不支持,增加移动端的支持。
❌
❌