工地无信号?我用端侧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;
// ... 加载模型
}
}
这个设计带来了几个好处:
- 零等待体验:用户感知不到初始化过程
- 资源复用:全局单例,避免重复加载模型
- 优雅降级:如果用户很快就使用语音功能,也能正常等待完成
- 状态可观测:提供完整的状态查询 API
交互设计:向微信学习
在交互层面,我选择了用户最熟悉的模式——长按说话,松开识别。这种交互方式有几个优点:
- 符合直觉:微信已经教育了用户
- 明确的开始/结束信号:不需要语音唤醒词或手动点击停止
- 可取消:手指滑开即可取消,容错性好
配合丰富的视觉反馈:
- 🔵 脉冲呼吸动画:表示正在监听
- 🌊 波纹扩散效果:增强"录音中"的感知
- 📊 实时音量指示:让用户知道声音被采集到了
- ⏱️ 录音时长显示:还剩多少时间
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 动态库需要正确签名才能在真机运行。
解决方案:
- 在 Xcode 中选择正确的开发者证书
- 确保
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
- 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 月,一个终于不用担心"无信号"的夜晚。