boycot 搜索
avatar

boycot

利用G渲染器实现的音频可视化方案

利用阿里 Antvis 出品的 G 底层图形渲染器,结合 AudioContext 提供的音频数据获取 API,实现出类似网易云播放音频特效。

项目地址:

关于 G 渲染器

G是一款易用、高效、强大的 2D 可视化渲染引擎,提供 Canvas、SVG 等多种渲染方式的实现。目前,已有多个顶级的可视化开源项目基于G开发,比如图形语法库G2、图可视化库G6等。

作为一个底层渲染器,其内置了许多常用的内置图形,提供完整的 DOM 事件模拟,同时提供了流程的动画实现,这些特性对我们这次实现音频特效都是很有必要的。

目前与G相似的竞品还有EchartZRender,相比较以我个人看法来说,Zrender 提供的 API 更丰富,但是上手难度比 G 要高一点,而G的 API 相对简洁一点。

类似的还有老大哥d3,这个相较以上两个更底层,API 更丰富,但上手难度就更大了。同时g里面的一些方法好像也是参考了d3算法思路。

G 官方文档 (这里吐槽说一下,G 的官方文档感觉还有很大优化空间,实在太简洁了,很多 API 都是一笔带过,用法也不怎么说明)

AudioContext 读取音频数据

实现音频特效动画的前提是需要拿到一个音频的音频数据,浏览网上一些方案后,发现AudioContext含有相关的 API。

原理:

  • 首先需要基于AudioContext.createAnalyser()创建一个Analyser
  • Analyser关联音频源,目前常用的音频源方式一般为以下两个
    • createMediaElementSource(): 关联到audiovideo标签中(当前方案选择了这个)
    • createMediaStreamSource(): 关联到本地计算机或网络音频媒体流对象
  • 创建Gain音量节点并关联到Analyserdestination
  • 通过AnalyserNode.getByteFrequencyData()方法将当前频率数据复制到传入的最终需读取音频的 Uint8Array 中

把以上操作封装到一个类中,便于初始化,可参考以下代码:

// src/plugins/MusicVisualizer.ts
const _analyser = new window.AudioContext();

type MusicVisualizerOptions = {
  audioEl?: HTMLAudioElement;
  size?: number;
};
export class MusicVisualizer {
  private analyser: AnalyserNode;
  private gainNode: GainNode;
  private audioSource?: MediaElementAudioSourceNode;
  private options: MusicVisualizerOptions & {
    size: number;
  };
  private visualArr: Uint8Array;
  constructor(options?: MusicVisualizerOptions) {
    const defaultOptions = {
      size: 128,
    };
    this.options = {
      ...defaultOptions,
      ...options,
    };
    this.analyser = _analyser.createAnalyser();
    this.analyser.fftSize = this.options.size * 2;
    this.gainNode = _analyser.createGain();
    this.gainNode.connect(_analyser.destination);
    this.analyser.connect(this.gainNode);
    if (this.options.audioEl) {
      this.audioSource = _analyser.createMediaElementSource(
        this.options.audioEl
      );
      this.audioSource.connect(this.analyser);
    }
    this.visualArr = new Uint8Array(this.analyser.frequencyBinCount);
    this.resumeAudioContext();
  }

  // 新版Chrome Audio需要有交互行为后才可以利用JS执行播放
  private resumeAudioContext() {
    if (_analyser) {
      const resumeAudio = () => {
        if (_analyser.state === "suspended") _analyser.resume();
        document.removeEventListener("click", resumeAudio);
      };
      document.addEventListener("click", resumeAudio);
    }
  }

  // 更换Audio
  setAudioEl(el: HTMLAudioElement) {
    if (this.audioSource) {
      this.audioSource.disconnect(this.analyser);
    }
    this.audioSource = _analyser.createMediaElementSource(el);
    this.audioSource.connect(this.analyser);
  }

  // 获取音频频域数据
  getVisualizeValue() {
    this.analyser.getByteFrequencyData(this.visualArr);
    return this.visualArr;
  }

  // 更改音量
  changeVolumn(value: number) {
    this.gainNode.gain.value = value;
  }

  // 卸载
  destory() {
    this.analyser.disconnect(this.gainNode);
    this.audioSource?.disconnect(this.analyser);
    this.gainNode.disconnect(_analyser.destination);
  }
}

初始化之后,就可以监听 Audio 的播放事件,当播放时利用getVisualizeValue()方法获取到实时音频(可结合利用 requestAnimationFrame 或 setTimeout 获取),这里因为是做可视化动画,当然是利用requestAnimationFrame读取每帧的数据后渲染。

还有一个需要注意的点,当 Audio 的数据源是网络音频时,有可能会出现无法读取到音频数据的问题。这个问题一般可能是因为网络音频的跨域限制,需要为 Audio 标签加入crossOrigin="anonymous"属性。 一般的 CDN 资源是很少设置 AccessHeader 跨域限制的,但加入这个属性后仍然出现了跨域的报错,说明这网络路径是设置了跨域限制的,这时候可以考虑用 Nginx 反向代理或服务端解决。

<audio
  controls
  onPlay="{play}"
  onPause="{pause}"
  ref="{audio}"
  src="{audioURL}"
  crossorigin="anonymous"
></audio>

可视化特效实现

以下选取项目部分功能的实现原理进行说明

专辑图片旋转动画

因为每个示例都需要用到专辑图片旋转动画,因此为了方便把专辑图片的创建抽离了出来。

在 G 中画一个圆形图片需要用到Clip,这个在文档中并没有说明,但从 github 中找到了该用法。

旋转动画不能直接使用基础属性模拟,这里用到了矩阵变换,利用shape.getMatrix()获取初始矩阵,再通过transform计算出每个ratio对应的矩阵。

transform是 G 提供的一个扩展矩阵变换方法,接收 2 个参数,第一个是当前矩阵,第二个参数是 Action 数组。这里的旋转对应的 action 是:

['t', -x, -y],
['r', 旋转角度],
['t', x, y],

简单示例

代码参考如下:

import { Canvas } from "@antv/g-canvas";
import { ext } from "@antv/matrix-util";

const { transform } = ext; // G提供的矩阵变换快捷方法

type ImageCircleConfig = {
  x: number;
  y: number;
  r: number;
  shadowColor?: string;
};
export function getImageCircle(
  canvas: Canvas,
  { x, y, r, shadowColor }: ImageCircleConfig
) {
  const shadowConfig = shadowColor
    ? {
        shadowColor,
        shadowBlur: 16,
      }
    : {};
  canvas.addShape("circle", {
    attrs: {
      x,
      y,
      r,
      fill: "#262626",
      ...shadowConfig,
    },
  });
  const shape = canvas.addShape("image", {
    attrs: {
      x: x - r,
      y: y - r,
      width: 2 * r,
      height: 2 * r,
      cover: `https://source.unsplash.com/random/${2 * r}x${2 * r}?Nature`,
    },
  });
  shape.setClip({
    type: "circle",
    attrs: {
      x,
      y,
      r,
    },
  });
  // 旋转动画
  const matrix = shape.getMatrix();
  const radian = 2 * Math.PI; // 旋转360度
  shape.animate(
    (ratio: number) => {
      return {
        matrix: transform(matrix, [
          ["t", -x, -y],
          ["r", radian * ratio],
          ["t", x, y],
        ]),
      };
    },
    {
      duration: 10000,
      repeat: true,
    }
  );
  // 创建后先暂停动画,等待播放后再恢复
  setTimeout(() => {
    shape.pauseAnimate();
  });
  return shape;
}

在圆上的点

示例中经常要计算的就是在圆上的点,以柱状条特效(示例一)为例,首先就是要出围绕着圆的平均 64 个点作为初始坐标。

可通过利用当前点与圆心的夹角结合简单三角函数运算出 x,y 的偏移量。

如下图, l = cos(θ) * r, t = sin(θ) * r, 通过圆心 O 坐标加上偏移量即可算出点 A 坐标。

获取圆上的点

// POINT_NUM = 64 柱状条数
sArr.current = Array.from({ length: POINT_NUM }, (item, index: number) => {
  const deg = index * (360 / POINT_NUM) - 150; // 当前角度
  const l = Math.cos((deg * Math.PI) / 180); // x方向偏移系数
  const t = Math.sin((deg * Math.PI) / 180); // y方向偏移系数
  const r = R + OFFSET;
  return (canvas.current as Canvas)
    .addShape("rect", {
      attrs: {
        width: RECT_WIDTH,
        height: RECT_WIDTH,
        radius: RECT_WIDTH / 2,
        x: X + l * r - RECT_WIDTH / 2,
        y: Y + t * r - RECT_WIDTH / 2,
        fill: RECT_COLOR,
      },
    })
    .rotateAtPoint(X + l * r, Y + t * r, ((deg - 90) * Math.PI) / 180);
});

这里每个柱状条都需要进行旋转来围绕圆排列,使用的是rotateAtPoint绕着初始点旋转对应角度。

基本所有的示例都需要首先计算出围绕圆的点坐标,都是采用这种方式计算即可。

使用 Path 绘制圆形

某些场景下需实现一些类圆动画(示例二、三等),但圆形是无法实现这种动画的,这时候可以采用 Path 实现。

在初始状态未进行播放时,默认会显示一个圆形,这是为了减少创建一个圆的实例,可以直接利用 Path 绘制出圆形,后续的动画直接更改这个 Path 实例。

可以使用 2 个圆弧生成生成一个圆形的 Path, 参考以下代码

export function getCirclePath(cx: number, cy: number, r: number) {
  return `M ${cx - r}, ${cy}
  a ${r}, ${r} 0 1, 0 ${r * 2}, 0 
  a ${r}, ${r} 0 1, 0 ${-r * 2}, 0`;
}

通过点形成平滑曲线

若仅仅是将目标一组点连接成线,在视觉效果上会显得很突兀,及时改换成 Path 来连接成曲线也是不够平滑。

这时候可以采用插值法为连续目标点再插入中间点来为 Path 更加平滑,一般来说都是采用三次样条插值算法实现。

在 d3 中内置了很多连线算法方案,可以直接采用。在本次的示例中,遇到多个点生成平滑曲线的都是采用了 d3 的curveCardinalClosed算法来生成 Path 路径。

// s-path.tsx
import { line, curveCardinalClosed } from "d3";
// some other code...
useEffect(() => {
  if (props.data?.length) {
    const pathArr: any[] = [[], [], [], []];
    getArray(props.data).map((item, index) => {
      pathArr[index % 4].push(
        getPointByIndex(index, ((item * item) / 65025) * POINT_OFFSET + 4)
      );
    });
    pathArr.map((item, index) => {
      // 使用d3的curveCardinalClosed为目标点数组插值生成平滑曲线Path
      const path = line()
        .x((d: [number, number]) => d[0])
        .y((d: [number, number]) => d[1])
        .curve(curveCardinalClosed)(item);
      sPathArr.current[index]?.attr("path", path);
    });
  }
}, [props.data]);

d3其他平滑曲线算法示例可参考笔者在很久以前写的 Demo: Click here

在圆上的点跟随圆放大的同时做圆周运动

圆周运动

示例五中的动画会出现在圆上的点跟随圆放大的同时做圆周运动,这种动画在实现时有两种方案:

第一种,是大圆利用 Path 模拟,然后动画开始后在每帧动画中,利用Path.getPoint(ratio: number)获取当前大圆中点当前帧下某个对应点的坐标。

第二种,是直接计算出当前帧下这个点在圆上的位置,利用三角函数结合大圆的放大偏移系数与ratio即可计算出当前点坐标。

在实现第一种方案时,发现效果不太理想,不知道是不是有 setTimeout 的原因,弃用了然后选择了方案二实现。

部分参考代码如下:

Array.from({ length: CIRCLE_NUM }, (item, index) => {
  circleArrStart.current.push(false);
  // circle大圆
  circleArr.current.push(addCircle());
  circleArr.current[index].animate((ratio: number) => {
    return {
      r: R + ratio * CIRCLE_SCALE_OFFSET,
      // path: getCirclePath(X, Y, R + ratio * 80),
      opacity: ratio > 0.02 && ratio < 0.9 ? 0.8 - ratio * 0.8 : 0,
    };
  }, animateOption);
  // circle-dot大圆上的点
  circleDotArr.current.push(addCircleDot());
  circleDotDegArr.current.push(0);
  circleDotArr.current[index].animate((ratio: number) => {
    if (props.data && ratio < 0.05 && !circleDotDegArr.current[index]) {
      circleDotDegArr.current[index] = pickStartPoint();
    } else if (ratio > 0.9) {
      circleDotDegArr.current[index] = 0;
    }
    const deg = circleDotDegArr.current[index] + ratio * 360 - 180;
    const l = Math.cos((deg * Math.PI) / 180);
    const t = Math.sin((deg * Math.PI) / 180);
    const r = R + ratio * CIRCLE_SCALE_OFFSET;
    return {
      x: X + l * r,
      y: Y + t * r,
      r: DOT_R * (1 - ratio / 2),
      opacity: ratio > 0.05 && ratio < 0.9 ? 0.8 - ratio * 0.8 : 0,
    };
  }, animateOption);
});

粒子特效的实现

示例六是一个粒子特效效果,也是实现这么多示例中耗时比较多的一个,这里拿出来说一下实现原理。

与其他示例一样初始化时,先初始化出专辑圆形图。

然后准备初始化粒子,定义圆形作为粒子形状,尽量小一点,可以开启阴影效果,但是性能会很差,这次就把 Shadow 阴影关闭了。

定义每个取样点周围的粒子数,当前为 64 个音频样点,一个样点设置 12 个粒子(可以更多,同样越多就约耗能),最终粒子数为 64 X 12 个。

使用随机值生成粒子样点,这里可以使用样点当前角度再随机偏移一定量即可生成均匀的粒子。

粒子效果的比较难的在于动画上,要选择一个合适的漂浮动画函数。这次示例选择了正弦函数实现左右均匀漂浮,在加上利用setTimeout随机延迟粒子生成时间即可完成粒子按一定规律下漂浮的动画。

定义粒子动画时,通过正弦函数与 ratio 计算出每帧粒子的实际 x,y 坐标即可。因为这次还会结合当前音频数据,让某个样点的粒子飘得高一点,让粒子的偏移量加大,这时还需要进一步对动画进行更改。

粒子特效

// POINT_NUM = 64 样点数
// PARTICLE_NUM = 12 样点周围粒子数
Array.from({ length: POINT_NUM }, (point, index1) => {
  Array.from({ length: PARTICLE_NUM }, (particle, index2) => {
    const deg = index1 * (360 / POINT_NUM) - 150 + (Math.random() - 0.5) * 10;
    const l = Math.cos((deg * Math.PI) / 180);
    const t = Math.sin((deg * Math.PI) / 180);
    const r = R + OFFSET;
    const x = X + l * r;
    const y = Y + t * r;
    const particleShape = (canvas.current as Canvas).addShape("circle", {
      attrs: {
        x,
        y,
        r: 0.8,
        fill: "#fff",
        opacity: 0,
        // ⚠开启阴影会掉帧
        // shadowColor: '#fcc8d9',
        // shadowBlur: 1
      },
    });
    particleShape.animate(
      (ratio: number) => {
        const deg = index1 * (360 / POINT_NUM) - 150 + Math.sin(ratio * 20) * 4;
        const l = Math.cos((deg * Math.PI) / 180);
        const t = Math.sin((deg * Math.PI) / 180);
        const _index = POINT_NUM * index1 + index2;
        if (particleActiveArr.current[_index]) {
          if (ratio < 0.02) {
            particleActiveArr.current[_index] =
              index1 >= currentActiveIndex.current - 1 &&
              index1 <= currentActiveIndex.current + 1
                ? POINT_ACTIVE_MOVE_LENGTH
                : POINT_MOVE_LENGTH;
          } else if (ratio > 0.98) {
            particleActiveArr.current[_index] = POINT_MOVE_LENGTH;
          }
        }
        const offset = particleActiveArr.current[_index] || POINT_MOVE_LENGTH;
        return {
          x: x + l * ratio * offset,
          y: y + t * ratio * offset,
          opacity: 1 - ratio,
        };
      },
      {
        duration: POINT_CREATE_DELAY,
        repeat: true,
        easing: "easeSinInOut",
      }
    );
    particleArr.current.push(particleShape);
    particleStartArr.current.push(false);
    particleActiveArr.current.push(POINT_MOVE_LENGTH);
  });
});

其他说明

这个项目是一个练手项目,基于viteReactTypescript,因为 react 平时用的不多,项目中存在什么问题或写的不好的地方欢迎指点。

或者有什么好看的特效也可以提 ISSUE 或 PR 交流一下怎么实现。

乳白
杏仁黄
茉莉黄
麦秆黄
油菜花黄
佛手黄
篾黄
葵扇黄
柠檬黄
金瓜黄
藤黄
酪黄
香水玫瑰黄
淡密黄
大豆黄
素馨黄
向日葵黄
雅梨黄
黄连黄
金盏黄
蛋壳黄
肉色
鹅掌黄
鸡蛋黄
鼬黄
榴萼黄
淡橘橙
枇杷黄
橙皮黄
北瓜黄
杏黄
雄黄
万寿菊黄
菊蕾白
秋葵黄
硫华黄
柚黄
芒果黄
蒿黄
姜黄
香蕉黄
草黄
新禾绿
月灰
淡灰绿
草灰绿
苔绿
碧螺春绿
燕羽灰
蟹壳灰
潭水绿
橄榄绿
蚌肉白
豆汁黄
淡茧黄
乳鸭黄
荔肉白
象牙黄
炒米黄
鹦鹉冠黄
木瓜黄
浅烙黄
莲子白
谷黄
栀子黄
芥黄
银鼠灰
尘灰
枯绿
鲛青
粽叶绿
灰绿
鹤灰
淡松烟
暗海水绿
棕榈绿
米色
淡肉色
麦芽糖黄
琥珀黄
甘草黄
初熟杏黄
浅驼色
沙石黄
虎皮黄
土黄
百灵鸟灰
山鸡黄
龟背黄
苍黄
莱阳梨黄
蜴蜊绿
松鼠灰
橄榄灰
蟹壳绿
古铜绿
焦茶绿
粉白
落英淡粉
瓜瓤粉
蜜黄
金叶黄
金莺黄
鹿角棕
凋叶棕
玳瑁黄
软木黄
风帆黄
桂皮淡棕
猴毛灰
山鸡褐
驼色
茶褐
古铜褐
荷花白
玫瑰粉
橘橙
美人焦橙
润红
淡桃红
海螺橙
桃红
颊红
淡罂粟红
晨曦红
蟹壳红
金莲花橙
草莓红
龙睛鱼红
蜻蜓红
大红
柿红
榴花红
银朱
朱红
鲑鱼红
金黄
鹿皮褐
醉瓜肉
麂棕
淡银灰
淡赭
槟榔综
银灰
海鸥灰
淡咖啡
岩石棕
芒果棕
石板灰
珠母灰
丁香棕
咖啡
筍皮棕
燕颔红
玉粉红
金驼
铁棕
蛛网灰
淡可可棕
中红灰
淡土黄
淡豆沙
椰壳棕
淡铁灰
中灰驼
淡栗棕
可可棕
柞叶棕
野蔷薇红
菠萝红
藕荷
陶瓷红
晓灰
余烬红
火砖红
火泥棕
绀红
橡树棕
海报灰
玫瑰灰
火山棕
豆沙
淡米粉
初桃粉红
介壳淡粉红
淡藏花红
瓜瓤红
芙蓉红
莓酱红
法螺红
落霞红
淡玫瑰灰
蟹蝥红
火岩棕
赭石
暗驼棕
酱棕
栗棕
洋水仙红
谷鞘红
苹果红
铁水红
桂红
极光红
粉红
舌红
曲红
红汞红
淡绯
无花果红
榴子红
胭脂红
合欢红
春梅红
香叶红
珊瑚红
萝卜红
淡茜红
艳红
淡菽红
鱼鳃红
樱桃红
淡蕊香红
石竹红
草茉莉红
茶花红
枸枢红
秋海棠红
丽春红
夕阳红
鹤顶红
鹅血石红
覆盆子红
貂紫
暗玉紫
栗紫
葡萄酱紫
牡丹粉红
山茶红
海棠红
玉红
高粱红
满江红
枣红
葡萄紫
酱紫
淡曙红
唐菖蒲红
鹅冠红
莓红
枫叶红
苋菜红
烟红
暗紫苑红
殷红
猪肝紫
金鱼紫
草珠红
淡绛红
品红
凤仙花红
粉团花红
夹竹桃红
榲桲红
姜红
莲瓣红
水红
报春红
月季红
豇豆红
霞光红
松叶牡丹红
喜蛋红
鼠鼻红
尖晶玉红
山黎豆红
锦葵红
鼠背灰
甘蔗紫
石竹紫
苍蝇灰
卵石紫
李紫
茄皮紫
吊钟花红
兔眼红
紫荆红
菜头紫
鹞冠紫
葡萄酒红
磨石紫
檀紫
火鹅紫
墨紫
晶红
扁豆花红
白芨红
嫩菱红
菠根红
酢酱草红
洋葱紫
海象紫
绀紫
古铜紫
石蕊红
芍药耕红
藏花红
初荷红
马鞭草紫
丁香淡紫
丹紫红
玫瑰红
淡牵牛紫
凤信紫
萝兰紫
玫瑰紫
藤萝紫
槿紫
蕈紫
桔梗紫
魏紫
芝兰紫
菱锰红
龙须红
蓟粉红
电气石红
樱草紫
芦穗灰
隐红灰
苋菜紫
芦灰
暮云灰
斑鸠灰
淡藤萝紫
淡青紫
青蛤壳紫
豆蔻紫
扁豆紫
芥花紫
青莲
芓紫
葛巾紫
牵牛紫
紫灰
龙睛鱼紫
荸荠紫
古鼎灰
乌梅紫
深牵牛紫
银白
芡食白
远山紫
淡蓝紫
山梗紫
螺甸紫
玛瑙灰
野菊紫
满天星紫
锌灰
野葡萄紫
剑锋紫
龙葵紫
暗龙胆紫
晶石紫
暗蓝紫
景泰蓝
尼罗蓝
远天蓝
星蓝
羽扇豆蓝
花青
睛蓝
虹蓝
湖水蓝
秋波蓝
涧石蓝
潮蓝
群青
霁青
碧青
宝石蓝
天蓝
柏林蓝
海青
钴蓝
鸢尾蓝
牵牛花蓝
飞燕草蓝
品蓝
银鱼白
安安蓝
鱼尾灰
鲸鱼灰
海参灰
沙鱼灰
钢蓝
云水蓝
晴山蓝
靛青
大理石灰
海涛蓝
蝶翅蓝
海军蓝
水牛灰
牛角灰
燕颔蓝
云峰白
井天蓝
云山蓝
釉蓝
鸥蓝
搪磁蓝
月影白
星灰
淡蓝灰
鷃蓝
嫩灰
战舰灰
瓦罐灰
青灰
鸽蓝
钢青
暗蓝
月白
海天蓝
清水蓝
瀑布蓝
蔚蓝
孔雀蓝
甸子蓝
石绿
竹篁绿
粉绿
美蝶绿
毛绿
蔻梢绿
麦苗绿
蛙绿
铜绿
竹绿
蓝绿
穹灰
翠蓝
胆矾蓝
樫鸟蓝
闪蓝
冰山蓝
虾壳青
晚波蓝
蜻蜓蓝
玉鈫蓝
垩灰
夏云灰
苍蓝
黄昏灰
灰蓝
深灰蓝
玉簪绿
青矾绿
草原远绿
梧枝绿
浪花绿
海王绿
亚丁绿
镍灰
明灰
淡绿灰
飞泉绿
狼烟灰
绿灰
苍绿
深海绿
长石灰
苷蓝绿
莽丛绿
淡翠绿
明绿
田园绿
翠绿
淡绿
葱绿
孔雀绿
艾绿
蟾绿
宫殿绿
松霜绿
蛋白石绿
薄荷绿
瓦松绿
荷叶绿
田螺绿
白屈菜绿
河豚灰
蒽油绿
槲寄生绿
云杉绿
嫩菊绿
艾背绿
嘉陵水绿
玉髓绿
鲜绿
宝石绿
海沬绿
姚黄
橄榄石绿
水绿
芦苇绿
槐花黄绿
苹果绿
芽绿
蝶黄
橄榄黄绿
鹦鹉绿
油绿
象牙白
汉白玉
雪白
鱼肚白
珍珠灰
浅灰
铅灰
中灰
瓦灰
夜灰
雁灰
深灰