从零开发一款同声传译 App:技术架构、踩坑实录与反思

📅 2026 年 10 月 📱 Android · Kotlin · Jetpack Compose ⏱️ 阅读约 12 分钟

想用手机实现实时的多语言对话翻译?这篇文章记录了我从零开发一款同声传译 App 的完整过程: 技术选型的考量、语音识别管线的设计、五个让我卡了半天的坑,以及目前仍然存在的不足和未来可以优化的方向。 如果你也在做语音相关的 Android 开发,相信能少走一些弯路。

一、为什么要做这个 App

起因很简单:经常需要和说不同语言的人面对面交流,手机上的翻译 App 要么是打字输入太慢,要么是"按住说话"的交互在对话场景里非常别扭—— 你按住的时候对方不能说,对方说的时候你又来不及按。理想的状态应该是:手机往桌上一放,双方正常说话,手机自动识别、翻译、播报。

市面上当然有成熟的产品,比如 DeepL、有道翻译官、讯飞听见等等。但要么收费贵、要么有广告、要么功能不符合自己的使用习惯。 作为开发者,最好的学习方式就是自己动手做一个。于是有了这个项目。

目标功能

支持中、英、德、日、韩、西、俄、法 8 种语言互译;点击开始录音,说话结束后自动识别、翻译、用 TTS 播报出来;国内和欧盟环境下都能用。

二、技术选型与整体架构

2.1 技术栈选择

这是一个纯个人项目,不需要跨平台,直接上原生。选 Kotlin + Jetpack Compose 是因为写起来快、状态管理清晰, 而且 Compose 的声明式 UI 做这种"状态驱动"的界面特别顺手——录音中、识别中、翻译中、播放中,每个状态对应一套 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 整体数据流水线

同声传译数据流水线
🎙️
音频采集
AudioRecord
16kHz / 16bit / 单声道
→
🧠
语音识别
讯飞流式听写
WebSocket 实时返回
→
🌍
机器翻译
DeepSeek API
大模型翻译
→
🔊
语音合成
系统 TTS
本地播放

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 连接和相应的状态管理,留到后面再做。

四、踩过的那些坑

这个项目看似简单——不就是录音 → 识别 → 翻译 → 播报嘛,能有多难?实际做下来,大大小小的坑踩了不少。挑几个最有代表性的说。

1
APIKey 和 APISecret 写反了,鉴权永远 401

这是最蠢也最耗时的一个坑。讯飞控制台里 APIKey 和 APISecret 两行挨在一起,我复制的时候搞反了。 结果就是 WebSocket 握手永远返回 401,服务端提示 "HMAC signature cannot be verified: apikey not found"。

一开始我还在怀疑是不是签名算法写错了、日期格式不对、request-line 是不是应该用 POST…… 把鉴权文档翻来覆去看了三遍,最后才发现是 Key 搞反了。浪费了快一个小时。

修复方案

把 APIKey 和 APISecret 的值换回来就好了。教训是:从控制台复制密钥一定要对标签名,不要凭长度或格式猜测。 另外,在 App 里加一个诊断日志面板,把服务端的原始错误信息直接显示在界面上,排障效率能高很多。

2
录音停止时主线程阻塞,App 直接 ANR

最初的录音实现是在主线程调用 audioRecord.stop(),然后同步等待采集线程结束。 平时没问题,但在某些机型(比如我的华为 Mate 60 Pro)上,stop() 偶尔会阻塞 5 秒以上,直接触发 ANR。

用户的体感就是:点了结束按钮,界面卡死,等半天没反应,然后系统弹"应用无响应"。

修复方案

重写 AudioRecorder:停止时只设一个 AtomicBoolean 标记,让后台线程自己退出; 用协程的 suspend 函数封装停止操作,绝不阻塞主线程。 停止后的收尾(释放资源等)也都放在后台线程的 finally 块里。

3
录音读取失败时空转,CPU 100%,界面卡死

这是一个很隐蔽的问题。AudioRecord.read() 在某些异常情况下(比如麦克风被其他应用抢占、权限被动态收回) 会返回 0 或负值。如果代码里写的是"读不到就继续读",就会进入一个死循环——read 返回 0 → 立刻重试 → 又返回 0。

这个死循环跑在录音线程上,能把一个 CPU 核心完全打满。用户的体感是:App 变得巨卡, 按钮点了没反应,但又不是完全死了,偶尔还能动一下。

修复方案

读取失败时加退让机制:每次失败 Thread.sleep(20),连续失败 20 次就判定麦克风异常, 自动停止录音并回调错误。这样即使出了问题,用户也能看到明确的错误提示,而不是界面卡死。

4
讯飞旧版 HTTP 接口已下线,全网示例代码全是过时的

最开始我按照网上的教程,调用讯飞的 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。全部按照官方文档来。

5
VAD 后端点时间太短,说话中间停顿就被截断

讯飞默认的后端点检测(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 已经可以在移动端跑起来了, 但中文字型的识别准确率和速度跟云端比还有差距;翻译方面,端侧大模型的翻译质量暂时还打不过云端大模型。

所以短期的策略应该是:云端为主,离线作为降级方案——有网的时候用云端,保证质量; 没网的时候用本地模型,能凑合能用就行。

六、写在最后

做这个项目最大的感受是:语音相关的开发,坑都在细节里。 从表面上看就是"录音-识别-翻译-播报"四步,但每一步都有自己的坑——音频格式、帧长控制、鉴权签名、状态同步、线程安全…… 任何一个细节出问题,最终表现都是"识别不了"或者"卡住了",排查起来还挺费劲。

好在现在核心链路已经跑通了,中文和英文的体验还不错。接下来的优先级大概是:

  1. 换成在线 TTS,先把小语种的播放音质提上来
  2. 加 VAD 自动检测说话,实现"说就录、停就翻"的免按键模式
  3. 双向对话模式,不用手动切换翻译方向
  4. 探索离线方案作为降级
📝 项目信息

技术栈:Kotlin + Jetpack Compose + MVVM + StateFlow
核心依赖:OkHttp(WebSocket)、DeepSeek API、讯飞语音听写(流式版)
测试设备:华为 Mate 60 Pro(HarmonyOS / Android)
代码行数:约 1500 行 Kotlin

如果你也在做类似的项目,希望这篇文章能帮你少踩几个坑。有问题欢迎交流。