用Unity3D内部频谱分析方法做音乐视觉特效的原理说明

视频

http://v.youku.com/v_show/id_XMTU0NTk4NjgwOA==.html

FIESTAR: Mirror

视频截图

这里写图片描述
这里写图片描述

先理解几个名词和概念:

声音:一种波动,通过空气分子有节奏的震动进行传递。
声音频率Hz:声音每秒种震动的次数,以赫兹Hz 表示。频率越高,音高越高。
分贝dB:量度两个相同单位之数量比例的单位,可表示声音的强度单位。
人耳可听到的声波频率:每秒振动20次到20000次的范围内,既20赫兹至20000赫兹之间,。
采样Sampling:在信号处理程序中,将连续信号(例如声波)降低成离散信号(一系列样本数据)。
采样率Sampling Rate:每秒从连续信号中提取并组成离散信号的采样个数,单位也是赫兹。
快速傅里叶变换FFT:一种算法,可用来转换信号。
窗函数Window Function:在信号处理之中,用来降低信噪比的一种算法。
信噪比:
—噪讯比越高的话,声音的大音量和小音量的音量差会越大(音质猛爆)。
—噪讯比越低的话,声音的大音量和小音量的音量差会越小(音质柔和)。

然后我们看一下Unity内置的这条命令:

AudioSource.GetSpectrumData

public void GetSpectrumData(float[] samples, int channel, FFTWindow window);

samples:
函数返回值。每个元素代表该音源当前在某个赫兹的强度。针对快速傅里叶变换算法的性能,数组大小必须为2的n次方,最小64,最大8192。
channel:
一般设置为0。该参数与硬件是mono或是stereo有关,mono的话所有的音响会播放同一个音源,而stereo立体声的话不同的音响会播放不同的音源,因此出现了一个channel的概念,通过指定channel可以只取stereo的某个音源的data,设为0的话会按照mono的方式取整个音源。
window:
辅助快速傅里叶变换的窗函数,算法越复杂,声音越柔和,但速度更慢。

用法:

先声明一个浮点数组:

public float[] spectrumData=new float[8192];

在Update方法里面使用方法:

thisAudioSource.GetSpectrumData(spectrumData,0,FFTWindow.BlackmanHarris);

那么这个方法传送到浮点数组里的数据是什么呢?

已知了开始部分的概念,我们可以定义几个变量:
一系列采样数据样本Samples: N
采样频率Sampling Rate: f s {f_s} fs
时间:T
已知公式: T = N f s T=\dfrac{N}{{f_s}} T=fsN

N f s \dfrac{N}{{f_s}} fsN的倒数称为频率分辨率Frequency Resolution: d f = 1 T = f s N df=\dfrac{1}{T}=\dfrac{f_s}{N} df=T1=Nfs

频率分辨率越高,转换出来的数据越精确(下图,同样情况下,低频率分辨率与高频率分辨率的比较)。

这里写图片描述

这里写图片描述
而我们声明的浮点数数组的大小既是频率分辨率,而数组中每个浮点数的值既是谱密度,既此元素所代表的频率波携带的功率。我们知道了数组长度既当前频率分辨率既是df=8192,那么每个元素的谱密度dB表示的的是哪个频率范围或音高范围的功率呢?

实际测试一下。目前数字音乐领域的采样率通常为44100Hz,通过软件分析音频文件[MV] FIESTAR(피에스타) _ Mirror.mp3的频谱,16000Hz以上的谱密度都非常低了。

这里写图片描述

而在Unity内Debug spectrumData的数值,spectrumData[5500]左右以后的浮点数值与前面有一个断崖似的减少。因此综合音频软件分析的结果可推断出spectrumData[5500]大概对应16000Hz,那么16000/5500*8192=23831,GetSpectrumData的采样的最高频率应该是在20000~23000赫兹之间,既音频文件23000赫兹以上频率的数据都被忽略掉了。

如果继续深入,可研究声波频率与音高的关系,将spectrumData特定范围的浮点数相加即可体现乐曲中各个音高的谱密度,由于人的听觉系统对音高最为敏感,其视觉效果应该会更加理想。(传送门:对该思路进行实践的下一篇系列文章)

在这里插入图片描述
(由上图可见音高的最高频率是15804.639Hz,与上面实际测试的结果一致)

有关傅里叶变换

GetSpectrumData的核心算法是FFT快速傅里叶变换。文章开头已经说明,声音是一种波动,可以把它当成一种正弦波形,波形的频率与音高有关,波形的振幅与音量有关。例如下图中是两个频率不变振幅逐渐加大的声波。

在这里插入图片描述


(x轴为时间,y轴为声波的振幅)

在有关波形的另一篇文章中说过,波形有个叠加原理,我们可以将上面两个声波叠加在一起:

在这里插入图片描述


音频文件要存储的东西实际上就是这种不同频率与振幅的声波叠加后的复合波而已。如此看来,生成一个音频文件,或者说做音乐,实际上就是对不同的声波或复合波(调音师称为音色)进行反复叠加,而傅里叶变换要做的事正好相反,是对一个复合波形进行分解。无论一个复合波如何复杂,经过一些列神奇的计算,傅里叶变换都能将其中所有的单一频率的波形一一分解出来。

从波形的角度来理解,傅里叶变换要经过几个二维空间的转换:

时间轴x/功率y => 频率x/功率y => 时间轴x/频率a功率y
                                                 => 时间轴x/频率b功率y
                                                 => 时间轴x/频率c功率y
                                                 …

而最终的单个频率a的功率y既是上面spectrumData数组中的某个元素的值。

傅里叶变换需要以不同频率对复合波进行采样,一次采样的对象值称为一个Sample,正确的计算结果需要足够数量的Samples,这个环节有一个相关的奈奎斯特采样定理,简单来讲既是对一个频率为3Hz的声波,傅里叶变换至少要在一秒内对其进行频率3*2=6次采样才能较正确的计算出结果。

上面说过了音乐工业标准的采样率是44100,也就是音频文件每秒内有44100个样本Samples,以离散的形式保存了最终复合波。那么由于奈奎斯特采样定理的限制,可以进行傅里叶变换的最高声波频率为22050Hz,与上文中的实际测试结果也是一致的。如果可以确定GetSpectrumData方法返回的最高频率为22050,接下来就可以进一步确定spectrumData数组中每个元素代表的确切频率,例如数组长度如果为8192:

spectrumData[0] 22050Hz/8192*1=2.6916 Hz
spectrumData[1] 22050Hz/8192*2=5.3833 Hz

spectrumData[8195] 22050Hz/8192*8192=22050 Hz

那么对照上文中的频率/音高表,结合调整数组长度,就可以比较准确的拿到各个音高的功率。

另一个可以确定的问题就是傅里叶变换的采样次数。当spectrumData数组的长度越长,需要采样的不同频率越多,采样次数越多,GetSpectrumData的性能消耗既是越大。


(2020-01-08 add 项目源码)

GitHub链接:
https://github.com/liu-if-else/UnitySpectrumData
部分源码:

using UnityEngine;
using System.Collections;
using DG.Tweening;

public class Controller : MonoBehaviour {
    //音频相关
    public AudioSource thisAudioSource;
    private float[] spectrumData = new float[8192];
    //cube相关
	public GameObject cubePrototype;
	public Transform startPoint;
	private Transform[] cube_transforms=new Transform[8192];
    private Vector3[] cubes_position= new Vector3[8192];
    //颜色相关
    public GridOverlay gridOverlay;
    private MeshRenderer[] cube_meshRenderers = new MeshRenderer[8192];
    private bool cubeColorChange;
    private bool gridColorChange;
    //相机移动相关
    public Vector3 cameraStartPoint;
    public Transform cameraTransform;
    public bool lookat0_1;
    public bool lookat1_2;
    public bool lookat2_3;
    public Vector3 lookat0_1_vector = Vector3.zero;
    public Vector3 lookat1_2_vector = new Vector3(106f, 12f, 78f);
    public Vector3 lookat2_3_vector = Vector3.zero;
    private Vector3[] moveTos = new Vector3[8192];
    public Transform cubes_parent;
    private bool cubesRotate = true;
	// Use this for initialization
	void Start () {
        //cube生成与排列
		Vector3 p=startPoint.position;

		for(int i=0;i<8192;i++){
			p=new Vector3(p.x+0.11f,p.y,p.z);
            GameObject cube=Object.Instantiate(cubePrototype,p,cubePrototype.transform.rotation)as GameObject;
			cube_transforms[i]=cube.transform;
            cube_meshRenderers[i] =cube.GetComponent<MeshRenderer>();
		}

		p=startPoint.position;

		float a=2f*Mathf.PI/5461;

		for(int i=0;i<5461;i++){
			cube_transforms[i].position=new Vector3(p.x+Mathf.Cos(a)*131,p.y,p.z+131*Mathf.Sin(a));
			a+=2f*Mathf.PI/5461;
            cubes_position[i]=cube_transforms[i].position;
			cube_transforms[i].parent=startPoint;
		}
        //颜色相关
        gridColorChange = false;
        cubeColorChange = false;
        Invoke("SwitchCC", 3f);
        //相机移动相关
        cameraStartPoint = cameraTransform.position;
        StartCoroutine(CameraMovement());
        //延迟播放音频
        thisAudioSource.PlayDelayed(2f);
	}
	// Update is called once per frame
	void Update () {
        Spectrum2Cube();
        DynamicColor();
        CameraLookAt();
	}
	//颜色相关
    void SwitchCC(){
        cubeColorChange = !cubeColorChange;
    }
    void SwitchGC(){
        gridColorChange = !gridColorChange;
    }
	void DynamicColor(){
        if (cubeColorChange)
        {
            for (int i = 0; i < 5461; i++)
            {
                cube_meshRenderers[i].material.SetColor("_Color", new Vector4(Mathf.Lerp(cube_meshRenderers[i].material.color.r, spectrumData[i] * 500f, 0.2f), 0.5f, 1f, 1f));
            }
        }
        if (gridColorChange)
        {
            float gridColor = Mathf.Lerp(gridOverlay.mainColor.r, spectrumData[2000] * 1000, 0.5f);
            if (gridColor > 1)
            {
                gridColor = 1;
            }
            gridOverlay.mainColor = new Vector4(gridColor, 0.5f, 1f, 1f);
        }
    }
    //thisAudioSource当前帧频率波功率,传到对应cube的localScale
    void Spectrum2Cube(){
        thisAudioSource.GetSpectrumData(spectrumData, 0, FFTWindow.BlackmanHarris);
        for (int i = 0; i < 5461; i++)
        {
            cube_transforms[i].localScale = new Vector3(0.15f, Mathf.Lerp(cube_transforms[i].localScale.y, spectrumData[i] * 10000f, 0.5f), 0.15f);
        }
    }
    //相机角度控制
    void CameraLookAt(){
        if (lookat0_1)
        {
            cameraTransform.LookAt(lookat0_1_vector);
        }
        if (lookat1_2)
        {
            cameraTransform.LookAt(lookat1_2_vector);

        }
        if (lookat2_3)
        {
            cameraTransform.LookAt(cubes_position[5190]);
        }
    }
    //网格动画
    IEnumerator GridOff()
    {
        for (int i = 0; i < 51; i++)
        {
            gridOverlay.largeStep += 10;
            yield return new WaitForSeconds(0.02f);
        }
        gridOverlay.showMain = false;

    }
    IEnumerator GridOn()
    {
        gridOverlay.showMain = true;
        gridColorChange = true;
        gridOverlay.largeStep = 500;
        for (int i = 0; i < 49; i++)
        {
            gridOverlay.largeStep -= 10;
            yield return new WaitForSeconds(0.02f);
        }
    }
    //相机重复移动,暂无退出机制
    public void CameraRepeatMove()
    {
        StopAllCoroutines();
        StartCoroutine(CameraMovement());
        if (cubesRotate)
        {
            cubesRotate = false;
            cubes_parent.DORotate(new Vector3(0f, 360f, 0f), 117f, RotateMode.FastBeyond360);
        }
        gridColorChange = false;
    }
    //相机移动脚本
    IEnumerator CameraMovement()
    {
        yield return new WaitForSeconds(20f);
        lookat2_3_vector = new Vector3(cubes_position[5200].x, 12f, cubes_position[5200].z);
        cameraTransform.DOMove(startPoint.position, 20f);
        for (int i = 0; i < 8192; i++)
        {
            moveTos[i] = new Vector3(cubes_position[i].x, 10f, cubes_position[i].z);
        }
        yield return new WaitForSeconds(20f);
        cameraTransform.DOMove(new Vector3(126f, 252f, 1f), 10f);
        cameraTransform.DOLookAt(Vector3.zero, 10f, AxisConstraint.None, Vector3.up);
        yield return new WaitForSeconds(10f);
        cameraTransform.DOMove(new Vector3(106f, 12f, 78f), 19f);
        cameraTransform.DOLookAt(lookat1_2_vector, 19f, AxisConstraint.None, Vector3.up);
        yield return new WaitForSeconds(19f);
        lookat1_2 = false;
        StartCoroutine(GridOn());
        cameraTransform.DOLookAt(lookat2_3_vector, 8f, AxisConstraint.None, Vector3.up);
        cameraTransform.DOMove(new Vector3(cubes_position[5460].x, 12f, cubes_position[5460].z), 8f);
        yield return new WaitForSeconds(8f);
        cameraTransform.DOLookAt(cubes_position[5200], 2f, AxisConstraint.None, Vector3.up);
        yield return new WaitForSeconds(2f);
        int counter = 0;
        while (counter < 2700)
        {
            cameraTransform.LookAt(cubes_position[5200 - counter]);
            cameraTransform.DOMove(moveTos[5460 - counter], 0.01f);
            yield return new WaitForSeconds(0.01f);
            counter += 10;
        }
        cameraTransform.DOLookAt(lookat0_1_vector, 3f, AxisConstraint.None, Vector3.up);
        yield return new WaitForSeconds(3f);
        StartCoroutine(GridOff());
        lookat0_1 = true;
        cameraTransform.DOMove(new Vector3(cameraStartPoint.x, cameraStartPoint.y + 300f, cameraStartPoint.z), 6f);
        yield return new WaitForSeconds(6f);
        lookat0_1 = false;
        CameraRepeatMove();
    }
}


传送门:
下一篇系列文章:用Unity的GetSpectrumData方法识别钢琴曲中的钢琴琴键
https://blog.csdn.net/liu_if_else/article/details/124908996


我写的本文英文版:
https://liu-if-else.github.io/unity3d-audio-visualizer/


参考:
奈奎斯特采样定理(Nyquist) — Zero to One
https://www.cnblogs.com/zoneofmine/p/10853096.html

Algorithmic Beat Mapping in Unity: Real-time Audio Analysis Using the Unity API — Jesse
https://medium.com/giant-scam/algorithmic-beat-mapping-in-unity-real-time-audio-analysis-using-the-unity-api-6e9595823ce4

形象的介绍—什么是傅里叶变换 — 3Blue1Brown
https://www.youtube.com/watch?v=spUNpyF58BY


维护日志:
2020-1-8:review,附上项目与源码 (GitHub链接:
https://github.com/liu-if-else/UnitySpectrumData

2020-11-11:增加傅里叶变换的讨论部分


版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


学习编程是顺着互联网的发展潮流,是一件好事。新手如何学习编程?其实不难,不过在学习编程之前你得先了解你的目的是什么?这个很重要,因为目的决定你的发展方向、决定你的发展速度。
IT行业是什么工作做什么?IT行业的工作有:产品策划类、页面设计类、前端与移动、开发与测试、营销推广类、数据运营类、运营维护类、游戏相关类等,根据不同的分类下面有细分了不同的岗位。
女生学Java好就业吗?女生适合学Java编程吗?目前有不少女生学习Java开发,但要结合自身的情况,先了解自己适不适合去学习Java,不要盲目的选择不适合自己的Java培训班进行学习。只要肯下功夫钻研,多看、多想、多练
Can’t connect to local MySQL server through socket \'/var/lib/mysql/mysql.sock问题 1.进入mysql路径
oracle基本命令 一、登录操作 1.管理员登录 # 管理员登录 sqlplus / as sysdba 2.普通用户登录
一、背景 因为项目中需要通北京网络,所以需要连vpn,但是服务器有时候会断掉,所以写个shell脚本每五分钟去判断是否连接,于是就有下面的shell脚本。
BETWEEN 操作符选取介于两个值之间的数据范围内的值。这些值可以是数值、文本或者日期。
假如你已经使用过苹果开发者中心上架app,你肯定知道在苹果开发者中心的web界面,无法直接提交ipa文件,而是需要使用第三方工具,将ipa文件上传到构建版本,开...
下面的 SQL 语句指定了两个别名,一个是 name 列的别名,一个是 country 列的别名。**提示:**如果列名称包含空格,要求使用双引号或方括号:
在使用H5混合开发的app打包后,需要将ipa文件上传到appstore进行发布,就需要去苹果开发者中心进行发布。​
+----+--------------+---------------------------+-------+---------+
数组的声明并不是声明一个个单独的变量,比如 number0、number1、...、number99,而是声明一个数组变量,比如 numbers,然后使用 nu...
第一步:到appuploader官网下载辅助工具和iCloud驱动,使用前面创建的AppID登录。
如需删除表中的列,请使用下面的语法(请注意,某些数据库系统不允许这种在数据库表中删除列的方式):
前不久在制作win11pe,制作了一版,1.26GB,太大了,不满意,想再裁剪下,发现这次dism mount正常,commit或discard巨慢,以前都很快...
赛门铁克各个版本概览:https://knowledge.broadcom.com/external/article?legacyId=tech163829
实测Python 3.6.6用pip 21.3.1,再高就报错了,Python 3.10.7用pip 22.3.1是可以的
Broadcom Corporation (博通公司,股票代号AVGO)是全球领先的有线和无线通信半导体公司。其产品实现向家庭、 办公室和移动环境以及在这些环境...
发现个问题,server2016上安装了c4d这些版本,低版本的正常显示窗格,但红色圈出的高版本c4d打开后不显示窗格,
TAT:https://cloud.tencent.com/document/product/1340