H5 实现 ChatGPT 语音对话效果

2024-07-25
369813 分钟
...

起因

前段时间在群里看到群友想要复刻ChatGPT的语音对话效果,手痒之下自己也来复刻一波!

难点解析

这个效果平心而论是比较复杂的,我一般会把这种比较复杂的效果简单拆解出来,然后一步一步来解决。

  1. 不规则圆形,可以看到最开始时是由一个圆形过渡到不规则的圆形同时有一个滚动的效果。

  1. 溶解粘滞效果,从不规则的圆形过渡到音柱时可以看到有一个粘滞溶解的效果。

  1. 音柱的抖动效果,我们需要模拟收音时的不规则抖动效果来更贴近真实场景。

  1. 融合拓展效果,从音柱变回圆形的时候,有一个融合的效果让过渡更显丝滑。

神兵利器

在正式开始本篇内容之前我们需要先了解一些解决上述问题的神兵利器

  1. svg滤镜

    SVG滤镜(Scalable Vector Graphics Filters)是SVG(可缩放矢量图形)规范的一部分,用于对SVG图形应用各种视觉效果。SVG滤镜允许开发人员和设计师创建复杂的图形效果,比如模糊、阴影、颜色变化等,而无需使用位图图像

  2. 正弦曲线

    正弦曲线是一种周期性波动的数学曲线,常用于描述周期性变化的现象,如声波、光 波、电磁波、潮汐等。它在数学上被定义为一个角的正弦函数图像。

    基本形式 正弦曲线的基本形式可以表示为: y=Asin(B(x−C))+D

  3. 圆的极坐标公式

    圆的极坐标公式描述了圆在极坐标系中的数学表达。极坐标系是一种二维坐标系统,其中每个点的位置由一个半径(r)和一个角度(θ)确定,其中半径是从原点到点的距离,角度是从正x轴到点的连线与正x轴的夹角。

    基本形式

    对于一个圆心位于原点(0,0)的圆,其半径为 𝑅圆的极坐标方程可以表示为: r(θ)=R 这里,𝑟是从原点到圆上任意一点的距离,θ 是从正x轴逆时针测量的角度,单位通常是度或弧度。

Just do it!

现在我们把难点梳理出来后又有了解决难点的神兵利器!接下来只需要跟着我一步一步来完成上述的效果!

如何做一个不规则圆形!

这种不规则的圆形我们普通的html元素是非常难实现的,所以我们第一考虑的就应该是动态svgsvg本身的定义就是:

矢量图形:SVG使用数学方程来定义图形,如直线、曲线、多边形等。这意味着它们可以无限放大而不失清晰度。

既然是通过数学方程来定义图形,那自然是可以通过数学方程来定义不规则圆形!观察效果图我们能发现基本图形是由一个圆形过渡到有五个突出的不规则形状。

如果忽略了动画效果形状其实就是由五个扇形组成!

那么问题就变得简单了!,我们设置五个path并把每个path的路径都设置为扇形! 简单复习一下pathd属性值

命令描述参数示例
Mm移动到指定点,��绘制M 100 200m 30 50
Ll绘制直线到指定点L 200 300l 40 50
Hh绘制水平线到指定x坐标H 150h -20
Vv绘制垂直线到指定y坐标V 100v -30
Cc绘制三次贝塞尔曲线C 100 100 200 200 300 300
Ss绘制平滑三次贝塞尔曲线S 200 200 300 300
Qq绘制二次贝塞尔曲线Q 100 100 200 300
Tt绘制平滑二次贝塞尔曲线T 300 300
Aa绘制椭圆弧A 50 50 0 0 1 200 300
Zz关闭路径,回到起始点Zz
数字坐标点或控制点的坐标值例如 100 200

可以看到A的作用是绘制椭圆形,也就是我们需要的命令,A或a命令的基本格式如下:

A rx ry x-axis-rotation large-arc-flag sweep-flag x y

  • rx 和 ry 是弧线的x轴和y轴半径。
  • x-axis-rotation 是椭圆的x轴相对于当前坐标系的旋转角度,单位是度(deg)。
  • large-arc-flag 是一个布尔值,用来指定弧线的长度。值为0表示较短的弧,值为1表示较长的弧。
  • sweep-flag 是另一个布尔值,用来指定绘制弧线的方向。值为0表示顺时针方向,值为1表示逆时针方向。
  • x 和 y 是弧线结束点的坐标(对于大写A)。

举个例子!


<svg
        id="svg"
        width="500"
        height="500"
        version="1.1"
        xmlns="http://www.w3.org/2000/svg">

    <path fill="white" d="M250,250 L250,100 A150,150 0 0,1 400,250 Z"/>

</svg>

可以看到我们首先使用M命令移动到中心点,然后使用L命令画出了一个半径150的圆形半径,最后使用A命令并且让rx,ry等于圆形的半径150,此时就会出现一个1/4圆,如果我们让rx,ry不等于半径就会发生突起效果!

<svg
        id="svg"
        width="500"
        height="500"
        version="1.1"
        xmlns="http://www.w3.org/2000/svg">

    <path fill="white" d="M250,250 L250,100 A100,100 0 0,1 400,250 Z"/>

</svg>

明白了突起效果的原理我们就可以回答第一个难题了,如何做一个不规则突起的圆!答案是利用A命令,并且动态改变rx,ry使其不等于圆形的半径。

但是此时会面临一个数学问题,一个圆形需要五个扇形组成,扇形A命令的x,y应该怎么算出来,注意这里不是rx,ry!此时就需要我们的神兵利器之一圆的极坐标公式出场了!

对于一个圆心位于原点(0,0)的圆,其半径为 𝑅圆的极坐标方程可以表示为:

r(θ)=R 这里,𝑟是从原点到圆上任意一点的距离,θ 是从正x轴逆时针测量的角度,单位通常是度或弧度。 极坐标公式为:

x=rcos(θ)

y=rsin(θ)

我们已知r=150,θ = 360 / 5 = 72°,自然可以算出来五个扇形的x,y


<svg
        id="svg"
        width="500"
        height="500"
        version="1.1"
        xmlns="http://www.w3.org/2000/svg">

        <path class="path" d="M250,250 L400,250 A150,150 0 0,1 296.3525491562421,392.658477444273 L250,250 A 0,0 0 0,1 250,250 Z" fill="white"></path>

        <path class="path" d="M250,250 L296.3525491562421,392.658477444273 A150,150 0 0,1 128.6474508437579,338.167787843871 L250,250 A 0,0 0 0,1 250,250 Z" fill="white"></path>

        <path class="path" d="M250,250 L128.6474508437579,338.167787843871 A150,150 0 0,1 128.64745084375787,161.83221215612906 L250,250 A 0,0 0 0,1 250,250 Z" fill="white"></path>

        <path class="path" d="M250,250 L128.64745084375787,161.83221215612906 A150,150 0 0,1 296.3525491562421,107.34152255572695 L250,250 A 0,0 0 0,1 250,250 Z" fill="white"></path>

        <path class="path" d="M250,250 L296.3525491562421,107.34152255572695 A150,150 0 0,1 400,249.99999999999997 L250,250 A 0,0 0 0,1 250,250 Z" fill="white"></path>

</svg>

此时有一个完美的圆形之后,我们只要考虑如何动态改变A命令的rx,ry就可以了,观察效果会发现,在不规则的圆形成后,其实整个形状是在一定范围内不断重复循环并且带上了一些旋转效果,此时应该请出我们另一个神兵利器

看到这种在一定范围内不断重复的效果,我们应该首先想到就是利用正弦曲线循环往复的特性来完成

正弦曲线的基本形式可以表示为:y=Asin(B(x−C))+D

带入我们实际情况来看

  • rx,ry = y
  • A = 振幅
  • D = 初始偏移量
  • B = 频率

为了让效果更灵动一点,我这边把频率设置成随机数了,大家可以自行尝试喜欢的频率,精简后的代码如下:

window.SineWaveGenerator = class SineWaveGenerator {

    phase = Math.PI / 2

    constructor(frequency, amplitude, offset) {
        this.frequency = frequency
        this.amplitude = amplitude
        this.offset = offset
    }


    calculateValue(currentTime) {
        const currentFrequency = this.frequency
        const currentAmplitude = this.amplitude
        const currentOffset = this.offset
        // 计算并返回当前的正弦波值
        return currentAmplitude * Math.sin(currentFrequency * currentTime + this.phase) + currentOffset
    }

}

const min = 0.002
const max = 0.004
const circleSineWaveGenerator = []
for (let i = 0
    const f = this.getRandomBetween(min, max)
    const s = new window.SineWaveGenerator(f, 27.5, 122.5)
    circleSineWaveGenerator.push(s)
}

const points = circle.calculateValue(angle, xy)
points.forEach((f, i) => circlePaths[i].setAttribute('d', f))

至此第一个问题就解决了。

溶解粘滞效果

关于溶解粘滞的效果,我们其实可以参考Coco大佬的一篇文章你所不知道的 CSS 滤镜技巧与细节 核心是利用了两个滤镜:

  • filter: blur(): 给图像设置高斯模糊效果。
  • filter: contrast(): 调整图像的对比度。

这两个滤镜结合的时候我们会发现产生了神奇的粘滞效果

我们的方案是使用svg,是否可以直接使用上述的方案呢,答案是不行的😢,但是!我们有自己的神兵利器svg滤镜

SVG滤镜(Scalable Vector Graphics Filters)是SVG(可缩放矢量图形)规范的一部分,用于对SVG图形应用各种视觉效果。SVG滤镜允许开发人员和设计师创建复杂的图形效果,比如模糊、阴影、颜色变化等,而无需使用位图图像

既然css可以通过blur结合contrast滤镜来完成粘滞效果,我们的svg滤镜是否有类似的功能呢,答案是YES!💕

  • feGaussianBlur 是一种模糊滤镜,它根据高斯函数对图像进行模糊处理。高斯模糊是一种非常流行的图像处理技术,常用于减少图像噪声或创建柔和的边缘效果。

  • feColorMatrix 滤镜允许你对图像的颜色和 Alpha 通道进行矩阵变换。这可以用来调整颜色平衡、对比度、亮度等,或者实现更复杂的颜色效果。

这两个滤镜结合可以实现和CSS滤镜完全一致的效果!,滤镜的使用方法也可以参考有意思!强大的 SVG 滤镜本篇不再详细赘述。

因为要用到滤镜此时我们的svg结构需要有所调整,需要多加一层g标签来保证滤镜的效果


<svg id="svg" width="500" height="500" version="1.1" xmlns="http://www.w3.org/2000/svg">

    <defs>
        <filter id="goo">
            <feGaussianBlur ...></feGaussianBlur>
            <feColorMatrix ...></feColorMatrix>
        </filter>
    </defs>
    <g filter="url(#goo)">
        <path ...></path>
        <path ...></path>
        <path ...></path>
        <path ...></path>
        <path ...></path>
    </g>
</svg>

此时我们的准备工作就已经全部完成了,接下来需要把圆形变为分散的音柱结构!这种形变也是svg所擅长的!我们只需要计算出音柱的各个坐标信息并动态修改即可!


    canvasWidth = 500
    canvasHeight = 500

    width = 300
    height = 300

    initStartX
    initStartY

    audioWidth = 0
    audioSpace = 4
    audioCount = 4

    constructor(paths) {

        this.circlePaths = paths

        // 音柱的开始x
        this.initStartX = (this.canvasWidth - this.width) / 2

        // 音柱的开始y
        this.initStartY = this.canvasHeight / 2
        
        // 音柱的宽度
        this.audioWidth = (this.width - (this.audioCount - 1) * this.audioSpace) / this.audioCount

    }

此时我们已经计算出了所有音柱的坐标信息,这里有一点需要注意,圆形是由五个扇形组成,音柱是由四个圆柱组成,所以我们需要两片扇形共用一个圆柱的坐标信息!


decomposerAnimation(timeStart) {

        let startX = this.initStartX

        const pathSteps = []

        const tempStartX = []
        const tempStartY = []
        const tempHeight = []


        for (let i = 0
            // 让音柱y轴有一些偏差效果更自然
            let startY = i > 1 ? this.initStartY + Math.abs((4 - i) * 20) : this.initStartY + 80
            // 让音柱高度有一些偏差效果更自然
            const h = i > 1 ? 30 : 20
            const p = this.getDecomposePath(startX, startY, h)
            pathSteps.push(p)
            tempStartX.push(startX)
            tempStartY.push(startY)
            tempHeight.push(h)
            startX += (this.audioWidth + this.audioSpace)
        }

        let startPaths = this.circlePaths.reduce((p, c) => [...p, this.execPath(c.getAttribute('d'))], [])

        let endPaths = Array.from({length: this.circlePaths.length}).map((value, index) => this.execPath(this.getPath(index, pathSteps)))

        const animation = () => {
            const currentTime = performance.now()
            const timeElapsed = currentTime - timeStart
            const progress = Math.min(timeElapsed / this.decomposerDuration, 1)
            this.circlePaths.forEach((f, i) => f.setAttribute('d', this.calePathProgress(startPaths[i], endPaths[i], progress)))
            if (progress < 1) requestAnimationFrame(() => animation(timeStart))
            // else requestAnimationFrame(timeStart => this.translateAnimation(timeStart))
        }

        animation()

}

此时我们可以发现,粘滞效果已经实现了,但是我们的需求是分裂后就不要在保持粘滞效果了,所以我们在执行分裂动画的时候也需要动态调整两个滤镜的参数!


     const blur = document.getElementById('blur')
     const matrix = document.getElementById('matrix')
     
     // 动态过渡到没有粘滞效果
     const filterAnimation = () => {
         const currentTime = performance.now()
         const timeElapsed = currentTime - timeStart
         const progress = Math.min(timeElapsed / this.filterDuration, 1)
         const blurValue = Math.max(15 - (15 * progress), 0)
         const v1 = 26 - (25 * progress)
         const v2 = -7 + (7 * progress)
         blur.setAttribute('stdDeviation', blurValue.toString())
         const values = `1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${v1} ${v2}`
         matrix.setAttribute('values', values)
     }

此时我们的问题二也有了答案,如何做溶解粘滞效果,利用svg滤镜!同时动态调整滤镜参数

音柱抖动效果

ChatGPT这个音柱的抖动肯定是根据声音输入来动态改变的,咱们只是模仿效果,只需要观察效果的核心原理即可,也就是这个圆柱在一定的高度重复抖动!看到重复我们就应该知道又要用到我们的老朋友正弦函数来帮忙啦!


        const sineDuration = 2000
        const resetDuration = 200

        let startPaths = []
        let endPaths = []

        const sineAnimation = (timeStart) => {

            const currentTime = performance.now()
            const timeElapsed = currentTime - timeStart
            const progress = Math.min(timeElapsed / sineDuration, 1)

            const handleStart = (f, i) => {
                const progress = Math.min(timeElapsed / duration[i], 1)

                const initialFrequency = Math.PI * 0.018
                const finalFrequency = Math.PI * 0.008

                // 动态计算当前振幅范围
                const currentMinHeight = lerp(range[i][0], range[i][2], progress)
                const currentMaxHeight = lerp(range[i][1], range[i][3], progress)

                // 使用正弦函数计算当前高度
                const heightRange = (currentMaxHeight - currentMinHeight) / 2
                const averageHeight = (currentMaxHeight + currentMinHeight) / 2
                const frequency = lerp(initialFrequency, finalFrequency, progress)
                const currentHeight = averageHeight + heightRange * Math.sin(frequency * timeElapsed)

                const {x, y} = this.getDecomposePathXY(f.getAttribute('d'))
                f.setAttribute('d', this.getDecomposePath(x, y, currentHeight))
            }


            this.circlePaths.forEach(handleStart)

            if (progress < 1) {
                requestAnimationFrame(() => sineAnimation(timeStart))
                return
            }

            startPaths = this.circlePaths.reduce((p, c) => [...p, this.execPath(c.getAttribute('d'))], [])

            endPaths = this.circlePaths.map(v => {
                const {x, y} = this.getDecomposePathXY(v.getAttribute('d'))
                return this.execPath(this.getDecomposePath(x, y, 0))
            })

            requestAnimationFrame(timeStart => resetAnimation(timeStart))

        }

核心就是利用正弦函数来不断的改变我们音柱的高度!

融合拓展效果

终于到我们最后一个效果了!经过了问题二,我们应该知道融合效果的实现方式是根据svg滤镜来实现的,在效果二最后我们把滤镜过渡到了没有粘滞效果的状态,此时我们应该重新过渡到有粘滞效果的状态!


  const blur = document.getElementById('blur')
  const matrix = document.getElementById('matrix')
  const filterAnimation = (timeStart) => {
        const currentTime = performance.now()
        const timeElapsed = currentTime - timeStart
        const progress = Math.min(timeElapsed / this.filterDuration, 1)
        const blurValue = Math.min(15 * progress, 15)
        const v1 = 1 + (25 * progress)
        const v2 = 0 - (7 * progress)
        blur.setAttribute('stdDeviation', blurValue.toString())
        const values = `1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${v1} ${v2}`
        matrix.setAttribute('values', values)
    }

大功告成了么?并不!如果我们仅仅只是把粘滞的效果恢复同时把四个音柱的坐标恢复成最开始的圆形坐标时会发现效果如下:

虽然最后融合成了一个圆,但是和ChatGPT的效果不一样,融合的过程有一些扭曲。此时我们要使用一个小小的障眼法!还记得在问题二中我们隐藏了一个扇形,此时就应该派上用场了。

思路如下:

藏一个音柱并改成椭圆结构藏在最后面,剩下的四个音柱往中心聚拢,同时中心的两个音柱高度变化,藏在后面的椭圆结构撑起来变成一个圆!

完整代码

结语

OK,整个效果做下来,我们捋清了filter的使用,一些数学公式的实际运用,希望大家能看的开心,学的开心!

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
css2026-06-17
外阴影和内阴影介绍
在前端页面设计中,阴影是非常常见的视觉效果。它可以让元素看起来更有层次感,也可以用来表达悬浮、按压、凹陷、卡片、按钮等状态。 CSS 中最常用的阴影属性是 。它既可以实现外阴影,也可以实现内阴影。 一、什么是外阴影 外阴影指的是元素外部的阴影效果。 它会出现在元素边框之外,让元素看起来像是从页面中“浮起来”了一样。常见的卡片、弹窗、按钮悬浮效果,基本都会用到外阴影。 例如: 这个阴影表示:元素下方有一层柔和的黑色阴影,让卡片看起来更有立...
学习
css2026-05-15
border-image-slice详细介绍
是 CSS 系列属性里最关键、也最容易让人懵的一个属性。 它的作用可以简单理解为: 把一张图片切成九宫格,然后决定哪几块用来绘制元素的边框。 它通常和这些属性一起使用: 或者简写成: 其中 决定“怎么切图”。 一、 是什么? 普通的 只能画纯色边框: 但有时候我们想用图片作为边框,比如: 游戏按钮边框 像素风 UI 复古窗口 聊天气泡 卡牌边框 花纹装饰框 九宫格按钮背景 这时候就可以用 。 例如一张按钮图片长这样: 我们希望按钮变宽时...
学习
css2026-03-05
如何在最小副作用下创建 BFC
在 CSS 布局的江湖中, BFC (Block Formatting Context,块级格式化上下文) 是一个老生常谈却又至关重要的概念。无论是解决 高度塌陷 、 清除浮动 ,还是防止 垂直外边距重叠 ,BFC 都是我们的得力助手。 但是,创建 BFC 的方法有很多: 、 、 、 …… 哪种方法对现有布局的“破坏”最小? ❌ 过去的老方法 在过去,为了触发 BFC,我们不得不使用一些带有副作用的“黑客”技巧: : 元素会脱离文档流,...
学习
css2026-03-03
CSS过渡性能解析:transition: all
CSS过渡性能深度解析:为什么 可能是你页面的隐形杀手 一、引言:一个被忽视的性能陷阱 你是否曾在项目中这样写: 看似简洁优雅,却可能在低端设备上引发卡顿、掉帧。 真相 : 是前端性能优化中高频出现的“隐形陷阱”。本文将彻底揭开它的面纱。 二、核心概念: 到底在控制什么? 指定 哪些CSS属性变化时触发动画过渡 。它是过渡效果的“守门员”: | 值 | 含义 | 生成的CSS | | | | | | | 所有 可过渡属性变化时均触发动画...
学习
css2026-01-26
在CSS中,四种高度视口单位的区别
在CSS中,视口单位是构建响应式网页设计的重要工具,特别是针对移动端开发。除了传统的 ,CSS新引入了 、 和 等更精确的单位,解决传统单位在移动端的局限性。 四种高度视口单位的区别 | 单位 | 全称 | 含义 | 特点 | | | | | | | vh | Viewport Height | 传统视口高度单位,1vh = 视口高度的1% | 在移动端有问题:不考虑浏览器UI变化,经常导致内容被遮挡或出现意外滚动 | | svh |...
学习
css2025-11-04
keyframes 关键帧机制
一、什么是 @keyframes? 用于定义动画在不同时间点(关键帧)的样式变化;配合 属性应用到元素上。 定义动画 :使用 声明关键帧序列; 应用动画 :在选择器中通过 / 相关长属性控制表现。 二、基本语法 等价写法( / ): 三、与 属性配合使用 /相关长属性 | 属性名 | 含义 | 示例值 | | : | : | : | | | 动画名称 | | | | 持续时长 | 、 | | | 速度曲线 | / / / | | | 延...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录