视频
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];
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 举报,一经查实,本站将立刻删除。