1. 核心出装
起因是晨郢给了这么一个思考题:
水神(上次数学 150 那位)一下课就冲过来向我宣称,答案就是
我大受震撼,上机验证发现是对的,就近查询,机房大蛇告诉我这玩意和单位根反演有密切联系,还说这个系数可以 FFT 出来,题设序列可以拓展到任意长度、周期、正负号。然后傅里叶变换这玩意迟早要弄透,于是有了这篇。
多项式拆解
\(\Re\left((1+i)^{21}\right)\) 是凭数感观察出来的,这个取实部看着不是很能拓展。写成共轭的标准表达:
我们想要知道,\(1\) 后面跟着的 \(\pm i\) 是怎么出来的。重写问题:
我们希望将其表达成:
也即要求:
单位根构造
接下来的构造是完备且一体的,并没有特定的 insight 指引我们观察出这个构造,只能去欣赏它的简洁优美。
我们取
其中 \(\omega\) 为复数域上的单位根:
到现在我们依然没有解释 \(n\) 是怎么得出的。实则 \(n\) 为题设序列 \(a\) 的任意周期(e.g. 这个例题的 \(n=4,8,12,16,20,22\))。又因为 \(\color{lime}x_j^{k+n}=x_j^k\),所以 \(x_j^k\) 关于 \(k\) 也有 \(n\) 的周期。那么 ① 的条件可以直接改成 \(\forall k\in[0,n)\)。
至此我们扔掉了组合数并把问题的范围缩小到了一个 \(n\)。
DFT
要把问题转成 DFT 的形式,需要利用单位根的性质
孩子们,\(a\) 和 \(b\) 互为同一个多项式的点值和系数表示法!已知 \(a\) 求 \(b\) 就是一个 IDFT 的事情。(注意 \(b_j\in \C\))
#include <cstdio>
#include <cmath>const double pi=acos(-1),eps=1e-7;
const int N=(1<<21)|1;struct cplx{double r,i;}A[N];
cplx operator+(const cplx &x,const cplx &y){return (cplx){x.r+y.r,x.i+y.i};
}
cplx operator-(const cplx &x,const cplx &y){return (cplx){x.r-y.r,x.i-y.i};
}
cplx operator*(const cplx &x,const cplx &y){return (cplx){x.r*y.r-x.i*y.i,x.r*y.i+x.i*y.r};
}int r[N],n;void FFT(cplx* a,int typ){for(int i=0;i<n;++i) if(i<r[i]){cplx tmp=a[i];a[i]=a[r[i]];a[r[i]]=tmp;}for(int w=1;w<n;w<<=1){cplx o1=(cplx){cos(pi/w),typ*sin(pi/w)};for(int i=0;i<n;i+=(w<<1)){cplx ok=(cplx){1,0};for(int j=i;j<i+w;ok=ok*o1,++j){cplx u=a[j],d=ok*a[j+w];a[j]=u+d;a[j+w]=u-d;}}}
}void wr(double r,double i){printf("(%.4lf",r);if(i>-eps) putchar('+');printf("%.4lfi)",i);}int main()
{scanf("%d",&n);int C;scanf("%d",&C);for(int i=0;i<n;++i) scanf("%lf",&A[i].r);int m=n,l=0;for(n=1;n<m;n<<=1) ++l;for(int i=0;i<n;++i) r[i]=(r[i>>1]>>1)|((i&1)<<(l-1));FFT(A,-1);double w=2*pi/n;for(int i=0;i<n;++i){wr(A[i].r/n,A[i].i/n);wr(1+cos(w*i),sin(w*i));printf("^%d",C);if(i<n-1) puts(" +");}
}
输入 #1:
4 21
1 0 -1 0
输出 #1:
(0.0000+0.0000i)(2.0000+0.0000i)^21 +
(0.5000+0.0000i)(1.0000+1.0000i)^21 +
(0.0000+0.0000i)(0.0000+0.0000i)^21 +
(0.5000+0.0000i)(1.0000-1.0000i)^21
输入 #2:
2 20
1 -1
输出 #2:
(0.0000+0.0000i)(2.0000+0.0000i)^20 +
(1.0000+0.0000i)(0.0000+0.0000i)^20
输入 #3:
8 114514
1 3 -1 2 -4 2 -1 -2
输出 #3:
(0.0000+0.0000i)(2.0000+0.0000i)^114514 +
(0.3598-0.4419i)(1.7071+0.7071i)^114514 +
(-0.1250-0.6250i)(1.0000+1.0000i)^114514 +
(0.8902-0.4419i)(0.2929+0.7071i)^114514 +
(-1.2500+0.0000i)(0.0000+0.0000i)^114514 +
(0.8902+0.4419i)(0.2929-0.7071i)^114514 +
(-0.1250+0.6250i)(1.0000-1.0000i)^114514 +
(0.3598+0.4419i)(1.7071-0.7071i)^114514
(对 \(n\ne 2^k,k\in \Z\) 是没法补零做 FFT 的,会改变周期,只能朴素 \(n^2\) 去做)
到这里原问题就结束了。考察这个转换对计算原式的意义,实则是聊胜于无,因为你涉及 \((\cos\theta+1+i\sin \theta)^m\) 状物的计算,手算只在 \(n=4,\theta=\frac {k\pi}2\) 时能算,计算机去算的话无非就是整型炸精变成了浮点炸精(而且如果是整数域上取模算的话我为啥不直接算),所以这题相当无聊。
!?DFT?!
但是我突然发现这个工具非常强力,可以解决一个我从初二想到现在都没能实现的事情。
这是我第一次理解 DFT 和频率分解间的直接联系。这个式子完全等效于,你把 \(n\) 个采样点的离散信号 \(a\) 分解成波长为 \(\frac nj\) 的多个正弦波的叠加。那么单旋律音高提取这件事就完全可以做了。
2. 音高提取
音频读取
我们有开源精神,可以把每个采样点的功率给读出来,下面记 \(A\)。.wav 文件可以理解成是最基本、不带压缩的时间-功率映射关系。
频率分解
上面那个式子是 IDFT 出来的,实则 DFT 和 IDFT 只差前面一个 \(\frac 1n\) 的系数、并且各个 \(b_j\) 互为共轭,又由前置知识,所以你只需要对 \(A\) 做 FFT 然后取各个频率上 \(|b_j|\) 的 \(\arg\max\) 即为(近似)对应音高。
这玩意乍听上去非常色情,但实则你要考虑下面两个棘手的问题:
时间窗
直接频率分解的前提是,你要确定本段 \(A\) 序列所对应的时间片段里,音高是保持不变的。因此你需要对时间做微元(即时间窗 window。注意与采样点的概念区分,采样点是最小不可再分的时间单位)。令采样率 \(W(W=48000,44100,\cdots)\),\(U\) 为 1s 内分出 window 的个数,则 \(n=\frac WU\)。以下均取 \(W=48000\) 的最理想情况。
- \(n\) 不能太小,否则分析出来频率错位严重。你要处理的频率最低值约为 \(f_m=200\) Hz(G0 左右),波长最大值 \(\lambda_m=\frac W{f_m}=240\),那么你最少应该要求 \(n>5\lambda_m\)。
- \(U\) 不能太小,你 \(150\) bpm 的十六分音即可达到 1s 内 \(U_0=10\) 个音符,显然 \(U\ge 2U_0\) 才能对这样的情形做有效分析。
实际测出来的结论是,无论 \(W\) 为多少我们都取 \(U=25\),这是比较优的。
误差分析
DFT 出来的频率是离散的。它得到的分量永远满足:一个 window 刚好为 \(j\) 个波长,\(j\in \Z\)。那么你得到的频率也只有 \(\frac Wj,j\in \N^*\)(其实由奈奎斯特采样定理 \(j=1\) 也取不到,不过无所谓)。以下 \(W=48000\)。
首先你就要面临取整带来的直接误差。考虑 \(f\) 的计算式:
那么相对误差 \(\frac f{f_{real}}\) 会去到 \(\frac {n+\frac 12 \lambda}n\),极端情况为 \(n=2048\)(要 FFT)、\(\lambda=\lambda_m=240\),误差约为 \(1.05\)。
如果你认为这样的误差无所谓,下面还有一个更大的问题。你把 \(\frac Wj,j\in \N^*\) 列出来然后拿这玩意跟 \(\text{A1}=440\) Hz 的十二平均律标准音高列对比:
... 1200.00 1230.77 1263.16 1297.30 1333.33 1371.43 1411.76 1454.55 1500.00 1548.39 1600.00 1655.17 1714.29 1777.78 ...
... 1174.66 1244.51 1318.51 1396.91 1479.98 1567.98 1661.22 1760.00 ...
你会发现有些频率点刚好卡在两半音中间,在峰值落到这些点时你无法决策具体取两边哪个半音,这很难受,你需要处理一手邻域。
归根到底,是频谱的离散性导致了这些问题。有没有把时域上离散的信号转换成在频谱上连续表达的方法呢?有的兄弟有的。
3. DTFT
设无限序列 \(x[n]\) 的 DTFT 为
\[X(\omega)=\sum_{n=-\infty}^{\infty}x[n]e^{-i\omega n} \]DFT 实际上是:
- 截断信号
- 周期延拓
- 对 DTFT 进行等间隔采样
采样点为
\[\omega_k=\frac{2\pi k}{N} \]于是
\[X[k]=X(\omega_k) \]
变换 时域 频域 傅里叶级数 周期连续信号 离散频谱 傅里叶变换 非周期连续信号 连续频谱 DTFT 非周期离散信号 连续周期频谱 DFT 周期离散信号 离散周期频谱 其中:
- DTFT:频域周期 \(2\pi\)
- DFT:频域周期 \(N\)
你别笑,这还真是第一次听说。这直接解释了你看 EQ 那个频谱图是怎么来的。重新审视这个音高提取,它实际上是结果可枚举的基频提取:你完全可以枚举目标音区内的每个半音,按照
去计算 \(X(\omega)\) 然后取各个频率上复系数模的 \(\arg\max\)。(事实上直接单独比较这些系数的实部 / 虚部也是可行的)

(\(W=48000,f=1000 \text{Hz},n=2048\))
剩下就是一些 trivial 的处理:
-
判断音符结束位置。你不能直接根据复系数模(下称“力度”)的骤减去判断,因为你并不清楚当前阶段是 Decay 还是 Release。目前的方案是人为设置一个 cutoff \(c\),对力度 \(<c\) 的 window 认为没有音符存在。

-
判断音符开始位置。你依然不能直接根据力度的骤增判断,因为频率的“纯度”会随着一个音符进入 Hold 阶段(“浑浊”的音头部分消失)而提升,此时整个音符的响度降低,但是单个频率的系数在增加。尝试判断音头的存在,依然非常困难,我本来以为音头会有频率的显著变化,实则并没有。尝试通过判断全局力度(功率)的显著提升得到音头,依然无法,因为你不知道 Attack 有多长。所以我对相邻两音符同音的处理方式就是不处理(笑)
-
频率分析误差处理:持续(音高不动)时间 \(t=\frac {kn}W<\frac 1 {U_0}\) 的片段直接无视。
-
会出现倍频(识别出的频率为实际的 \(2\) 倍),解决方法为当 \(\frac f2\) 的力度 \(>f\) 力度的 \(\frac 1{20}\) 时认为 \(\frac f2\) 为所求音高。暂时还没测出半频的情况。
这实际上根本无法处理捏过包络的非原声乐器。完全基于频域的音高提取算法已经不是主流了(时域上有 YIN,现在基于神经网络和 GAN 的更是随便做),看一乐呵就行。
#include "AudioFile.h"
#include <map>using namespace std;const double pi=acos(-1);AudioFile<double> File;const int LEN=6e6,SP=100;map<string,double> Freq;map<string,string> nxt;void iitFreq(){nxt["G#"]="A";Freq["A"]=440;const double d1=pow(2,1.0/12),d2=pow(2,1.0/6),d3=pow(2,0.25),d4=pow(2,1.0/3),d6=pow(2,0.5);nxt["A"]="A#";Freq["A#"]=440*d1;nxt["A#"]="B";Freq["B"]=440*d2;nxt["G"]="G#";Freq["G#"]=440/d1;nxt["F#"]="G";Freq["G"]=440/d2;nxt["F"]="F#";Freq["F#"]=440/d3;nxt["E"]="F";Freq["F"]=440/d4;nxt["D#"]="E";Freq["E"]=440/d3/d2;nxt["D"]="D#";Freq["D#"]=440/d6;nxt["C#"]="D";Freq["D"]=440/d4/d3;nxt["C"]="C#";Freq["C#"]=440/d4/d4;nxt["B"]="C";Freq["C"]=440/d6/d3;
}struct note{string p;int g;double freq(){return Freq[p]*pow(2,g-1);}bool operator!=(const note &t)const{return p!=t.p||g!=t.g;}void wr(){cout<<p<<g<<" ";}
}T[SP];
const note rest=(note){",",114514};double B[SP];int Span;void initfreq(string p="A",int group=1,int num=25){Span=num;for(int i=0;i<num;++i){T[i]=(note){p,group};p=nxt[p];if(p=="C") ++group;}
}const int U=25;double A[LEN];note S[LEN];double V[LEN];struct period{note x;int l,r;
};
vector<period> vec;int main()
{File.load("Pno-1.wav");File.printSummary();iitFreq();initfreq("G",0);const double cutoff=10,mindetectsec=0.1;int N=File.getNumSamplesPerChannel();for(int i=0;i<N;++i) A[i]=File.samples[0][i];int W=File.getSampleRate(),n=W/U,m=0;for(int i=0;i+n<N+1;i+=n,++m){for(int f=0;f<Span;++f){double w=2*pi*T[f].freq()/W,a=0,b=0;for(int j=0;j<n;++j) a+=cos(w*j)*A[i+j],b+=sin(w*j)*A[i+j];B[f]=a*a+b*b;}double* p=max_element(B,B+Span);if(p-B>=12&&*(p-12)>*p/20) p-=12;S[m]=T[p-B];V[m]=*p;if(V[m]<cutoff) S[m]=rest;}for(int i=1,l=0;i<m;++i) if(S[i]!=S[i-1]){if(i-l>mindetectsec*U) vec.push_back((period){S[i-1],l,i-1});l=i;}//freopen("data.txt","w",stdout);for(auto[x,l,r]:vec){printf("%.2lfs~%.2lfs ",l*1.0/U,r*1.0/U);x.wr();puts(""); }
}
复杂度是 \(\Theta(DN)\) 的,\(D\) 是检测音高范围(半音数),\(N\) 是采样总数。测试结果(音色为钢琴):

再往后就是写 api 根据 bpm 转成谱子了,这个更是 Dirty Work,没时间就不写了。
