端侧AI实战:Flutter离线语音识别的工程化落地

207 阅读11分钟

工地无信号?我用端侧AI实现了离线语音识别

当你的用户在地下三层的施工现场,手机信号只有一格甚至没有,却需要快速记录质检问题时,云端语音识别就成了一句空话。

从一个真实的痛点说起

作为一名移动端开发者,我负责一款工程质检类 App 的开发。这款应用的核心场景是:质检员在施工现场巡检时,需要快速记录发现的问题——钢筋间距不合规、混凝土浇筑质量问题、安全隐患等。

传统的做法是手动输入文字,但在工地环境下这几乎是一种折磨:

  • 戴着安全手套操作手机键盘,效率极低
  • 灰尘、噪音、光线等环境因素干扰
  • 质检员往往需要同时观察、拍照、记录,腾不出双手

语音输入是显而易见的解决方案。然而,当我兴冲冲地接入某云厂商的语音识别 API 后,现实给了我当头一棒:

工地没有信号。

是的,无论是高层建筑的电梯井道、地下室基坑,还是偏远郊区的新建工地,网络信号都是一个奢侈品。我们的用户反馈里,"语音功能不可用"成了高频词。

技术选型:端侧 AI 的崛起

既然云端不可靠,那就把 AI 搬到端侧。

经过调研,我将目光锁定在了 阿里达摩院的 Paraformer 模型上。这是一款专门针对中文优化的语音识别模型,具备以下特点:

特性说明
离线运行无需网络,本地推理
中文优化针对普通话深度优化,识别准确率高
模型轻量量化后约 70MB,移动端可接受
开源免费Apache 2.0 协议,商用友好

配合 sherpa-onnx 推理引擎,可以在 iOS/Android 双平台实现高性能的本地语音识别。

架构设计:不只是能用,还要好用

技术可行性验证通过后,我开始思考如何设计一个对开发者友好、对用户体验友好的组件架构。

分层架构

┌─────────────────────────────────────────────┐
│              UI 组件层                       │
│  VoiceRecordButton  │  VoiceRecordOverlay   │
├─────────────────────────────────────────────┤
│              服务管理层                      │
│         VoiceRecognizerRegistry             │
├─────────────────────────────────────────────┤
│              核心服务层                      │
│  AudioRecorderService │ ParaformerRecognizer│
├─────────────────────────────────────────────┤
│              推理引擎层                      │
│            sherpa-onnx                      │
└─────────────────────────────────────────────┘

UI 组件层:开箱即用的长按录音按钮和语音输入弹窗,类似微信的交互体验。

服务管理层:这是我后来重构加入的一层,解决了一个关键的用户体验问题——下文详述。

核心服务层:音频采集与语音识别的核心逻辑。

推理引擎层:sherpa-onnx 提供的跨平台 ONNX 推理能力。

一个被忽视的体验问题

组件的第一版很快完成了,功能测试一切正常。然而在实际使用中,我发现了一个严重的体验问题:

每次打开语音功能,都要等待 2-3 秒的初始化时间。

这是因为 ONNX 模型需要从 assets 复制到沙盒目录,然后加载到内存。对于心急的质检员来说,这几秒钟的等待足以消磨他们的耐心。

于是我设计了 VoiceRecognizerRegistry —— 一个全局的服务注册中心:

// 应用启动时,异步预加载(不阻塞启动)
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // 后台静默初始化语音识别服务
  VoiceRecognizerRegistry.instance.preInitialize();
  
  runApp(MyApp());
}

核心思想很简单:把初始化前置到应用启动时。用户从打开 App 到真正使用语音功能,通常会有几秒到几十秒的间隔,足够完成模型加载。当用户真正点击麦克风按钮时,服务已经就绪,即点即用。

class VoiceRecognizerRegistry extends ChangeNotifier {
  static VoiceRecognizerRegistry? _instance;
  static VoiceRecognizerRegistry get instance {
    _instance ??= VoiceRecognizerRegistry._();
    return _instance!;
  }
  
  // 初始化状态
  InitializationStatus _status = InitializationStatus.notStarted;
  
  // 预初始化(异步,不阻塞)
  Future<bool> preInitialize() async {
    if (_status == InitializationStatus.ready) return true;
    if (_status == InitializationStatus.initializing) {
      return _initCompleter!.future;
    }
    
    _status = InitializationStatus.initializing;
    // ... 加载模型
  }
}

这个设计带来了几个好处:

  1. 零等待体验:用户感知不到初始化过程
  2. 资源复用:全局单例,避免重复加载模型
  3. 优雅降级:如果用户很快就使用语音功能,也能正常等待完成
  4. 状态可观测:提供完整的状态查询 API

交互设计:向微信学习

在交互层面,我选择了用户最熟悉的模式——长按说话,松开识别。这种交互方式有几个优点:

  1. 符合直觉:微信已经教育了用户
  2. 明确的开始/结束信号:不需要语音唤醒词或手动点击停止
  3. 可取消:手指滑开即可取消,容错性好

配合丰富的视觉反馈:

  • 🔵 脉冲呼吸动画:表示正在监听
  • 🌊 波纹扩散效果:增强"录音中"的感知
  • 📊 实时音量指示:让用户知道声音被采集到了
  • ⏱️ 录音时长显示:还剩多少时间
VoiceRecordButton(
  maxDuration: 10,
  showRipple: true,
  showDuration: true,
  enableHaptic: true,  // 触感反馈
  onResult: (text) {
    // 识别结果
  },
)

实际效果

经过两个版本的迭代,这套离线语音识别方案已经在生产环境稳定运行。来看一些数据:

指标数值
模型加载时间~2s(首次)/ 0ms(预加载后)
识别延迟< 500ms
识别准确率~95%(安静环境)/ ~85%(工地噪音)
内存占用~150MB
包体积增加~70MB

在工地的实际测试中,即使在地下两层、完全无信号的环境下,语音识别功能依然可以正常使用。质检员的记录效率提升了约 40%。

集成踩坑实录

这部分是我花了最多时间的地方。sherpa-onnx + Paraformer 的组合虽然强大,但集成过程中遇到了不少"暗坑"。希望我的经验能帮你少走弯路。

坑1:音频格式转换——PCM 16bit 到 Float32

问题现象:录音能正常采集,但识别结果总是空的,或者输出一堆乱码。

原因分析:Paraformer 模型要求输入的音频格式是 16kHz、Float32、单声道。而 record 插件输出的是 PCM 16bit 有符号整数。如果直接把 PCM 数据喂给模型,识别结果必然是错的。

解决方案:手动做格式转换,将 Int16 归一化到 [-1.0, 1.0] 的浮点数范围:

Float32List _pcm16ToFloat32(Uint8List pcm16) {
  final length = pcm16.length ~/ 2;
  if (length == 0) return Float32List(0);
  
  // 将字节数组视为 Int16 数组
  final int16Data = Int16List.view(pcm16.buffer, 0, length);
  final float32Data = Float32List(length);
  
  for (var i = 0; i < length; i++) {
    // Int16 范围是 -32768 ~ 32767,归一化到 -1.0 ~ 1.0
    float32Data[i] = int16Data[i] / 32768.0;
  }
  
  return float32Data;
}

注意事项:除数是 32768.0 而不是 32767.0,这是因为负数的范围比正数多一个(-32768 到 32767)。用 32768 可以保证归一化后的范围对称。


坑2:模型文件加载——Assets 到沙盒的复制

问题现象:sherpa-onnx 初始化失败,报错 "file not found" 或 "invalid model"。

原因分析:Flutter 的 assets 文件不能直接通过文件路径访问,必须先通过 rootBundle.load() 读取,然后写入到应用沙盒目录。sherpa-onnx 需要的是真实的文件系统路径

解决方案

Future<String?> _prepareModelFiles() async {
  try {
    final tempDir = await getTemporaryDirectory();
    final modelDir = Directory('${tempDir.path}/paraformer_model');
    
    // 确保目录存在
    if (!await modelDir.exists()) {
      await modelDir.create(recursive: true);
    }
    
    // 复制模型文件
    await _copyAssetToFile(
      'packages/voice_recognizer/assets/audio_model/model.int8.onnx',
      '${modelDir.path}/model.int8.onnx',
    );
    
    // 复制词表文件
    await _copyAssetToFile(
      'packages/voice_recognizer/assets/audio_model/tokens.txt',
      '${modelDir.path}/tokens.txt',
    );
    
    return modelDir.path;
  } catch (e) {
    debugPrint('准备模型文件失败: $e');
    return null;
  }
}

Future<void> _copyAssetToFile(String assetPath, String destPath) async {
  final file = File(destPath);
  // 避免重复复制
  if (!await file.exists()) {
    final data = await rootBundle.load(assetPath);
    await file.writeAsBytes(data.buffer.asUint8List());
  }
}

踩坑点

  • 路径前缀要用 packages/voice_recognizer/ 而不是 assets/,因为这是一个独立的 package
  • 首次复制 70MB 的模型文件需要 1-2 秒,要做好加载状态提示
  • 建议在开发阶段清除缓存重新复制,避免旧模型干扰调试

坑3:流式识别 vs 非流式识别——模型类型的选择

问题现象:使用 OnlineRecognizer(流式)初始化失败,或者识别效果很差。

原因分析:Paraformer 模型分为两种:

  • Paraformer-large:非流式,准确率高,但必须等整段音频录完才能识别
  • Paraformer-streaming:流式,可以边录边识别,但需要专门的流式模型

我最初下载的是非流式模型,却用流式 API 去加载,自然会出问题。

解决方案:根据模型类型选择对应的 API:

// 非流式识别(推荐,准确率更高)
final config = sherpa.OfflineRecognizerConfig(
  model: sherpa.OfflineModelConfig(
    paraformer: sherpa.OfflineParaformerModelConfig(
      model: '$modelDir/model.int8.onnx',
    ),
    tokens: '$modelDir/tokens.txt',
    numThreads: 2,
    provider: 'cpu',
  ),
);
_recognizer = sherpa.OfflineRecognizer(config);

// 使用时:先录完,再一次性识别
final stream = _recognizer.createStream();
stream.acceptWaveform(sampleRate: 16000, samples: allSamples);
_recognizer.decode(stream);
final result = _recognizer.getResult(stream);

我的选择:最终采用非流式识别。虽然不能边录边出结果,但准确率明显更高。对于质检场景,用户说完一句话通常也就几秒钟,等待是可以接受的。


坑4:Tokens 文件格式——JSON vs TXT

问题现象:模型加载成功,但识别结果全是乱码或者空白。

原因分析:sherpa-onnx 要求的 tokens 文件是纯文本格式(每行一个 token),而有些模型下载下来的是 JSON 格式。

错误的格式(JSON)

{"你": 0, "好": 1, "世": 2, "界": 3, ...}

正确的格式(TXT)

你
好
世
界
...

解决方案:写个脚本转换一下,或者直接去 ModelScope/HuggingFace 下载正确格式的 tokens.txt。


坑5:iOS 真机调试——动态库签名问题

问题现象:模拟器运行正常,真机运行崩溃,报错 "code signature invalid"。

原因分析:sherpa-onnx 的 iOS 动态库需要正确签名才能在真机运行。

解决方案

  1. 在 Xcode 中选择正确的开发者证书
  2. 确保 Podfile 中配置了动态库嵌入:
post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['BUILD_LIBRARY_FOR_DISTRIBUTION'] = 'YES'
    end
  end
end
  1. Clean Build Folder 后重新编译

坑6:Android 64位兼容——armeabi-v7a 的缺失

问题现象:部分 Android 设备启动崩溃,报错 "couldn't find libsherpa-onnx-jni.so"。

原因分析:sherpa-onnx 默认只提供 arm64-v8a 的 so 库,而一些老设备是 32 位的。

解决方案:在 android/app/build.gradle 中限制 ABI:

android {
    defaultConfig {
        ndk {
            abiFilters 'arm64-v8a', 'x86_64'  // 只支持64位
        }
    }
}

或者去 sherpa-onnx 官方下载 32 位的库(如果需要兼容老设备)。


坑7:内存泄漏——Stream 对象未释放

问题现象:多次录音后,内存占用持续上涨,最终 OOM 崩溃。

原因分析:每次调用 recognizer.createStream() 都会创建新的 Stream 对象,但 Dart 的 GC 不会自动释放 native 内存。

解决方案:识别完成后主动释放资源,并复用 Recognizer 实例:

// 全局复用 Recognizer
class VoiceRecognizerRegistry {
  SimpleParaformerRecognizer? _recognizer;  // 单例复用
  
  // 每次识别创建新的 Stream,用完即弃
  // Stream 是轻量级的,Recognizer 是重量级的
}

// 必要时释放全部资源
void reset() {
  _recognizer?.dispose();
  _recognizer = null;
}

坑8:采样率不匹配——16kHz 的强制要求

问题现象:识别结果偶尔正确,偶尔完全错误,没有规律。

原因分析record 插件在不同设备上的默认采样率不同(44.1kHz、48kHz 等),而 Paraformer 只接受 16kHz。

解决方案:强制指定采样率:

await _recorder.start(
  const RecordConfig(
    encoder: AudioEncoder.pcm16bits,
    sampleRate: 16000,  // 必须是 16000
    numChannels: 1,      // 单声道
  ),
  path: '', // 流式录制,不保存文件
);

坑9:权限处理——iOS 的特殊性

问题现象:Android 正常,iOS 首次使用时直接崩溃。

原因分析:iOS 对麦克风权限非常严格,必须在 Info.plist 中声明,且需要在使用前检查权限状态。

解决方案

<!-- Info.plist -->
<key>NSMicrophoneUsageDescription</key>
<string>需要使用麦克风进行语音输入</string>
// 使用前检查权限
Future<bool> _checkPermission() async {
  var status = await Permission.microphone.status;
  if (status.isDenied) {
    status = await Permission.microphone.request();
  }
  return status.isGranted;
}

用户体验建议:不要在 App 启动时就申请权限,而是在用户首次点击麦克风按钮时再申请。这样用户更容易理解为什么需要这个权限。


坑10:调试困难——如何定位识别失败的原因

问题现象:识别结果为空,但不知道是录音问题还是模型问题。

解决方案:我加了一套完整的调试日志:

// 1. 检查音频数据
debugPrint('音频样本数: $totalLength, 时长约 ${totalLength / sampleRate} 秒');

// 2. 检查音频数据范围(应该在 -1.0 ~ 1.0 之间)
double minVal = 0, maxVal = 0;
for (var sample in allSamples) {
  if (sample < minVal) minVal = sample;
  if (sample > maxVal) maxVal = sample;
}
debugPrint('音频数据范围: min=$minVal, max=$maxVal');

// 3. 保存 WAV 文件用于人工检查
// 录下来的音频如果人耳都听不清,那模型肯定也识别不了

终极调试手段:把录制的音频保存成 WAV 文件,用电脑播放听一下。如果人耳都听不清楚,那就是录音环节的问题;如果听得清但识别不出,那就是模型或参数的问题。

写在最后

端侧 AI 正在重塑移动应用的能力边界。曾经必须依赖云端的能力——语音识别、图像识别、自然语言处理——现在都可以在用户的设备上本地运行。

对于我们这种特定场景的应用(弱网/无网环境),端侧 AI 不是"可选项",而是"必选项"。它让技术真正服务于用户,而不是让用户迁就技术的局限。

如果你也在开发类似场景的应用,希望这篇文章能给你一些启发。完整的组件代码已开源,欢迎 Star 和 PR。

pub.dev链接

技术栈:Flutter + Dart + sherpa-onnx + Paraformer-zh

写于 2026 年 1 月,一个终于不用担心"无信号"的夜晚。