简介:这是一套基于MFCC(梅尔频率倒谱系数)与DTW(动态时间规整)算法的语音识别定点数C代码版本,适合在嵌入式、DSP或FPGA等缺乏浮点运算单元的平台上进行算法移植与性能优化。代码完整覆盖预处理、FFT、MFCC特征提取、DTW模板匹配等关键环节,所有核心计算均采用定点数实现,便于在低成本处理器上保持实时性与低功耗。压缩包共103个文件,主要类型为C源文件、头文件、CMake/Make工程文件以及JSON配置文件,整体仅1018KB,结构清晰,可快速定位到特征提取、距离计算与识别决策等模块。其中还包括可执行示例与编译辅助文件,方便对照验证。目前已有259人学习。对于想深入理解语音识别原理或需要参考工程代码的工程师和学生,能大幅降低搭建开发环境与调试算法的时间成本,是一份轻量而完整的入门与二次开发素材。
1. 定点数语音识别选型:为什么MFCC+DTW比神经网络更现实
要在128KB内存、72MHz主频且没有浮点单元的单片机上做一份语音识别算法设计,跑10个词的离线命令识别。神经网络方案在PC上训练得好好的,交叉编译后内存直接爆掉;换MFCC+DTW定点数C代码版本,每帧特征计算只有几百次定点乘加,模板匹配是几十次距离比较,跑起来余量充足。
这个选型反直觉但现实:不用训练、代码量小、原理完全透明,出问题可以一步步断点定位。这份方案把预处理、MFCC定点化、DTW匹配、验收逐层拆开,做毕设可以直接按章节组织文档,做产品原型也能裁剪移植,适合需要离线语音识别算法的同学和工程师。
2. 总体架构设计:采样预处理到判决的定点化模块链路
上一章确定了MFCC+DTW路线,这一章把它拆成可实现的模块链路。整体结构按数据处理方向分成五段:采集与端点检测、预加重分帧加窗、MFCC特征提取、DTW模板匹配、判决输出。这样拆的直接理由是每段都能独立测试,出问题不用把整条链路当黑匣子猜。
2.1 五段链路与工程组织
第一段负责从麦克风拿到PCM数据并判断有效语音起止;第二段把连续语音切成固定长度帧;第三段把每帧语音压成13维MFCC特征向量;第四段在特征序列层面做模板匹配;第五段根据距离和阈值输出词表序号或拒识。前两段工作在时域,后两段工作在特征域,第三段是桥梁。
参数上采用8kHz采样率、20ms帧长,对应每帧160个采样点,帧移80点即10ms。8kHz覆盖语音通信0~4kHz通带,20ms窗内语音近似平稳,10ms帧移保证帧间信息不丢。如果要移植到16kHz,帧长改成320点,Mel滤波器组的频点必须同步重算,这是移植时最容易被漏掉的一步,漏掉后识别率直接掉一半。
工程目录按算法层和平台层分开,后续换平台只改采集和驱动,算法代码不动:
src/ ├── qmath.h/c # Q格式定点数学:乘法饱和、除法、查表对数 ├── preproc.h/c # 预加重、分帧、加窗 ├── fft16.h/c # 16bit定点基2 FFT ├── melbank.h/c # Mel滤波器组系数表与能量累加 ├── mfcc.h/c # 对数、DCT、特征输出 ├── dtw.h/c # 距离计算、DP匹配、模板结构体 ├── tpl_data.c # 模板库二进制数据 └── main.c # 采集回调与控制流程模块边界规则:qmath不依赖其他模块,preproc和fft只调用qmath,melbank和mfcc依赖fft,dtw只接收mfcc特征数组。这样分层的好处是评测时可以逐段插入浮点对照函数,定点误差能定位到具体层,答辩被问到“误差来自哪一层”时可以直接回答,不需要含糊。
2.2 预加重与分帧加窗的定点实现
预加重公式是y[n]=x[n]-αx[n-1],α取0.97。语音高频段每倍频程衰减约6dB,预加重把高频抬平,让后续FFT拿到更均衡的频谱。定点实现时α不存浮点,转成Q14整型。
/* Q14 格式的 0.97,由 (int)(0.97 * 16384) 计算得到 */ #define PREEMPH_ALPHA_Q14 15892 static int16_t preemph_sample(int16_t cur, int16_t prev) { int32_t prod = ((int32_t)prev * PREEMPH_ALPHA_Q14) >> 14; int32_t out = (int32_t)cur - prod; if (out > 32767) out = 32767; if (out < -32768) out = -32768; return (int16_t)out; }为什么选Q14而不是Q15?0.97用Q15是31772,乘完右移15位,和Q14右移14位相比只差一位精度,但Q14给中间乘积多留了一位整数位,后续加窗再做一次右移时,饱和概率更小。这个函数在采集回调里随到随算,不需要先缓存整帧,省一份RAM。
加窗用汉明窗,公式w[n]=0.54-0.46cos(2πn/(N-1)),N=160。窗系数在PC端按Q15量化后放到const数组,运行时每个样本乘窗系数,乘完立即右移15位:
frame[i] = (int16_t)(((int32_t)preemph_out * hamming_q15[i]) >> 15);量级上,预加重输出实测约±4000,窗系数最大值0.54对应Q15约17695,相乘右移后结果约±3800,离32767上限很远。若输入PCM接近满量程且连续样本差分后叠加,preemph_sample里的饱和分支会兜底。日志里如果频繁看到这个饱和分支触发,就要回头查ADC增益配置。
2.3 基2 FFT蝶形的定点化
帧长160不是2的幂,处理方式是补零到256点做基2 FFT。256点代码短、旋转因子表固定,补零只是略微平滑谱线,对MFCC影响很小。若强行实现160点DFT,代码复杂度和调试成本在嵌入式上都没有性价比。
蝶形运算核心代码,旋转因子按Q15存表:
/* cos_tab[k], sin_tab[k]: Q15 旋转因子,长度 N/2 */ void fft_butterfly(int16_t *re, int16_t *im, const int16_t *cos_tab, const int16_t *sin_tab, int len, int step) { for (int k = 0; k < len; k++) { int c = cos_tab[k * step]; int s = sin_tab[k * step]; int32_t tr = ((int32_t)re[len + k] * c - (int32_t)im[len + k] * s) >> 15; int32_t ti = ((int32_t)re[len + k] * s + (int32_t)im[len + k] * c) >> 15; re[len + k] = (int16_t)(re[k] - tr); im[len + k] = (int16_t)(im[k] - ti); re[k] = (int16_t)(re[k] + tr); im[k] = (int16_t)(im[k] + ti); } }关键点是先乘旋转因子并立即右移压回Q15,再做加减。如果先加减再右移,中间结果范围到±65534,int16装不下。上面代码里加减处的int16赋值在极端情况下仍可能溢出,完整实现里每个加减小节都要包一层饱和clamp宏,不然高位溢出会直接毁掉相邻两个bin。全套FFT还需要位反序和三级循环控制,256点蝶形运算在这颗72MHz主频上实测约2.8ms,远小于10ms帧间隔,实时性没问题。
3. MFCC特征提取定点化:Mel滤波器组与DCT的Q格式处理
FFT输出的是线性频谱,直接用有两个问题:一是频率分布不符合人耳听觉特性,二是129个bin维度太高,模板数据会膨胀。MFCC用Mel刻度压缩频率维度,再做DCT去相关,得到13维特征,是DTW最常用的前端。
3.1 Mel滤波器组设计与系数预计算
8kHz采样率下,0~4kHz映射到Mel轴取24个三角滤波器是常用配置。Mel频率换算公式mel=2595*log10(1+f/700),在Mel轴上均匀取26个点,两两之间构成一个三角滤波器,共24个。每个滤波器覆盖若干FFT bin,加权系数随频点线性变化。
运行时不计算浮点,因此在PC端把整张加权表一次算好,量化成Q12存储。生成脚本大致这样:
import numpy as np MEL_N = 24 N_FFT = 256 SR = 8000 mel_max = 2595.0 * np.log10(1.0 + SR / 2 / 700.0) mel_bins = np.linspace(0, mel_max, MEL_N + 2) hz = 700.0 * (10 ** (mel_bins / 2595.0) - 1) bins = np.floor((N_FFT + 1) * hz / SR).astype(int) for m in range(1, MEL_N + 1): left, center, right = bins[m-1], bins[m], bins[m+1] for k in range(left, right + 1): if k < center: w = (k - left) / (center - left) else: w = (right - k) / (right - center) print(f"{{{k}, {(int)(w * 4096)}}},")bin索引用(N_FFT+1)*hz/SR计算,对应FFT bin中心频率间隔。权重归一化到1而不是精确做滤波器面积归一化,是因为Mel能量在后续只做相对比较,DTW距离也只看相对大小,省一次定点除法很划算。Q12量化后权重最大4096,24个通道乘加最大累加值约13个bin乘4096乘32767,结果约1.74e9,接近但不超int32正上限,所以不用Q8。若Mel通道加到40个,要把Q12降成Q11或分两段累加。
3.2 对数运算与DCT的定点实现
Mel能量动态范围很大,直接做DCT数值不稳定,要先取对数。定点下常用查表加线性插值:把能量上限定到2^24,按指数分成16段,段内用Q8表插值,输出映射到0~255。只需要一个移位就能定位段索引,不需要除法。
DCT公式C(k)=sum(E[n]cos(pik*(2n+1)/(2M))),M=24,取前13维。余弦系数表也在PC端按k和n排成二维表,Q15存储。定点代码:
/* cos_tab_q15: MFCC_DIM x MEL_N 的余弦系数表 */ void mfcc_dct(const int16_t *log_e, int16_t *mfcc_out, const int16_t *cos_tab_q15) { for (int k = 0; k < MFCC_DIM; k++) { /* MFCC_DIM = 13 */ int32_t acc = 0; const int16_t *row = &cos_tab_q15[k * MEL_N]; for (int n = 0; n < MEL_N; n++) { acc += (int32_t)log_e[n] * row[n]; } /* log_e 是 Q8,cos 表是 Q15,乘积累加后右移 13 位输出 Q10 */ mfcc_out[k] = (int16_t)(acc >> 13); } }acc的中间量级:log_e最大255,cos表最大32767,24维乘积累加约2e8,不会超int32。右移13位是因为Q8乘Q15等于Q23,输出Q10需要右移13。如果输出定Q12就把右移改成11。log_e如果饱和成固定值255,DCT输出会失真,所以查表前要确认能量缩放比例合适,这是联调时最常调的系数。第0维C0是均值分量,对DTW距离贡献最大,通常在后端做倒谱均值归一化时减去,也可以在特征生成时直接丢弃,只要模板库的导出脚本保持一致。
3.3 各阶段Q格式与动态范围汇总
| 阶段 | Q格式 | 常见取值范围 | 溢出余量 |
|---|---|---|---|
| 输入PCM | Q0 | ±32767 | 无 |
| 预加重输出 | Q0 | ±20000 | 饱和兜底 |
| 窗乘结果 | Q0 | ±20000 | 窗系数衰减 |
| FFT旋转因子 | Q15 | ±0.9999 | 无 |
| 蝶形加减 | Q15 | ±32767 | 各级右移 |
| Mel加权系数 | Q12 | 0~4096 | 1位余量 |
| 通道对数 | Q8 | 0~255 | 截断 |
| MFCC输出 | Q10 | ±1500 | 5倍余量 |
表里最大风险点在FFT蝶形加减,不饱和直接截断会引入固定偏差,后级Mel累加会把它叠进去。所以每级蝶形后做一次饱和,虽然多几十条分支指令,但整个识别过程不到300ms,对实时性无感,换来的是特征稳定。
选Q格式的血泪经验:不要迷信公式里的理论动态范围,直接拿20条真实语音跑一遍定点版本,把每阶段最大值打印出来再回头调格式。我第一次做时把Mel系数放到Q8,中频段能量一高就饱和,第3维以后的MFCC全部趋同,DTW距离区分不开,识别结果就是一团浆糊。
4. DTW模板匹配实现:距离度量与路径约束整型化
MFCC把语音变成特征序列,但同一人同一词两次发音的时长、节奏不可能完全一致。DTW在时间轴上寻找代价最小的对齐路径,是经典但有效的匹配方式。定点化这里的关键是控制距离量级,防止累计矩阵溢出。
4.1 距离度量选择与整型化实现
特征距离有L1和L2两种常用度量。浮点上L2更常见,但定点下L2的平方量级会翻倍:Q10特征差值4000,平方就是1600万,累计40帧接近6.4亿,离int32上限只剩3倍余量,边缘情况很容易翻车。所以我用L1绝对值距离,差值最大约8000,累计40帧才32万,余量极大,而且实测和L2的识别率差距不到2个百分点。
/* 每帧13维特征差的绝对值总和 */ static int32_t feat_dist(const int16_t *a, const int16_t *b, int dim) { int32_t sum = 0; for (int d = 0; d < dim; d++) { int32_t diff = (int32_t)a[d] - b[d]; if (diff < 0) diff = -diff; sum += diff; } return sum; }差值量级由Q10决定,当前13维不需要再除以维度数。如果之后把特征扩到26维加差分,必须把距离除以2再累计,或者把维度数存在模板头里做动态归一化,否则模板一变长距离上限又要逼近int32边界。
4.2 累计距离DP与路径窗口约束
标准DTW递推关系是D[i][j]=dist(i,j)+min(D[i-1][j], D[i-1][j-1], D[i][j-1])。测试帧M、模板帧N,最坏情况矩阵M*N个int32,30帧对40帧就是4.8KB,可以接受,但词表一多就要优化。两个方向:滚动两行数组省内存,加Sakoe-Chiba窗口限制搜索范围。
#define DTW_WIN_RATIO 15 /* 窗口比例,单侧宽度 = 帧数 * 15% / 2 */ int32_t dtw_match(const int16_t *feat_test, int M, const int16_t *feat_tpl, int N) { int w = (M * DTW_WIN_RATIO / 100) / 2; if (w < 2) w = 2; int32_t prev[256] = {0}, cur[256] = {0}; for (int i = 1; i <= M; i++) { int jlo = (i - w > 1) ? i - w : 1; int jhi = (i + w < N) ? i + w : N; for (int j = jlo; j <= jhi; j++) { int32_t c = feat_dist(&feat_test[(i-1)*MFCC_DIM], &feat_tpl[(j-1)*MFCC_DIM], MFCC_DIM); int32_t best = prev[j]; /* 上:D[i-1][j] */ if (prev[j-1] < best) best = prev[j-1]; /* 左上:D[i-1][j-1] */ if (cur[j-1] < best) best = cur[j-1]; /* 左:D[i][j-1] */ cur[j] = c + best; } int32_t *tmp = prev; prev = cur; cur = tmp; for (int j = jlo; j <= jhi; j++) cur[j] = 0; } return prev[N]; }窗口内jlo到jhi之外的值不更新,交换数组后直接置零,避免脏数据影响下一轮。窗口比例15%是实验下来比较稳的值,太窄只能对齐局部,太宽退化成全矩阵,时间和错误率都上升。加窗口后计算量从O(MN)降到约O(M*(2w+1)),M=40时省一半以上。
注意:滚动数组边界条件里,prev[0]每一轮都必须保持0,否则第一列路径会拿到上一轮残留数据。这个坑我至少踩过两次,表现特征是识别结果对模板顺序敏感,换一个模板顺序准确率就波动。
4.3 模板库构建与判决逻辑
模板库不是直接存录音PCM,而是离线跑一遍MFCC,把特征序列和元信息存成二进制数组。每个词录3遍,先用DTW两两对齐,再做逐帧平均得到合成模板。直接平均没有意义,因为3遍录音帧数不一定相同,必须对齐到中间帧数。
模板结构体设计成变长数组:
typedef struct { uint8_t word_id; /* 词表序号 0-9 */ uint8_t frame_cnt; /* 模板帧数 */ uint8_t feat_dim; /* 当前维度,固定13 */ int16_t feat_buf[]; /* 变长特征帧,frame_cnt * feat_dim 个 */ } dtw_template_t;判决逻辑:测试序列和全部模板跑DTW,取最小距离dist_min。若dist_min除以帧数小于阈值,判为该词,否则拒识。阈值不能定死,同一人两次发音的单帧平均距离波动能到30%,所以阈值在训练集上统计均值加1.5倍标准差。拒识这一环非常重要,答辩必问“非词表语音怎么避免乱识别”,答法是统计非词表语音的dist_min分布,把阈值定为词表语音均值的两倍,再叠加能量门限,静音帧不参与匹配。
模板采集前必须先做端点检测,把静音段截掉再提特征。否则模板前后多出十几帧静音,DTW路径全部耗在静音对齐上,词本身的区分信息反而被稀释。端点检测用短时能量阈值就够,连续80ms低能量视为静音,代码很短,但漏掉它整套方案会前后端都对不上。
5. 定点实现常见问题排查:溢出、Q格式错选与特征失真
这一章把调试中概率最高的四个问题列出来,每个都按现象、原因、解决的顺序写,真遇到时可以直接对照,不用把整条链路从头翻一遍。
5.1 现象:Mel通道能量全为0,特征序列是一条横线
现象:串口打印mfcc_out,前两维正常,后面全是同一个负数,mel_e[0..23]打印出来全是0。
原因:Mel滤波器表的FFT bin索引越界。最典型的生成错误是采样率参数不匹配:脚本按16kHz算bin索引,代码实际跑8kHz,bin索引普遍大于129,运行时读表越界或全空,能量全部归零。
解决:第一步打印mel_e前几帧确认全零;第二步把melbank表拉到PC端画成柱状图,看24个滤波器是否覆盖0~4kHz;第三步核对生成脚本里的SR和N_FFT宏是否与C代码一致。如果只有部分通道为0,查三角滤波器左右边界是否有重叠空隙。这类问题半小时内能定位,别去动DTW。
5.2 现象:DTW距离为负,识别结果总是命中最后一个模板
现象:跑完所有模板,dist_min是负数,判断时所有模板都比它大,判决永远落到词表最后一个条目。
原因:累计距离int32溢出。常见诱因是MFCC高维特征饱和到±30000而不是正常的±1500,每帧距离从几千涨到几十万,几十帧累计直接爆掉。
解决:先打印一帧特征的min和max,确认量级是否回到正常区间;再在dtw_match入口加一个单帧距离统计,如果超过上限一半就先把距离除以维度数再累计。根治还是要回到Mel滤波器和DCT的饱和处理,把Q格式和缩放系数调对。距离为负是最容易定位的现象,特征值一打印就清楚。
5.3 现象:浮点版识别率95%,定点版只剩60%
现象:同一批测试样本,浮点基准跑95%,定点版跑60%,错误均匀分布在所有词上,不是某个词的问题。
原因:各阶段Q格式不统一。最典型的是DCT输出标成Q10,DTW里按Q12读,差4倍;或者Mel对数表按Q8,余弦表却按Q12生成,两个表错位导致高维特征失真。
解决:给每个模块出口加一个范围检查宏,跑10条语音,把每维特征均值和方差dump出来与浮点基准逐维对比,哪维偏差超过5%就查那一段的Q格式换算。这个方法每次都应该优先用,比盲目怀疑DTW快得多。Q格式错位是定点实现里最隐蔽的坑,也是评审最常问的点,能讲清楚这一层,说明整个链路都吃透了。
5.4 现象:训练集识别率99%,换到实测场景只有50%
现象:实验室安静环境下的模板和测试都能对上,换到走廊、教室,识别率立刻崩到一半以下。
原因:模板过拟合了录音环境。端点检测把环境底噪当语音开头,特征序列前后多出十几帧静音,DTW路径大部分消耗在静音对齐上,词本身的区分信息没有参与比较。
解决:模板库做两遍采集,一遍安静一遍带噪声;端点检测阈值按环境噪声自适应,取前10帧噪声能量均值的2倍作为起点判定门限;预加重系数保留0.97压掉大部分低频底噪。这三件事做完,实测场景能回到85%左右。这个问题比前三个更考验工程经验,答辩时主动讲出来,比被问住要主动得多。
这几个问题处理完,剩下的多是参数边界,按特征打印来定位远比盲调参数快。每个问题修复后要回归一遍完整测试集,别只跑触发问题的那一条样本。
6. 验收与答辩:识别率指标与浮定点误差分析
6.1 测试集设计与验收指标
| 阶段 | 词数 | 人数 | 每词遍数 | 样本数 | 要求 |
|---|---|---|---|---|---|
| 安静环境识别率 | 10 | 5 | 5 | 250 | ≥95% |
| 中等噪声识别率 | 10 | 5 | 5 | 250 | ≥85% |
| 非词表拒识率 | 20条非词表语音 | 5 | 各1遍 | 100 | 误触发≤10% |
测试集与模板采集必须分开,避免“用训练集当测试集”的虚假高分。除了整体识别率,还要生成混淆矩阵,看哪些词互相混。尾音相似的命令词最容易互混,这块数据是答辩时最能体现测试深度的材料。
6.2 浮定点对照验证
浮定点对照的验收标准:MFCC特征平均绝对误差小于Q10一个LSB,DTW最终距离相对误差小于5%。做法是在main.c里加一个编译开关FLOAT_REF,同一份PCM分别跑浮点特征和定点特征,每条样本记录距离对,最后统计相对误差。注意统计时把C0均值维去掉再算,C0对距离贡献大,容易掩盖高维误差。这个调试开关建议从第一天就写进工程,等验收时再补会牵扯整条调用链,非常麻烦。
6.3 答辩演示顺序
演示顺序建议:先讲五段链路图,30秒;现场跑3个词,展示串口打印的DTW距离和识别结果,90秒;播放一段带噪录音,说明端点检测和阈值的作用,60秒;最后展示浮定点误差表,60秒。全程控制在6分钟内。
现场最容易翻车的是麦克风增益和说话距离,我遇到过AGC把增益压下来导致识别立刻失灵的情况。从那以后我每次做语音方案演示都强制走一遍“先录后识别,再进实时流程”,固定这个习惯后现场稳了很多,也建议你提前准备一条后备录音文件,断点时直接放文件跑,不至于卡壳。希望帮到你。
本文还有配套的精品资源,点击获取