19、语音识别基础:音频特征提取(MFCC)、语音模型加载、实时语音识别流程

说实话,语音识别这块,我最早接触的时候也觉得挺玄乎的。你对着手机说句话,它就能听懂,这背后到底干了啥?

后来我自己在Android上折腾NNAPI做语音识别,踩了不少坑。今天我就把核心的东西捋一捋——音频特征提取、模型加载、还有实时识别的完整流程。嗯,咱们一步步来。

19.1 音频特征提取:MFCC 是怎么来的?

原始音频信号,说白了就是一串随时间变化的振幅值。但神经网络不吃这个,它需要更紧凑、更有代表性的特征。MFCC(梅尔频率倒谱系数)就是最常用的特征之一。

我个人习惯把MFCC提取分成这几步:

  1. 预加重:提升高频信号,补偿语音信号中高频部分的衰减。公式很简单:y[n] = x[n] - 0.97 * x[n-1]。我在项目中试过不加这步,识别率直接掉了3-5个百分点。
  2. 分帧:把音频切成短帧,每帧20-40毫秒。帧移通常是帧长的一半。比如帧长25ms,帧移10ms。
  3. 加窗:对每帧应用汉明窗,减少频谱泄漏。汉明窗的公式:w[n] = 0.54 - 0.46 * cos(2πn/(N-1))
  4. FFT:对每帧做快速傅里叶变换,得到频谱。
  5. 梅尔滤波器组:将频谱映射到梅尔刻度上。梅尔刻度模拟了人耳的听觉特性——对低频敏感,对高频不敏感。转换公式:Mel(f) = 2595 * log10(1 + f/700)
  6. 取对数:对滤波器组输出取对数,模拟人耳对声音强度的对数感知。
  7. DCT:离散余弦变换,得到倒谱系数。通常取前13个系数作为MFCC特征。

核心要点:MFCC的维度通常是13维,但实际应用中会加上一阶差分和二阶差分,变成39维。我建议你至少保留39维,信息量更足。

在Android上,你可以用AudioRecord采集音频,然后用Java/Kotlin实现MFCC提取。不过我更推荐用C++写JNI层,性能好很多。我曾经在低端机上用纯Java做MFCC,一帧处理要花30ms,换成C++后直接降到5ms以内。

// 伪代码:MFCC提取核心步骤
public float[][] extractMFCC(short[] audioData, int sampleRate) {
    // 1. 预加重
    float[] preEmphasized = preEmphasize(audioData, 0.97f);
    // 2. 分帧
    float[][] frames = framing(preEmphasized, frameLength, frameShift);
    // 3. 加窗
    for (int i = 0; i < frames.length; i++) {
        frames[i] = applyHammingWindow(frames[i]);
    }
    // 4. FFT
    Complex[][] spectrums = fft(frames);
    // 5. 梅尔滤波器组
    float[][] melSpectrums = applyMelFilterBank(spectrums, sampleRate, numFilters);
    // 6. 取对数 + DCT
    float[][] mfcc = dct(log(melSpectrums));
    return mfcc;
}

19.2 语音模型加载:NNAPI 与 TFLite 的配合

模型加载这块,我踩过最大的坑就是——模型格式和NNAPI的兼容性。Android上最常用的是TFLite模型,但NNAPI对某些算子支持有限。

加载流程大致如下:

  1. 准备模型文件:把训练好的.tflite模型放到assets目录下。
  2. 创建解释器:用InterpreterNnApiDelegate加载模型。
  3. 配置NNAPI委托:让模型跑在NPU或DSP上,而不是CPU。
  4. 分配输入输出张量:确保输入张量的形状和MFCC特征匹配。

小技巧:加载模型前,先调用NnApiDelegate.Options.setAllowFp16(true),可以启用FP16加速。我实测过,某些机型上推理速度能提升40%。

// 加载TFLite模型并启用NNAPI委托
try {
    // 从assets加载模型
    ByteBuffer modelBuffer = loadModelFile(context, "speech_model.tflite");
    Interpreter.Options options = new Interpreter.Options();
    
    // 配置NNAPI委托
    NnApiDelegate.Options nnOptions = new NnApiDelegate.Options();
    nnOptions.setAllowFp16(true);
    nnOptions.setExecutionPreference(NnApiDelegate.Options.EXECUTION_PREFERENCE_FAST_SINGLE_ANSWER);
    
    NnApiDelegate delegate = new NnApiDelegate(nnOptions);
    options.addDelegate(delegate);
    
    // 创建解释器
    Interpreter interpreter = new Interpreter(modelBuffer, options);
    
    // 检查输入输出形状
    int[] inputShape = interpreter.getInputTensor(0).shape();
    int[] outputShape = interpreter.getOutputTensor(0).shape();
    Log.d("ModelInfo", "Input shape: " + Arrays.toString(inputShape));
    Log.d("ModelInfo", "Output shape: " + Arrays.toString(outputShape));
    
} catch (Exception e) {
    Log.e("ModelLoad", "加载模型失败: " + e.getMessage());
}

注意:不是所有TFLite算子都支持NNAPI委托。如果模型包含NNAPI不支持的算子(比如某些自定义OP),委托会回退到CPU执行。你可以用NnApiDelegate.getSupportedOperations()检查哪些算子跑在硬件上。

19.3 实时语音识别流程:从麦克风到文字

实时语音识别,说白了就是边录音边识别。这里有个关键问题——延迟。你不能等用户说完整句话再识别,那样体验太差了。

我一般用AudioRecord配合环形缓冲区(Ring Buffer)来实现。流程如下:

  1. 启动录音线程:用AudioRecord以16kHz采样率、16位PCM格式采集音频。
  2. 缓冲管理:每采集到一帧(比如320个采样点,对应20ms),就放入环形缓冲区。
  3. 特征提取线程:从缓冲区取出足够的数据(比如1秒的音频),计算MFCC特征。
  4. 推理线程:把MFCC特征送入模型,得到识别结果。
  5. 结果后处理:用CTC解码或贪心搜索,把模型输出转成文字。

这里有个细节——滑动窗口。你想想看,如果每次都用固定1秒的窗口,那用户说「你好」时,「你」字可能被切到两个窗口里。我建议用50%重叠的滑动窗口,每帧移动10ms,这样能保证语音的连续性。

// 实时语音识别核心循环
public class RealtimeRecognizer {
    private static final int SAMPLE_RATE = 16000;
    private static final int FRAME_LENGTH = 320;  // 20ms
    private static final int WINDOW_SIZE = 1600;  // 100ms的上下文
    
    private AudioRecord audioRecord;
    private RingBuffer ringBuffer;
    private Interpreter interpreter;
    
    public void startRecognition() {
        // 启动录音
        audioRecord.startRecording();
        
        // 录音线程
        new Thread(() -> {
            short[] buffer = new short[FRAME_LENGTH];
            while (isRunning) {
                int read = audioRecord.read(buffer, 0, FRAME_LENGTH);
                if (read > 0) {
                    ringBuffer.write(buffer, read);
                }
            }
        }).start();
        
        // 推理线程
        new Thread(() -> {
            while (isRunning) {
                if (ringBuffer.available() >= WINDOW_SIZE) {
                    // 取出滑动窗口数据
                    short[] windowData = ringBuffer.read(WINDOW_SIZE);
                    // 提取MFCC特征
                    float[][] mfcc = extractMFCC(windowData, SAMPLE_RATE);
                    // 执行推理
                    float[][] output = new float[1][NUM_CLASSES];
                    interpreter.run(mfcc, output);
                    // 解码结果
                    String text = decodeOutput(output);
                    // 回调通知UI
                    onResult(text);
                }
                // 控制推理频率,避免CPU过载
                Thread.sleep(10);
            }
        }).start();
    }
}

关键点:实时识别的延迟主要来自三个地方——录音缓冲、特征提取、模型推理。我建议把特征提取和模型推理放在同一个线程,减少线程切换开销。录音线程用高优先级,保证不丢数据。

19.4 知识体系总览

下面这张图,我把整个流程串起来了。你看一眼就明白各个模块怎么配合的。

实时语音识别流程总览 音频采集 AudioRecord 16kHz 16bit 环形缓冲区 滑动窗口 50%重叠 MFCC特征提取 39维 每帧10ms NNAPI模型推理 TFLite + NNAPI委托 → NPU/DSP CTC解码 / 贪心搜索 输出文本结果 模型加载 从assets加载.tflite UI更新:显示识别文字

19.5 避坑指南与性能优化

做实时语音识别,我遇到过几个特别坑的问题,分享给你:

  • 音频采样率不匹配:模型训练时用的16kHz,你录音用了44.1kHz,特征就全乱了。我曾经因为这个排查了两天,最后发现是采样率没对齐。
  • NNAPI委托初始化慢:第一次加载模型时,NNAPI委托可能需要几百毫秒初始化。我建议在应用启动时就预加载模型,别等到用户点「开始识别」才加载。
  • 内存泄漏:环形缓冲区如果处理不及时,数据会越积越多。我习惯在每次推理后清理已处理的数据,控制缓冲区大小不超过2秒。
  • 线程安全:录音线程和推理线程共享缓冲区,一定要加锁或用无锁队列。我吃过亏,不加锁的话偶尔会出现数据错乱,识别出乱码。

性能调优建议

  • MFCC提取用定点数运算代替浮点数,速度能快2-3倍
  • 模型量化到INT8,推理速度提升明显,精度损失通常小于1%
  • 如果设备支持,优先用DSP而不是NPU,DSP的功耗更低,适合长时间录音

好了,以上就是语音识别基础的全部内容。从MFCC特征提取,到模型加载,再到实时识别流程,每一步都有细节要注意。你动手试试看,把代码跑起来,遇到问题再回来翻翻这部分内容。

公众号:蓝海资料掘金营,微信deep3321