用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:fs{f_s}fs
时间:T
已知公式: T=NfsT=\dfrac{N}{{f_s}}T=fsN
Nfs\dfrac{N}{{f_s}}fsN的倒数称为频率分辨率Frequency Resolution:df=1T=fsNdf=\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 initializationvoid 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 framevoid 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的localScalevoid 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:增加傅里叶变换的讨论部分
用Unity3D内部频谱分析方法做音乐视觉特效的原理说明相关推荐
- 【干货】最高级的运营,就是用科学的方法做艺术
有一个很经典的问题:营销是艺术还是科学? 我们可以认为营销是一门关乎人性.情感,甚至个人价值追求的学问,从这个角度,它无疑是艺术的:但是当Growth Hacker.CMT这样的人创造一个个营销奇迹的 ...
- 手把手教你做音乐播放器(三)获取音乐信息
第3节 获取音乐信息 在"视频播放器"的开发过程当中,我们已经学会了如何获取视频文件的信息: 定义一个视频信息的数据结构VideoItem: 自定义一个AnsycTask,在它的工 ...
- 为什么不能使用 BigDecimal 的 equals 方法做等值比较
目录 前言 BigDecimal 做等值比较 使用 compareTo 方法 PS 前言 BigDecimal 是 java.math 包中提供的一种可以用来进行精确运算的类型.所以,在支付.电商等业 ...
- STM32F103单片机使用内部RC振荡器做时钟源
平时在做项目的时候都用的是外部晶振做为时钟源,想试试用内部RC振荡器做为时钟源,在网上搜了一下如何设置内部时钟,发现资料比较少的.决定将设置内部RC振荡器做为时钟源的方法记录下来. 用的单片机是STM ...
- 提升孩子的智力从用对方法做起
让孩子聪明的绝妙方法 一.睡足睡好 德国科学家发现,每晚睡眠10小时的孩子成绩优于每晚睡 眠小于8小时的孩子.大脑充分休息,才能提高智力水平. 二.重视早餐 美国科学家实验证明,在其余实验条件相同的情 ...
- 三维重建方法--激光or视觉
导读: 激光雷达则是无人驾驶和扫地机器人等领域的核心一环.那么为什么出现多种方案呢?它们到底有什么差异? 看似很酷炫的技术,实际上并没有外界想得那么高大上. Realsense之所以能够识别物体的深度 ...
- 用计算机搞音乐,用电脑键盘做音乐
(本文截选自<电脑报??汤楠的电脑音乐世界(连载)>) 上期给大家介绍了如何将游戏手柄作为MIDI控制器使用,轻松控制MIDI信号.不过据我所知很多音乐人很少玩游戏,购买游戏手柄还需要额外 ...
- 声网高纯:领域和方向要聚焦,用最专业的方法做最专业的事丨人物专访
前言 本期「声网开发者 x 人物专访」的受访者,是声网高级架构师 @高纯. 高纯是 W3C 组织的 AC REP(Advisory Committee Representative),还是一名管乐爱好 ...
- 用简单的方法做整套UI(教程第一/二/三弹合集)
http://bbs.66rpg.com/thread-329530-1-1.html http://v.tieba.baidu.com/p/2985559487 首先要准备两个工具,"美图 ...
最新文章
- Java并发基础:了解无锁CAS就从源码分析
- 基于Docker的开源端到端开发者平台
- [PHP] Laravel常见报错总结(持续更新)
- C++面试中string类的一种正确写法
- java 所有子类_java 查找类的所有子类
- weakhashmap_Java WeakHashMap keySet()方法与示例
- maven中ssm框架快速搭建
- Java项目课程06:系统实现-数据库
- 3.看板方法---一种成功秘诀
- 大数据开发,如何发掘数据的关系?
- ibm x60 学习linux,IBM X60 T60系列安装系统时SATA设置问题
- 判断一个字符串能否通过添加一个字符变成回文串
- 宝塔面板nginx域名配置
- Android camera2对焦设置
- C++实现 数字游戏之拼出最大数
- NEUQ ACM预备队训练-week5(图的基础存图和dfs)
- 使用ASP.NET.MVC制作手机接收验证码
- 机器视觉-相机镜头光源介绍及选型-3.光源分类
- 安全计算:使用ClamWin为高级用户提供免费病毒防护
- oracle增量恢复dg备库,rman增量恢复DG备库出现GAP的情况
热门文章
- 计算机中级职称.临沂,山东临沂2017年中级会计职称考试时间:9月9日-10日
- Ubuntu18.04安装微信记录
- 在美国,你才是真的得不起病……
- 深度学习之Keras检测恶意流量
- echarts环形图高亮提示文字位置位于中间_CAD多行文字的格式设置
- MLX96014 红外温度传感器EEPROM内数据修改失败
- 双非本科计算机考研985很难吗,本科双非报考985、211受歧视?
- MySQL-数据类型(一)
- windows10强制删除文件_Windows 10、8、7的7种最佳磁盘分区软件
- 使用Docker部署GitLab、Nexus、Registry私服