杂记

希望多年以后,看到之前记录的文章,会有所感触。

0%

这里主要介绍语音识别引擎的端云协同机制。在实际手机端/车机端语音识别产品具有用户量庞大的特点,这意味着,如果所有的音频数据都调用云侧LLM进行推理,那么占用的显卡资源将是巨大的。为了给企业降低成本的同时保证用户的体验,端云协同就是不可或缺的。
端云协同包括两级,L1:端侧和云侧的路由。L2:云侧的二级路由(使用LLM或者encoder-decoder(ED)的结果,不使用LLM)。现在主流的做法L1是使用是否存在网络作为一个硬性的过滤,因为绝大多数手机用户的手机的计算资源是及其有限的,因此对于L1基本没有其他软性过滤的条件,甚至很多软件不会提供边缘端计算的能力。因此主要的降低计算量的地方在于L2这一层次的路由,在保证字准的同时,要最大化的降低LLM调用的次数。
要减少LLM调用的次数,首先就想到:什么情况下ED的结果足以支撑起用户的需求呢?顺着这个思路向下,不难理出两点:1.ED结果足够好。2.不适合LLM处理。针对第一种情况,即 ED 的识别结果已经足够可靠,此时即使进一步调用 LLM,能够获得的字准提升也十分有限,反而会额外增加推理成本和响应时延。因此,L2 路由的核心问题之一,可以进一步转化为:如何在不知道真实转写结果的情况下,提前判断当前 ED 输出是否可信。

最直接的思路是利用 ED 模型自身输出的置信度信息。例如,可以统计 token 级概率、句级平均置信度、最低 token 置信度以及候选序列之间的得分差异。当模型对当前语音具有较高把握时,其输出概率通常更加集中,最优候选与次优候选之间也会存在较明显的分数间隔,此时可以直接返回 ED 结果,而无需继续调用 LLM。相反,当多个候选序列的得分十分接近时,说明 ED 模型对当前语音存在较大的识别不确定性,此类样本更适合交由能力更强的 LLM 进一步处理。

然而,仅依赖最终解码结果的置信度并不足以完整描述一次识别过程中的不确定性。对于 CTC 或 CTC-Attention 类语音识别模型,还可以进一步利用声学模型在帧级别的输出分布构造不确定性特征。例如,CTC 每一帧都会输出各 token 的后验概率分布,当概率集中在少数几个 token 上时,说明当前声学判断相对确定;而当概率分布较为均匀时,则意味着模型对该帧存在较强的不确定性。因此,可以进一步统计 CTC mean entropy、CTC max entropy 等指标,用于描述整段语音在声学层面的识别难度。

除了概率分布本身,还可以关注识别结果在时间维度上的稳定程度。对于流式 ASR,模型会随着新音频帧不断到来持续更新 partial hypothesis。如果某一段语音的中间识别结果频繁发生修改,通常意味着模型对该段语音的判断并不稳定。因此,可以引入 partial hypothesis revision rate、longest stable prefix ratio 等指标。前者用于统计中间识别结果的修改频率,后者用于衡量已经连续保持稳定的前缀长度占比。通常而言,revision rate 越高、stable prefix ratio 越低,说明当前识别结果的不确定性越大。

类似地,还可以直接从 CTC 输出序列的变化情况中提取特征。例如,统计相邻帧之间 top1 与 top2 token 的排序交叉次数,即 top1/top2 crossing rate;或者统计非 blank token 在连续帧之间发生改变的频率,即 nonblank token flip rate。当这些指标较高时,意味着模型在多个候选 token 之间反复摇摆,可以认为当前声学证据并不充分。相比单纯使用最终句级置信度,这类动态特征能够更直接地描述模型在整个解码过程中的“犹豫程度”。

因此,对于“ED 结果是否足够好”这一问题,可以将特征大致分为三个层次:第一类是最终结果置信度特征,例如平均 token probability、最小 token probability、N-best score margin 等;第二类是CTC 帧级不确定性特征,例如 mean entropy、max entropy、top1/top2 crossing rate 和 nonblank token flip rate;第三类是流式识别稳定性特征,例如 partial hypothesis revision rate 和 longest stable prefix ratio。通过这些特征,可以从不同角度刻画当前 ED 输出的可靠程度。

第二种情况则是 当前样本本身并不适合继续交给 LLM 处理。这里的核心并不是 ED 结果一定正确,而是即使调用 LLM,也很难获得足够的收益,甚至可能导致识别结果变差。例如,对于极短语音、单字或简单命令词,由于上下文信息非常有限,LLM 能够利用的语言信息较少;对于大量数字、字母、专有名词以及无明显语义关联的内容,LLM 还可能由于自身语言先验过强而主动修改原本正确的识别结果。在严重噪声、多人重叠语音或者非语音输入等情况下,如果前端声学信息本身已经严重缺失,LLM 同样很难恢复真实内容,并可能产生额外的幻觉结果。

因此,L2 路由实际上并不是简单地判断“ED 对不对”,而应当判断:

当前样本调用 LLM 后,是否能够带来足够大的预期收益。

从这个角度来看,可以将每条语音看作一个决策样本。对于第 ii 条语音,分别获得 ED 输出结果 YEDiY_{ED}^{i} 和 LLM 输出结果 YLLMiY_{LLM}^{i},并利用真实标注 YGTiY_{GT}^{i} 计算二者的字错误率:

WEREDi=WER(YEDi,YGTi)WER_{ED}^{i} = WER\left(Y_{ED}^{i}, Y_{GT}^{i}\right)

WERLLMi=WER(YLLMi,YGTi)WER_{LLM}^{i} = WER\left(Y_{LLM}^{i}, Y_{GT}^{i}\right)

进一步定义调用 LLM 所带来的收益:

Gaini=WEREDiWERLLMiGain_i = WER_{ED}^{i} - WER_{LLM}^{i}

GainiGain_i 较大时,说明该样本调用 LLM 可以明显改善识别结果,应当路由至 LLM;当 Gaini0Gain_i \leq 0 或收益非常小时,则说明 LLM 对该样本没有明显帮助,此时直接采用 ED 结果更加合理。

这样一来,L2 路由问题就可以进一步建模为一个二分类问题或收益预测问题。模型输入可以由 ASR 解码过程中提取的多种特征组成,例如音频时长、SNR、ED confidence、CTC entropy、N-best margin、token flip rate、revision rate 和 stable prefix ratio 等;模型输出则可以是当前语音是否需要调用 LLM,或者直接预测调用 LLM 后可能获得的字准收益。

在实际工程中,仅以识别准确率作为优化目标仍然不够,因为端云协同本质上还需要同时考虑精度、计算成本和时延。假设一次 LLM 调用的平均成本为 CLLMC_{LLM},则可以将路由优化目标描述为:

minP(LLM)CLLM\min \quad P(\mathrm{LLM}) \cdot C_{LLM}

同时约束整体识别性能满足:

WERrouteWERtargetWER_{route} \leq WER_{target}

也就是说,在保证系统整体字错误率不超过预定阈值的前提下,尽可能降低 LLM 的调用比例。这实际上才是 L2 路由真正需要解决的问题:不是单纯追求最高字准,而是在基本不损失用户体验的前提下,用尽可能少的 LLM 调用获得接近全量 LLM 的识别效果。

在具体系统实现时,可以采用“硬规则过滤 + 软路由模型”的两阶段策略。首先使用一些确定性较强的规则对明显无需调用 LLM 的样本进行过滤,例如极短语音、非语音片段、特定命令词或者部分特殊格式内容;随后,将剩余样本输入一个轻量级路由模型,根据 ED 置信度、CTC 不确定性以及流式稳定性等特征预测 LLM 的潜在收益。当预测收益高于一定阈值时调用 LLM,否则直接返回 ED 结果。

其中,路由阈值可以根据实际业务需求进行动态调整。例如,当服务器 GPU 负载较低时,可以适当降低阈值,让更多边界样本进入 LLM,以获得更高的整体识别准确率;而当 GPU 负载较高或者业务处于流量高峰时,则可以提高阈值,只将最困难、最有可能从 LLM 中获益的语音送入 LLM。这样,端云协同就不再是一个固定的二分类策略,而可以进一步演化为一个结合识别难度、LLM 收益、实时负载和推理成本的动态资源调度问题。

最终,整个端云协同链路可以概括为:

用户语音 → L1 端云路由 → 云侧 ED 推理 → L2 难度/收益评估 → ED 直接输出或调用 LLM → 最终识别结果。

其中,L1 解决的是“这条语音是否需要进入云侧”,而 L2 解决的是“进入云侧之后,这条语音是否值得继续消耗 LLM 计算资源”。相比 L1,L2 具有更大的优化空间,也是整个端云协同系统降低 GPU 资源消耗、控制推理成本,同时维持识别准确率的核心环节。
下一篇文章将会详细介绍当前主流的方法,以及未来我打算采用的方法。

最近一个星期进行了情感识别的AI大赛,比赛内容是进行10分类,指标看宏F1,不允许使用带有情感数据或者任务的预训练模型。最后结果不尽人意,没有取得前三名,看到前面人的方法,更是让我大吃一惊。
先说我的思路,既然是中英文的二分类问题,我是考虑训练一个语种分类的模型进行二分类(这个正确率一般都非常高,超过99%),后续用中文和英文训练集分别训练两个不同的模型,分别在各自的语种上面达到最好的效果,窃以为非常巧妙,实则效果不如其他人,百思不得其解,也尝试了很多模型,hubert wav2vec whisper wavlm等等等,在中文上可以达到很好的效果,但是英文上怎么样也不行,验证集正F1上不去。到了后面,实在没办法,选取了4个模型进行,对于难分类的问题进行了投票,结果0.55+,现在回想起来,或许不应该把中文的数据集冶金学投票,因为中文本身的F1就很高了,失误了。最后结束,问了别人,别人直接whisper+lora微调然后全量微调,就能到0.57以上了,哎,尴尬。
总结下来有两个失误,第一个失误:显卡资源不足。第二个失误:没有事先进行规划,应该先把主流的模型都跑一遍试一下的。第三个失误:最后专家投票时,不应该修改中文数据的标签。
雄关漫道真如铁,而今迈步从头越,靡不有初,鲜克有终。希望这股对生活的热情,和对工作的激情能一直保持下去。

整体结构:PCM音频->分帧->加窗->FFT->功率谱->Mel滤波器组->对数频谱
举一个简单的c++代码示范
MelFeatureExtractor
Compute()->包含:FrameSignal() ApplyWindow() FFT() MelFilterBank() Log()

头文件介绍:mel_feature.h 只是进行函数的声明不包含具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#pragma once  // 头文件保护指令,作用是防止该头文件被同一个编译单元重复包含。

#include <vector> // 引入动态数组容器


class MelFeatureExtractor
{

public:

MelFeatureExtractor(
int sample_rate,
int frame_length,
int frame_shift,
int n_fft,
int n_mels
);


// 输入PCM,输出Mel谱
std::vector<std::vector<float>>
Compute(
const std::vector<float>& waveform
); // 返回值为std::vector<std::vector<float>> 二维数组


private:

int sample_rate_;

int frame_length_;

int frame_shift_;

int n_fft_;

int n_mels_;


std::vector<float> window_; // 私有成员,构造时传入


// Mel滤波器组
std::vector<
std::vector<float>
> mel_filterbank_;



void InitWindow(); // 生成窗函数 存入windows_


void InitMelFilterbank(); // 生成梅尔三角滤波器组并存入mel_filterbank_


};

这个头文件声明了关键的几个函数,下面进行具体函数实现的介绍。

函数的具体实现:MelFeatureExtractor.cpp 梅尔特征的提取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
#include "MelFeatureExtractor.h"
#include <cmath>
#include <algorithm>

using namespace std;

// 构造函数:初始化参数 + 预计算窗函数 + 预计算梅尔滤波器
MelFeatureExtractor::MelFeatureExtractor( // 类里面的成员函数实现
int sample_rate,
int frame_length,
int frame_shift,
int n_fft,
int n_mels
)
: sample_rate_(sample_rate),
frame_length_(frame_length),
frame_shift_(frame_shift),
n_fft_(n_fft),
n_mels_(n_mels)
{
InitWindow();
InitMelFilterbank(); //生成传函数和滤波器组
}

// 初始化汉明窗 Hamming Window
void MelFeatureExtractor::InitWindow()
{
window_.resize(frame_length_);
for (int i = 0; i < frame_length_; ++i) {
window_[i] = 0.54f - 0.46f * cosf(2.0f * (float)M_PI * i / (frame_length_ - 1));
} //窗函数代码
}

// 线性频率 → 梅尔频率
static float linear_to_mel(float f)
{
return 2595.0f * log10f(1.0f + f / 700.0f);
} // 人耳频率映射

// 梅尔频率 → 线性频率
static float mel_to_linear(float mel)
{
return 700.0f * (powf(10.0f, mel / 2595.0f) - 1.0f);
}

// 初始化梅尔三角滤波器组
void MelFeatureExtractor::InitMelFilterbank()
{
int num_bins = n_fft_ / 2 + 1;
float f_max = sample_rate_ / 2.0f;

// 梅尔轴上均匀分点
float mel_min = linear_to_mel(0.0f);
float mel_max = linear_to_mel(f_max);

// 生成 n_mels + 2 个中心点
vector<float> mel_points(n_mels_ + 2);
for (int i = 0; i < n_mels_ + 2; ++i) {
mel_points[i] = mel_min + (mel_max - mel_min) * i / (n_mels_ + 1);
}

// 转回频率
vector<float> freq_points(n_mels_ + 2);
for (int i = 0; i < n_mels_ + 2; ++i) {
freq_points[i] = mel_to_linear(mel_points[i]);
}

// 转换成 FFT bin 索引
vector<int> bin_points(n_mels_ + 2);
for (int i = 0; i < n_mels_ + 2; ++i) {
bin_points[i] = roundf(freq_points[i] * n_fft_ / sample_rate_);
}

// 构造每个三角滤波器
mel_filterbank_.resize(n_mels_, vector<float>(num_bins, 0.0f));
for (int m = 0; m < n_mels_; ++m) {
int left = bin_points[m];
int center = bin_points[m + 1];
int right = bin_points[m + 2];

// 上升沿
for (int k = left; k < center; ++k) {
mel_filterbank_[m][k] = (float)(k - left) / (center - left);
}
// 下降沿
for (int k = center; k < right; ++k) {
mel_filterbank_[m][k] = (float)(right - k) / (right - center);
}
}
}

// 最简单的复数结构用于 FFT(仅实现本代码所需)
struct Complex {
float re, im;
Complex(float r = 0, float i = 0) : re(r), im(i) {}
Complex operator+(const Complex& o) const { return Complex(re+o.re, im+o.im); }
Complex operator-(const Complex& o) const { return Complex(re-o.re, im-o.im); }
Complex operator*(const Complex& o) const { return Complex(re*o.re - im*o.im, re*o.im + im*o.re); }
};

// 极精简的 FFT 实现(仅用于演示,工程建议用 kissfft / FFTW )
static void fft(vector<Complex>& data, bool invert) {
int n = data.size();
for (int i = 1, j = 0; i < n; i++) {
int bit = n >> 1;
for (; j & bit; bit >>= 1)
j ^= bit;
j ^= bit;
if (i < j)
swap(data[i], data[j]);
}

for (int len = 2; len <= n; len <<= 1) {
float ang = 2 * M_PI / len * (invert ? -1 : 1);
Complex wlen(cos(ang), sin(ang));
for (int i = 0; i < n; i += len) {
Complex w(1);
for (int j = 0; j < len/2; j++) {
Complex u = data[i+j], v = data[i+j+len/2] * w;
data[i+j] = u + v;
data[i+j+len/2] = u - v;
w = w * wlen;
}
}
}
}

// 计算梅尔谱
vector<vector<float>> MelFeatureExtractor::Compute(const vector<float>& waveform)
{
int num_samples = waveform.size();
int num_frames = (num_samples - frame_length_) / frame_shift_ + 1;

if (num_frames <= 0)
return {};

vector<vector<float>> mel_frames(num_frames, vector<float>(n_mels_, 0.0f));
int num_bins = n_fft_ / 2 + 1;

for (int t = 0; t < num_frames; ++t) {
// 1. 取一帧
vector<float> frame(frame_length_);
int start = t * frame_shift_;
for (int i = 0; i < frame_length_; ++i)
frame[i] = waveform[start + i];

// 2. 加窗
for (int i = 0; i < frame_length_; ++i)
frame[i] *= window_[i];

// 3. 补零到 n_fft
vector<Complex> fft_in(n_fft_);
for (int i = 0; i < frame_length_; ++i)
fft_in[i] = Complex(frame[i]);

// 4. FFT
fft(fft_in, false);

// 5. 计算功率谱
vector<float> power(num_bins);
for (int i = 0; i < num_bins; ++i) {
float re = fft_in[i].re;
float im = fft_in[i].im;
power[i] = (re*re + im*im) / n_fft_;
}

// 6. 梅尔滤波 + 取 log
for (int m = 0; m < n_mels_; ++m) {
float sum = 0.0f;
for (int i = 0; i < num_bins; ++i)
sum += power[i] * mel_filterbank_[m][i];

// 对数梅尔谱(加小epsilon防 log(0))
mel_frames[t][m] = logf(sum + 1e-6f);
}
}

return mel_frames;
}

基本实现梅尔谱特征的提取,值得说明的是,在引擎工程中,特征提取并不是重点,资源的调度才是重点。

从今天开始,我将用预计30天的时间,进行语音识别引擎的系统学习与分享。适合于没有接触过语音识别引擎但是有一定的代码基础和物理数学基础的人来阅读。刚开始撰写文章的顺序会很杂乱,后续全部完成后,会调整文章的顺序,让文章更适合阅读学习。
语音识别引擎(Automatic Speech Recognition Engine,简称 ASR Engine)是一套将人类语音信号转换为文本信息的软件系统。它是智能语音交互系统的核心模块,广泛应用于智能助手、车载语音、会议转写、客服机器人、智能家居、语音输入法等场景。
这是人机交互中不可或缺的一个关键环节,当识别引擎正确识别了人说的话,后续才会进行自然语言的理解以及后面的语音合成。当然,目前也存在端到端的模型。
概括来讲,引擎主要分为:音频输入模块,前端预处理模块,特征提取模块,声学模型/深度学习模型,解码器模块,语言模型。根据其部署位置,又分为端侧(轻量)和云侧,本系列侧重于云侧。
ChatGPT-Image-2026年8月3日-22_02_05.png

在公司举行的新人培训–展翼行动中,我被分到了展翼八班。先说结果,体验感很好,刚开始几天累,但后面就开始享受这种沉浸式的“剧本杀”体验了,我们也是顺利拿到了6666元的展翼大奖。除了班主任认真负责外,我对于我们的班长也是刮目相看,我认为他进到的职责,远远超过了普通的班长。通宵达旦的给我们修改ai大赛的内容,上课积极配合,引发了我的深思,自己的时间很重要,但是真的有很多人是无私的奉献出来的。另外一个令我印象深刻的人是陈ll,他是我被分到的bs220宿舍的舍友之一。他让我想起了我大学时期的舍长李cy,一举一动都很相似(甚至于眼神都很像),他们都喜欢电子产品,都是极简主义,最关键的是,他的床位就在我们大学舍长的那个位置。可惜现在舍长不知道深处何方。

印象深刻的活动有两个,第一个是游园会,我们班拿到了冠军。第二个活动是ai大赛,我们做了ai智能晾衣助手以及追觅虚拟人交友软件,最后在初赛就被淘汰了,晋级的那些更像是小玩具(严重怀疑有黑幕)。

结束了这次展翼活动,就要正式入职干活了,加油吧!

经过了6-8月的实习,顺利拿到了oppo的offer

拒掉了比亚迪 海康 迈瑞。目前比较心仪的地点是:京东 歌尔 东源 科大讯飞 这四家,都是声学行业。华为也在池子里面泡着。主要考虑的因素是:城市,行业是否长就,公司是否稳定,以及更新换代速度。 不去oppo的主要考量在于城市,深圳距离太远,且是南方城市,过于潮湿,未来行业发展比较受限,跳槽较难,只能去部分手表企业(运动健康的算法),此时我的竞争对手是生医工的,我的声学背景无用武之地。不确定是否能持续工作10年以上。

声学领域:稳定性强,风险集中在技术替代。未来耳机是否会被眼镜所替代呢?车内音响仍然是一个稳定的增长点,工作稳定。

运动健康算法领域:爆发性强,与AI结合更加紧密,交叉创新市场更加的广阔。同时竞争对手也会更加多。

在写这篇博客的时候,我感觉这两个行业都是不错的行业,回归本质都是消费电子的领域,所以要具体对比的就是公司的待遇问题,以及离家远近的问题了。正是所谓的幸福度。我可以在一个短期内(3-5年)努力奋斗,提升专业的技能,但是聚焦到长期,依靠一笔固定的工资,难以实现自己的财富自由,学会投资是一门必修课,这也就反映了,我需要一个较为安逸的工作,让我在工作之余,有精力去研究制定自己的投资计划,或者说有能力去做一下副业。

暂时是这样,我当前的眼界还是很有限的,看不到更远的方向。短期来看,找一个较为高薪的工作,并在工作之余提升自己,给自己的未来提供一些选择的机会总还是好的。

目前基本完成了暑期实习的投递,拿到了oppo健康算法以及华为终端bg算法的offer。
腾讯二面挂,京东字节简历挂,联想终面挂。
目前决定去oppo,感觉未来发展的前景会好一些,但是oppo是在深圳,地理位置不是特别理想,不知道未来去向何方~
走一步看一步,计划先去oppo试试看。

这两天结束了为期两周的假期,来到了学校,总结一下近期任务:

1、变压器故障检测论文的完善。

2、声纹转振动的思路搭建,写模型跑结果。

3、matlab的pdm转pcm