从零开发一款同声传译 App:技术架构、踩坑实录与反思
想用手机实现实时的多语言对话翻译?这篇文章记录了我从零开发一款同声传译 App 的完整过程: 技术选型的考量、语音识别管线的设计、五个让我卡了半天的坑,以及目前仍然存在的不足和未来可以优化的方向。 如果你也在做语音相关的 Android 开发,相信能少走一些弯路。
一、为什么要做这个 App
起因很简单:经常需要和说不同语言的人面对面交流,手机上的翻译 App 要么是打字输入太慢,要么是"按住说话"的交互在对话场景里非常别扭—— 你按住的时候对方不能说,对方说的时候你又来不及按。理想的状态应该是:手机往桌上一放,双方正常说话,手机自动识别、翻译、播报。
市面上当然有成熟的产品,比如 DeepL、有道翻译官、讯飞听见等等。但要么收费贵、要么有广告、要么功能不符合自己的使用习惯。 作为开发者,最好的学习方式就是自己动手做一个。于是有了这个项目。
支持中、英、德、日、韩、西、俄、法 8 种语言互译;点击开始录音,说话结束后自动识别、翻译、用 TTS 播报出来;国内和欧盟环境下都能用。
二、技术选型与整体架构
2.1 技术栈选择
这是一个纯个人项目,不需要跨平台,直接上原生。选 Kotlin + Jetpack Compose 是因为写起来快、状态管理清晰, 而且 Compose 的声明式 UI 做这种"状态驱动"的界面特别顺手——录音中、识别中、翻译中、播放中,每个状态对应一套 UI,非常直观。
Jetpack Compose · MVVM · StateFlow
状态驱动,响应式更新
科大讯飞语音听写(流式版)
WebSocket 协议,实时返回结果
DeepSeek Chat API
大模型翻译,质量好、价格低
Android 系统 TTS
零成本接入,小语种音质一般
2.2 为什么不用 Android 自带的 SpeechRecognizer
一开始我想图省事,直接用 Android 系统自带的 SpeechRecognizer API。但实际测试后放弃了,原因有三:
- 华为手机上识别率低——系统语音识别走的是华为自己的引擎,对非中英文的支持很弱
- 没有流式结果——系统 API 是一句话结束才返回完整结果,中间看不到实时文字
- 可控性差——VAD(静音检测)的阈值、音频采样率、编码格式都没法精细调整
所以最终方案是:本地用 AudioRecord 录音 → 实时推送到云端讯飞识别 → DeepSeek 翻译 → 系统 TTS 播报。 虽然链路长了点,但每一环都可控。
2.3 整体数据流水线
16kHz / 16bit / 单声道
WebSocket 实时返回
大模型翻译
本地播放
2.4 架构模式
标准的 MVVM 架构:MainActivity 负责 UI 渲染,TranslatorViewModel 负责业务逻辑和状态管理,
底下是四个独立的 Service/模块:AudioRecorder、XfyunSpeechRecognizer、
DeepSeekTranslationService、TextToSpeechService。
每个模块只干一件事,对外暴露简洁的接口。ViewModel 用协程把它们串起来,状态通过 StateFlow 下发到 UI。
好处是每个模块都可以独立测试和替换——比如哪天想把讯飞换成 Google Speech,只需要改 Recognizer 这一层。
三、核心模块设计要点
3.1 音频采集:按帧回调,绝不阻塞主线程
音频采集是整个链路的起点,也是最容易出问题的地方。最初的实现非常 naive:开个线程读音频,读完一帧回调一次。
但停止录音的时候直接在主线程调用 record.stop(),结果在某些机型上会阻塞几秒,导致 App 直接 ANR。
正确的做法是:
- 采集完全在后台线程,通过回调把音频帧传出去,回调必须立即返回
- 停止时只设一个 flag,让采集线程自己退出,主线程绝不等待
- 按固定帧长回调——16kHz 采样率 × 40ms = 1280 字节,和讯飞建议的发送帧长对齐
AudioRecord.read() 返回 0 或负值的时候,绝对不能立刻重试。
一定要加退让(backoff),否则会把 CPU 一个核打满,界面看起来就是"卡住了",
而且因为采集线程空转,音频帧永远发不出去,服务端也不会有响应。
3.2 语音识别:WebSocket 流式听写的细节
讯飞的语音听写流式版用的是 WebSocket 协议,鉴权是 HMAC-SHA256 签名,拼在 URL 的 query 参数里。 整个流程看起来简单,但有几个细节特别容易踩坑:
- 中英文和小语种是不同的域名——中英文走
iat-api.xfyun.cn,小语种走iat-niche-api.xfyun.cn,用错了直接报错 - 首帧必须带 common 和 business——第一帧音频的 JSON 里要包含
common.app_id和完整的业务参数,中间帧只需要音频数据 - 音频帧长和间隔要均匀——建议 1280 字节一帧,间隔 40ms,一口气全发出去会被服务端判定为异常流量
- 结束帧 status=2——发完所有音频后要再发一帧空音频,status 设为 2,告诉服务端"说完了"
3.3 翻译:为什么选大模型而不是翻译 API
一开始考虑过用 Google Translate、DeepL 或者有道翻译的 API,最后选了 DeepSeek 的大模型 API,原因很实际:
- 价格便宜——deepseek-chat 模型输入 ¥1/百万 token,输出 ¥2/百万 token,一段话翻译几分钱
- 质量不差——口语翻译场景下,大模型的表现不输专业翻译 API,语气和上下文处理甚至更好
- 全球可用——DeepSeek 的 API 没有地区限制,欧盟也能直接调用
系统提示词也很重要,明确告诉模型"只输出翻译后的文本,不要加任何解释",否则大模型有时候会回一句"好的,翻译如下:"之类的废话。
3.4 语音合成:系统 TTS 的局限
TTS 是目前体验的短板。Android 系统自带的 TTS 在中文和英文上还凑合,但日语、韩语、俄语这些小语种的音色普遍比较机械, 发音也不够自然。华为手机上的系统 TTS 引擎对非中英文的支持尤其一般。
长期方案肯定是换成在线语音合成(比如讯飞的语音合成流式版),音质接近真人,各语种都靠谱。 但那就多了一路 WebSocket 连接和相应的状态管理,留到后面再做。
四、踩过的那些坑
这个项目看似简单——不就是录音 → 识别 → 翻译 → 播报嘛,能有多难?实际做下来,大大小小的坑踩了不少。挑几个最有代表性的说。
这是最蠢也最耗时的一个坑。讯飞控制台里 APIKey 和 APISecret 两行挨在一起,我复制的时候搞反了。
结果就是 WebSocket 握手永远返回 401,服务端提示 "HMAC signature cannot be verified: apikey not found"。
一开始我还在怀疑是不是签名算法写错了、日期格式不对、request-line 是不是应该用 POST…… 把鉴权文档翻来覆去看了三遍,最后才发现是 Key 搞反了。浪费了快一个小时。
把 APIKey 和 APISecret 的值换回来就好了。教训是:从控制台复制密钥一定要对标签名,不要凭长度或格式猜测。 另外,在 App 里加一个诊断日志面板,把服务端的原始错误信息直接显示在界面上,排障效率能高很多。
最初的录音实现是在主线程调用 audioRecord.stop(),然后同步等待采集线程结束。
平时没问题,但在某些机型(比如我的华为 Mate 60 Pro)上,stop() 偶尔会阻塞 5 秒以上,直接触发 ANR。
用户的体感就是:点了结束按钮,界面卡死,等半天没反应,然后系统弹"应用无响应"。
重写 AudioRecorder:停止时只设一个 AtomicBoolean 标记,让后台线程自己退出;
用协程的 suspend 函数封装停止操作,绝不阻塞主线程。
停止后的收尾(释放资源等)也都放在后台线程的 finally 块里。
这是一个很隐蔽的问题。AudioRecord.read() 在某些异常情况下(比如麦克风被其他应用抢占、权限被动态收回)
会返回 0 或负值。如果代码里写的是"读不到就继续读",就会进入一个死循环——read 返回 0 → 立刻重试 → 又返回 0。
这个死循环跑在录音线程上,能把一个 CPU 核心完全打满。用户的体感是:App 变得巨卡, 按钮点了没反应,但又不是完全死了,偶尔还能动一下。
读取失败时加退让机制:每次失败 Thread.sleep(20),连续失败 20 次就判定麦克风异常,
自动停止录音并回调错误。这样即使出了问题,用户也能看到明确的错误提示,而不是界面卡死。
最开始我按照网上的教程,调用讯飞的 HTTP 接口 http[s]://api.xfyun.cn/v1/service/v1/iat,
结果永远返回空或者错误。查了半天才发现:旧版 HTTP 接口早就不对新用户开放了,
现在语音听写只有 WebSocket 流式版可以用。
网上能搜到的绝大多数 Kotlin/Java 示例代码都是基于旧版 HTTP 接口的,照着写肯定跑不通。 一定要以官方文档为准,不要信第三方博客。
完全重写识别模块,用 OkHttp 的 WebSocket 实现流式听写。鉴权是 HMAC-SHA256 签名拼在 URL 里, 首帧带 common/business,中间帧 status=1,结束帧 status=2。全部按照官方文档来。
讯飞默认的后端点检测(VAD)静默时间是 2 秒。也就是说,你说话中间停顿超过 2 秒, 服务端就认为你说完了,自动结束会话。
这在日常对话场景里完全不够用——想一下、组织一下语言,很容易就超过 2 秒。 用户的体感是:我还没说完呢,怎么就开始翻译了?
把 eos 参数调到 10 秒(讯飞支持自定义),给正常的思考和停顿留出空间。
同时监听服务端的"静默超时自动结束"事件,App 侧自动收尾进入翻译流程,不会让用户白等。
五、目前还有哪些不足
功能跑通了,但离"好用"还有不小的距离。列一下已知的问题和待优化的方向。
| 问题 | 严重程度 | 说明 | 优化方向 |
|---|---|---|---|
| 小语种 TTS 音质差 | 中 | 系统 TTS 的日/韩/俄/法/西语音色机械,听着费劲 | 换成讯飞在线语音合成或 Edge TTS |
| 需要手动按按钮 | 中 | 对话时每次都要点开始/结束,不够自然 | 加 VAD 自动检测说话,自动启停 |
| 单向翻译 | 中 | 只能从 A 语言翻译成 B 语言,对话要手动切换方向 | 双向模式:自动识别谁说的话,双向互译 |
| 完全依赖网络 | 高 | 识别和翻译都要联网,没网就废了 | 探索 Whisper 本地识别 + 本地翻译模型 |
| 欧盟网络适配 | 中 | 讯飞服务器在国内,欧盟地区访问延迟和可用性未验证 | 增加 Google Speech 作为海外备选方案 |
| 没有降噪和回声消除 | 低 | 嘈杂环境下识别率下降明显 | 集成 Speex DSP 或 WebRTC 的 NS/AEC 模块 |
| 耗电较快 | 低 | 长时间录音 + WebSocket 长连接,耗电比普通 App 高 | 优化音频采样率、增加空闲时节能模式 |
关于"完全离线"的一点思考
说实话,要做到完全离线且体验不错,目前在手机上难度不小。语音识别方面,Whisper.cpp 已经可以在移动端跑起来了, 但中文字型的识别准确率和速度跟云端比还有差距;翻译方面,端侧大模型的翻译质量暂时还打不过云端大模型。
所以短期的策略应该是:云端为主,离线作为降级方案——有网的时候用云端,保证质量; 没网的时候用本地模型,能凑合能用就行。
六、写在最后
做这个项目最大的感受是:语音相关的开发,坑都在细节里。 从表面上看就是"录音-识别-翻译-播报"四步,但每一步都有自己的坑——音频格式、帧长控制、鉴权签名、状态同步、线程安全…… 任何一个细节出问题,最终表现都是"识别不了"或者"卡住了",排查起来还挺费劲。
好在现在核心链路已经跑通了,中文和英文的体验还不错。接下来的优先级大概是:
- 换成在线 TTS,先把小语种的播放音质提上来
- 加 VAD 自动检测说话,实现"说就录、停就翻"的免按键模式
- 双向对话模式,不用手动切换翻译方向
- 探索离线方案作为降级
技术栈:Kotlin + Jetpack Compose + MVVM + StateFlow
核心依赖:OkHttp(WebSocket)、DeepSeek API、讯飞语音听写(流式版)
测试设备:华为 Mate 60 Pro(HarmonyOS / Android)
代码行数:约 1500 行 Kotlin
如果你也在做类似的项目,希望这篇文章能帮你少踩几个坑。有问题欢迎交流。