手写一个 LPC 语音 Codec:从一帧真实音频看编码到解码的全过程
关键词:LPC、线性预测、Levinson-Durbin、Yule-Walker、反射系数、全极点合成、有损压缩
适用读者:想真正"算一遍"搞清楚 LPC 怎么工作的同学 / 工程师 / 教程作者
摘要
本文不堆公式,而是取一段真实录音里的 9 个采样 ,一步一步把 LPC 语音编码器的每一个中间结果都算出来,再原样解回去。你会发现:LPC 本身是一个无损的可逆变换 ,真正产生"压缩"和"失真"的,是最后那一步对残差做 4 bit 粗量化。全文数据前后完全一致,可直接复算。
1. LPC 到底是什么(一句话版)
语音是"短时相关"的信号:现在的采样,很大程度上能用过去几个采样加权预测出来。
预测: x_hat[n] = a1·x[n-1] + a2·x[n-2] + a3·x[n-3]
残差: e[n] = x[n] - x_hat[n]
a(预测器)描述"声道/共振峰";e(残差)是预测剩下的"激励",又小又像噪声。
编码端只传 a(或其等价形式 K)+ 残差 e;解码端用 a 把 e 合成回 x。因为 e 很小,粗量化也听不出,于是"压缩"就来了。
历史注:自相关法来自 Yule(1927)/Walker(1931) 的 Yule-Walker 方程;高效解法 Levinson(1947) 递推、Durbin(1959) 应用于 AR 模型;反射系数 K 后来被 Itakura-Saito 用于语音(PARCOR/LPC)。
2. 实验素材:真实音频里的一帧
取一段 16 kHz 单声道语音 myvoice.wav,挑一帧中等响度的 9 个采样(3 个历史 + 6 个当前帧,预测阶数 P=3):
x = [0.03650, 0.03955, 0.04218, | 0.04437, 0.04623, 0.04779, 0.04901, 0.04968, 0.04971]
└── 3 历史 ──┘ └──────── 6 帧 ────────┘
为方便手算,选了幅度 ~0.04 的片段(数字小、好读;换任意其它帧算法完全一样)。
3. 编码端:从音频到两串整数
STAGE1 --- 原始音频
就是上面的 x(9 点浮点,来自 .wav 的 int16 / 32768)。
STAGE2 --- 自相关 r[k] = Σ x[n]·x[n-k]
只算 P+1 = 4 个(k=0...3):
r[0] = 0.04437²+0.04623²+...+0.04971² = 0.01373 (能量)
r[1] = 0.04437·0.04623 + ... + 0.04968·0.04971 = 0.01338
r[2] = ... = 0.01290
r[3] = ... = 0.01231
r[0] 加 0.2% 噪声地板: 0.01373 × 1.002 = 0.01376 (防除零)
归一化看相关度:r[1]/r[0]=0.97, r[2]/r[0]=0.94, r[3]/r[0]=0.89 ------ 相邻采样 97% 相关,冗余很大,这正是 LPC 能压缩的前提。
STAGE3 --- Levinson-Durbin 递推 → K 和 a
逐阶手算(E 初值 = r0,每阶乘 (1-k²)):
i=0: acc=r[1]=0.01338, k=0.01338/0.01376=0.9500 → K[0]=0.95(夹)
E=0.01376·(1-0.95²)=0.00134
i=1: acc=r[2]-a[0]·r[1]=0.01290-0.95·0.01338=0.00019
k=0.00019/0.00134=0.1429 → K[1]=0.1429
i=2: acc=r[3]-a[0]·r[2]-a[1]·r[1]=-0.00011
k=-0.00011/0.00131=-0.0809 → K[2]=-0.0809
最终: K = [0.95, 0.1429, -0.0809] 反射系数
a = [0.8258, 0.2087, -0.0809] 预测器(直接型)
K[0]=0.95 表示"只用前 1 个采样就能抓 95% 的相关性"。|K|<1 保证滤波器稳定。
STAGE4 --- 量化 K(准备传输)
K 在 -1,1,映射到 10 bit 整数 0...1023:码 = round((K+1)/2 × 1023)
K[0]=0.95 → 码 997 → 反量化 0.9492 → 夹到 ±0.90 → 0.90
K[1]=0.1429→ 码 585 → 反量化 0.1437 → 0.1437
K[2]=-0.0809→码 470 → 反量化 -0.0811 → -0.0811
Kq = [0.90, 0.1437, -0.0811] ← 真正传输的反射系数
STAGE5 --- Kq → a_dec(阶升 step-up)
解码端也会做同样一步,所以编码端也用同一份,保证两端预测器一致:
a_dec = refl_to_lpc(Kq) = [0.7823, 0.2062, -0.0811]
(注意 a_dec ≠ raw a,因为 K0 被夹 0.95→0.90。)
STAGE6 --- 用 a_dec 算残差
e_dec[n] = x[n] - (0.7823·x[n-1] + 0.2062·x[n-2] - 0.0811·x[n-3])
e_dec = [0.00618, 0.00603, 0.00589, 0.00569, 0.00524, 0.00461]
对比信号幅度 ~0.047,残差只剩 ~0.006。信号 RMS 0.04784 → 残差 RMS 0.00563,能量压到 1/72(≈18.6 dB) 。
(注:若用未量化的 raw a,残差 RMS 仅 0.00352,能量压到 1/185 ≈ 22.7 dB;K 量化让增益略降,代价极小。)
STAGE7 --- 帧增益 rms + 残差 4 bit 量化
帧 rms = 0.00563
残差/rms 的 4bit 码 = [10, 10, 9, 9, 9, 9]
重建 rq = [0.00751, 0.00751, 0.00451, 0.00451, 0.00451, 0.00451]
残差码全是 9~10(4 bit 范围 0~15,中点 7.5),说明残差小、靠近 0,4 bit 足够。
STAGE8 --- 打包
Kq: 3 × 10 bit = 30 bit
残差: 6 × 4 bit = 24 bit
合计 = 54 bit = 6.75 字节 (原 PCM: 6 采样 × 16 bit = 96 bit)
4. 传输(线路上只跑这两串整数)
Kq码流: [997, 585, 470]
残差码流: [10, 10, 9, 9, 9, 9]
5. 解码端:从两串整数反算回音频
STAGE9 --- 读码恢复参数(反算起点)
读 Kq码 → dq → 夹 → a_dec = [0.7823, 0.2062, -0.0811] (与编码端完全相同 ✓)
读残差码 → rq = [0.00751, 0.00751, 0.00451, 0.00451, 0.00451, 0.00451]
STAGE10 --- 残差反量化
就是 STAGE7 的逆:rq = 码/15 × 8 - 4,再 × rms。
STAGE11 --- 全极点合成(反算核心)
LPC 的逆运算(用合成出来的过去做反馈):
s[n] = rq[n] + 0.7823·s[n-1] + 0.2062·s[n-2] - 0.0811·s[n-3]
历史用移位寄存器(首帧用那 3 个历史采样)。逐步算出:
s = [0.04570, 0.04875, 0.04865, 0.04891, 0.04885, 0.04886]
(与原始 x[3..8] 只差一点点,见下节误差分析。)
STAGE12 --- 转 PCM
PCM int16 = round(s × 32767), 限幅 [-1, 1]
还原 PCM = [1497, 1597, 1594, 1603, 1601, 1601]
6. 结果:误差与"哪一步有损"
原始 PCM = [1454, 1515, 1566, 1606, 1628, 1629]
还原 PCM = [1497, 1597, 1594, 1603, 1601, 1601]
还原误差 RMS = 0.001308 (原信号 RMS = 0.04784 → 相对误差 2.7%)
逐 STAGE 定性:
| STAGE | 操作 | 有损? |
|---|---|---|
| 1--3 | x→r→K,a(浮点运算) | 无损 |
| 4 | K→10bit(量化+夹) | 有量化,但两端一致→不添重建误差 |
| 5--6 | Kq→a_dec、算 e_dec | 无损 |
| 7 | 残差→4bit(量化) | 有损 ← 误差 0.001308 全来自这里 |
| 8 | 打包 | 无损 |
| 9--11 | 读码、合成 | 无损(逆运算) |
| 12 | float→int16 | 末端格式量化(≈0.00003,可忽略) |
结论:只有 STAGE7(残差 4 bit 量化)是真正有损的步骤。 证明:把 STAGE7 的 rq 换成理想残差 e_dec(不量化),STAGE11 还原误差 = 0------LPC 分析/合成严格互逆。
7. 为什么能压缩?为什么有损?和 MP3/Opus 什么关系
- 能压缩 :LPC 去掉了短时相关性,把能量压到 1/72(本帧)。剩下的残差幅度只有原信号的 1/8.5、且像噪声,于是对它做粗量化 (4 bit)代价很小。原 16 bit/采样 → 本帧 9.0 bit/采样(小帧开销占比大);真实 codec 用 frame=160、P=8 时摊薄头开销,可到 4.6 bit/采样(≈3.5× 压缩),全文件相关度 0.9855,听感正常。
- 有损:压缩来自"残差少 bit",而少 bit = 量化 = 失真。有损是为换压缩比主动付出的,不是算法缺陷。
- 和工业 codec 的关系:MP3/AAC/Opus(SILK) 同一思路------去冗余(LPC 或 MDCT)+ 对"小且像噪声"的残差/系数做感知量化。区别只在:它们阶数更高、量化更精细、还加了心理声学模型。本文的 mini-codec 就是它的"最小可运行内核"。
8. 结论
用真实音频的一帧,我们完整走通了:
编码: x ─自相关→ K ─量化→ Kq码[997,585,470] + 残差─量化→ [10,10,9,9,9,9]
解码: Kq码→a_dec, 残差码→rq ─全极点合成→ s ─×32767→ PCM
- LPC 变换本身无损可逆;
- 压缩与失真100% 来自残差 4 bit 量化(STAGE7);
- 整条链数据前后完全对上,可逐数复算。
附录 A:本帧完整数据流(一览)
x = [0.03650, 0.03955, 0.04218, 0.04437, 0.04623, 0.04779, 0.04901, 0.04968, 0.04971]
r = [0.01376, 0.01338, 0.01290, 0.01231]
K = [0.95, 0.1429, -0.0809]
Kq码 = [997, 585, 470] → Kq = [0.90, 0.1437, -0.0811]
a_dec = [0.7823, 0.2062, -0.0811]
e_dec = [0.00618, 0.00603, 0.00589, 0.00569, 0.00524, 0.00461]
rms = 0.00563
残差4bit = [10, 10, 9, 9, 9, 9] → rq = [0.00751, 0.00751, 0.00451, 0.00451, 0.00451, 0.00451]
传输 = [997,585,470] + [10,10,9,9,9,9]
还原 s = [0.04570, 0.04875, 0.04865, 0.04891, 0.04885, 0.04886]
还原 PCM = [1497, 1597, 1594, 1603, 1601, 1601]
误差 RMS = 0.001308
附录 B:核心代码片段(Python / numpy)
python
def levinson(r, P):
a = np.zeros(P); E = r[0] + 1e-9; K = np.zeros(P)
for i in range(P):
acc = r[i+1] - sum(a[j]*r[i-j] for j in range(i))
k = np.clip(acc/E, -0.95, 0.95); K[i] = k
for j in range(i//2):
t = a[j]; a[j] = t - k*a[i-1-j]; a[i-1-j] = a[i-1-j] - k*t
if i & 1:
m = i//2; a[m] = a[m] - k*a[m]
a[i] = k; E *= (1 - k*k)
return a, E, K
def refl_to_lpc(K): # K -> 直接型预测器 a
P = len(K); a = np.zeros(P+1); a[0] = 1.0
for m in range(1, P+1):
km = K[m-1]; new = a.copy()
for i in range(1, m): new[i] = a[i] - km*a[m-i]
new[m] = km; a = new
return a[1:]
# 编码(单帧): K 量化 + 残差 4bit
Kq = np.clip(dq_lpc(q_lpc(K)), -0.90, 0.90); a_dec = refl_to_lpc(Kq)
e = xw[P:] - [sum(a_dec[k]*xw[P+n-1-k] for k in range(P)) for n in range(frame)]
rq = dq_res(q_res(e/np.sqrt(np.mean(e*e)), 4), 4) * np.sqrt(np.mean(e*e))
# 解码(单帧): 读码 -> 合成
a_dec = refl_to_lpc(np.clip(dq_lpc(qK), -0.90, 0.90))
sbuf = list(history)
for n in range(frame):
s = rq[n] + sum(a_dec[k]*sbuf[P-1-k] for k in range(P))
sbuf.append(s); sbuf.pop(0)
全文数据由 myvoice.wav(16 kHz 单声道)在 P=3、frame=6 的真实帧(start=4377)逐数复算得到。