Android无障碍通话应用开发:集成TTS与语音识别实现双向通信
在实际开发中,我们常常会遇到需要为特殊群体设计无障碍交互的场景。例如,对于因疾病、手术或先天原因导致暂时或永久性言语障碍的用户,如何接打电话是一个真实且急迫的需求。传统的文字转语音(TTS)和语音识别技术虽然成熟,但将它们无缝集成到一个稳定、易用、且能应对复杂通话流程的移动应用中,仍涉及一系列工程挑战,包括音频处理、实时交互、界面适配和网络兼容性等。
本文将以一个“为言语障碍者设计的电话辅助工具”为技术主线,从零开始,探讨如何构建一个具备核心通话功能的 Android 应用。我们将使用 Kotlin 语言,集成 Android 原生 TTS 引擎和 Google 的 Speech-to-Text API,实现“文本输入 -> 语音播报给对方”以及“收听对方语音 -> 实时转文字显示”的双向通信闭环。文章将详细拆解项目结构、关键代码、权限处理、音频焦点管理以及实际测试中遇到的典型问题,目标是交付一个可学习、可复现、可扩展的技术方案。
1. 理解核心交互模型与关键技术选型
在开始编码前,必须明确我们要解决的核心问题是什么,以及现有技术如何组合来解决它。这个工具不是简单的录音播放器,而是一个在标准电话通话链路中插入的实时辅助层。
1.1 交互流程拆解
一次完整的辅助通话涉及两个角色:使用本工具的用户(发送方)和接听电话的对方(接收方)。流程如下:
- 发送方接听/拨打电话:工具作为前台服务运行,监听电话状态。
- 发送方输入文本:在通话建立后,发送方通过键盘或预置短语输入想说的话。
- 文本转语音(TTS)播报:工具将输入文本转换为语音,并通过手机听筒或扬声器播放,声音会通过标准通话音频链路传输给接收方。
- 接收方说话:接收方像正常通话一样进行语音回复。
- 语音转文字(STT)显示:工具通过手机麦克风捕获接收方的语音,将其转换为文字,并实时显示在发送方的设备屏幕上。
这个流程的关键在于,TTS 输出和 STT 输入必须与系统电话通话共享同一个音频通道,且不能相互干扰。
1.2 技术栈选择与考量
- 开发语言与平台:选择 Kotlin 和 Android 原生开发。原因在于需要深度调用系统电话状态、音频管理 API,以及需要稳定的前台服务保活,跨平台框架在此类底层交互上可能受限或增加复杂度。
- 文本转语音(TTS):使用 Android 原生的
TextToSpeech引擎。它免费、离线可用(需下载语音数据包),且与系统集成度高,能较好地处理通话中的音频焦点冲突。我们不需要依赖第三方在线 TTS 服务,以保障无网络环境下的基础功能。 - 语音转文字(STT):选择 Google 的
SpeechRecognizerAPI(需 Google 服务支持)。虽然它需要网络连接,但识别准确率高、延迟相对较低,并且免费额度通常足够个人使用。对于完全离线的场景,可考虑集成如Mozilla DeepSpeech等离线模型,但那会显著增加应用体积和复杂度,本文作为核心教程,优先使用官方 API 实现可行性。 - 通话状态监听:使用
TelephonyManager和PhoneStateListener来监听来电、去电、通话中等状态。 - 音频管理:使用
AudioManager来请求和释放音频焦点,确保 TTS 播报时,系统能正确处理通话音频和媒体音频的关系。
2. 环境准备与项目基础配置
在 Android Studio 中创建一个新的项目,开始进行基础配置。
2.1 开发环境与依赖
确保你的环境满足以下要求:
- Android Studio:最新稳定版(如 Flamingo 或 Giraffe)。
- JDK:17 或以上。
- 目标设备/模拟器:建议使用 Android 9.0 (API level 28) 或更高版本,以获得更稳定的 TTS 和 STT API 支持。
在app/build.gradle.kts(或app/build.gradle) 文件中,配置最低 SDK 并添加必要的依赖。
// app/build.gradle.kts android { namespace = "com.example.assistivecall" compileSdk = 34 defaultConfig { applicationId = "com.example.assistivecall" minSdk = 28 // 设置为 API 28,确保较好的兼容性 targetSdk = 34 versionCode = 1 versionName = "1.0" } // ... 其他配置 } dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.activity:activity-compose:1.8.2") // 如果需要使用 Compose UI implementation(platform("androidx.compose:compose-bom:2023.10.01")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") // 用于前台服务等 implementation("androidx.lifecycle:lifecycle-service:2.7.0") }2.2 权限声明
此应用需要一系列敏感权限,必须在AndroidManifest.xml中声明,并在运行时动态申请(针对 Android 6.0+)。
<!-- app/src/main/AndroidManifest.xml --> <manifest xmlns:android="http://schemas.android.com/apk/res/android"> <!-- 网络权限(用于在线语音识别) --> <uses-permission android:name="android.permission.INTERNET" /> <!-- 录音权限(用于捕获对方语音进行识别) --> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <!-- 读取电话状态权限(用于监听来电、去电) --> <uses-permission android:name="android.permission.READ_PHONE_STATE" /> <!-- 前台服务权限(用于在通话时保持应用活跃) --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- 如果目标API>=33,需要新增的录音权限 --> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <!-- 在API 33+,还需要声明音频媒体权限(非必须,但建议) --> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/Theme.AssistiveCall"> <!-- 主Activity --> <activity android:name=".MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <!-- 通话辅助服务 --> <service android:name=".CallAssistService" android:enabled="true" android:exported="false" android:foregroundServiceType="phoneCall" /> <!-- API 31+ 需要指定类型 --> </application> </manifest>注意:
RECORD_AUDIO和READ_PHONE_STATE属于危险权限,必须在 Activity 中向用户动态申请。FOREGROUND_SERVICE和POST_NOTIFICATIONS在对应高版本系统上也需动态申请。
3. 构建核心:通话辅助服务 (CallAssistService)
核心逻辑应该放在一个Service中,因为它需要在后台持续运行,监听电话状态并管理音频。我们将其实现为一个LifecycleService,以便更好地与组件生命周期协同。
3.1 服务骨架与电话状态监听
首先创建CallAssistService类。
// app/src/main/java/com/example/assistivecall/CallAssistService.kt import android.app.* import android.content.Context import android.content.Intent import android.os.* import android.telephony.TelephonyManager import androidx.lifecycle.LifecycleService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch class CallAssistService : LifecycleService() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private lateinit var telephonyManager: TelephonyManager private var phoneStateListener: PhoneStateListener? = null override fun onCreate() { super.onCreate() // 初始化电话管理器 telephonyManager = getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager startForegroundService() setupPhoneStateListener() } private fun startForegroundService() { // 创建通知渠道 (API 26+) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, "通话辅助服务", NotificationManager.IMPORTANCE_LOW ).apply { description = "用于在通话中提供语音转文字和文字转语音辅助" } (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) .createNotificationChannel(channel) } // 创建前台服务通知 val notification = Notification.Builder(this, CHANNEL_ID) .setContentTitle("通话辅助工具运行中") .setContentText("已就绪,可接听/拨打电话") .setSmallIcon(android.R.drawable.ic_dialog_info) // 替换为你自己的图标 .build() // 启动前台服务。Android 9+ 需要 `FOREGROUND_SERVICE` 权限。 // Android 12+ 需要在 manifest 中指定 `foregroundServiceType`。 startForeground(NOTIFICATION_ID, notification) } private fun setupPhoneStateListener() { phoneStateListener = object : PhoneStateListener() { override fun onCallStateChanged(state: Int, phoneNumber: String?) { when (state) { TelephonyManager.CALL_STATE_IDLE -> { // 通话结束 serviceScope.launch { onCallEnded() } } TelephonyManager.CALL_STATE_OFFHOOK -> { // 通话接通(包括拨出接通和接听) serviceScope.launch { onCallConnected() } } TelephonyManager.CALL_STATE_RINGING -> { // 来电响铃 serviceScope.launch { onIncomingCall(phoneNumber) } } } } } // 注册监听器,监听通话状态变化 telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE) } private suspend fun onCallConnected() { // 通话接通,初始化 TTS 和 STT,并开始监听对方语音 initTTS() startSpeechRecognition() } private suspend fun onCallEnded() { // 通话结束,释放资源 releaseTTS() stopSpeechRecognition() } private suspend fun onIncomingCall(phoneNumber: String?) { // 可以在这里显示来电号码,或者预加载一些常用回复短语 // 本例中仅作日志记录 android.util.Log.d(TAG, "Incoming call from: $phoneNumber") } override fun onDestroy() { super.onDestroy() phoneStateListener?.let { telephonyManager.listen(it, PhoneStateListener.LISTEN_NONE) } serviceScope.cancel() } companion object { private const val TAG = "CallAssistService" private const val CHANNEL_ID = "call_assist_channel" private const val NOTIFICATION_ID = 1 } }3.2 集成文本转语音 (TTS)
在服务中初始化TextToSpeech,并处理音频焦点。
// 在 CallAssistService 类中添加以下成员和方法 import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener import android.media.AudioManager import java.util.* class CallAssistService : LifecycleService() { // ... 已有代码 ... private lateinit var textToSpeech: TextToSpeech private lateinit var audioManager: AudioManager private var isTtsReady = false private suspend fun initTTS() { // 在主线程初始化 TTS textToSpeech = TextToSpeech(applicationContext) { status -> if (status == TextToSpeech.SUCCESS) { // 设置语言,这里设置为中文 val result = textToSpeech.setLanguage(Locale.CHINESE) isTtsReady = if (result != TextToSpeech.LANG_MISSING_DATA && result != TextToSpeech.LANG_NOT_SUPPORTED ) { android.util.Log.d(TAG, "TTS 初始化成功") true } else { android.util.Log.e(TAG, "TTS 语言不支持或数据缺失") false } } else { android.util.Log.e(TAG, "TTS 初始化失败") isTtsReady = false } } audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager } /** * 对外暴露的方法:将文本转换为语音并播放给对方 */ fun speakText(text: String) { if (!isTtsReady) { android.util.Log.w(TAG, "TTS 未就绪,无法播报") return } serviceScope.launch(Dispatchers.Main) { // 1. 请求音频焦点,让系统知道我们要播放重要的音频(通话语音) val focusResult = audioManager.requestAudioFocus( null, // 可以传入 AudioManager.OnAudioFocusChangeListener 来监听焦点变化 AudioManager.STREAM_VOICE_CALL, // 使用通话音频流 AudioManager.AUDIOFOCUS_GAIN_TRANSIENT // 临时获取焦点 ) if (focusResult == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { // 2. 设置 TTS 使用通话音频流,确保声音从听筒/扬声器进入通话链路 val params = Bundle().apply { putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, AudioManager.STREAM_VOICE_CALL) } // 3. 播报语音 textToSpeech.speak(text, TextToSpeech.QUEUE_FLUSH, params, "utteranceId_${System.currentTimeMillis()}") // 4. 设置监听器,在播报完成后释放音频焦点 textToSpeech.setOnUtteranceProgressListener(object : UtteranceProgressListener() { override fun onStart(utteranceId: String?) {} override fun onError(utteranceId: String?) { releaseAudioFocus() } override fun onDone(utteranceId: String?) { releaseAudioFocus() } }) } else { android.util.Log.e(TAG, "无法获取音频焦点,播报失败") } } } private fun releaseAudioFocus() { audioManager.abandonAudioFocus(null) } private fun releaseTTS() { if (::textToSpeech.isInitialized) { textToSpeech.stop() textToSpeech.shutdown() isTtsReady = false } } }3.3 集成语音转文字 (STT)
使用SpeechRecognizer持续监听来自麦克风的音频(即对方说话的声音)。
// 在 CallAssistService 类中添加以下成员和方法 import android.speech.SpeechRecognizer import android.speech.RecognitionListener import android.speech.RecognizerIntent import android.content.Intent class CallAssistService : LifecycleService() { // ... 已有代码 ... private lateinit var speechRecognizer: SpeechRecognizer private var isListening = false private suspend fun startSpeechRecognition() { // 检查设备是否支持语音识别 if (!SpeechRecognizer.isRecognitionAvailable(applicationContext)) { android.util.Log.e(TAG, "设备不支持语音识别") return } speechRecognizer = SpeechRecognizer.createSpeechRecognizer(applicationContext).apply { setRecognitionListener(object : RecognitionListener { override fun onReadyForSpeech(params: Bundle?) { android.util.Log.d(TAG, "语音识别准备就绪") } override fun onBeginningOfSpeech() { android.util.Log.d(TAG, "检测到语音开始") } override fun onRmsChanged(rmsdB: Float) { // 可用于显示音量波动动画 } override fun onBufferReceived(buffer: ByteArray?) {} override fun onEndOfSpeech() { android.util.Log.d(TAG, "检测到语音结束") } override fun onError(error: Int) { val errorMessage = when (error) { SpeechRecognizer.ERROR_AUDIO -> "音频错误" SpeechRecognizer.ERROR_CLIENT -> "客户端错误" SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "权限不足" SpeechRecognizer.ERROR_NETWORK -> "网络错误" SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "网络超时" SpeechRecognizer.ERROR_NO_MATCH -> "无匹配结果" SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "识别器忙" SpeechRecognizer.ERROR_SERVER -> "服务器错误" SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "未检测到语音" else -> "未知错误: $error" } android.util.Log.w(TAG, "语音识别错误: $errorMessage") // 发生错误后,尝试重新开始监听(网络错误等可恢复错误) if (error != SpeechRecognizer.ERROR_CLIENT) { restartListening() } } override fun onResults(results: Bundle?) { // 识别成功,获取结果列表 val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) val bestMatch = matches?.get(0) // 取置信度最高的结果 bestMatch?.let { text -> android.util.Log.i(TAG, "识别结果: $text") // 这里应该将识别到的文字发送到 UI 界面显示 // 例如通过 LiveData 或广播 sendRecognitionResultToUI(text) } // 一次识别结束后,立即开始下一次监听,实现连续识别 restartListening() } override fun onPartialResults(partialResults: Bundle?) { // 可以在这里处理部分结果,实现实时字幕效果 } override fun onEvent(eventType: Int, params: Bundle?) {} }) } // 开始监听 restartListening() } private fun restartListening() { if (!isListening && ::speechRecognizer.isInitialized) { val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.CHINESE.language) // 设置识别语言为中文 putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) // 启用部分结果 putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) // 关键:设置识别为持续模式(非标准,但通过连续触发实现) // 更优方案是使用 `ACTION_RECOGNIZE_SPEECH` 并处理 `onEndOfSpeech` 后重启 } try { speechRecognizer.startListening(intent) isListening = true } catch (e: SecurityException) { android.util.Log.e(TAG, "无录音权限", e) } } } private fun stopSpeechRecognition() { if (isListening && ::speechRecognizer.isInitialized) { speechRecognizer.stopListening() speechRecognizer.destroy() isListening = false } } private fun sendRecognitionResultToUI(text: String) { // 通过 LocalBroadcastManager 或 ViewModel 将结果发送给 Activity val intent = Intent(ACTION_RECOGNITION_RESULT).apply { putExtra(EXTRA_RECOGNIZED_TEXT, text) } LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent) } companion object { const val ACTION_RECOGNITION_RESULT = "com.example.assistivecall.ACTION_RECOGNITION_RESULT" const val EXTRA_RECOGNIZED_TEXT = "recognized_text" // ... 其他常量 ... } }4. 构建用户界面 (UI) 与交互
UI 需要提供文本输入框、发送按钮、显示对方语音转文字结果的区域,以及控制服务启动/停止的入口。
4.1 主 Activity 布局与权限申请
我们使用 Jetpack Compose 来构建一个简单的界面。首先,在MainActivity中处理权限申请和服务绑定。
// app/src/main/java/com/example/assistivecall/MainActivity.kt import android.Manifest import android.content.* import android.os.Build import android.os.Bundle import android.os.IBinder import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.example.assistivecall.ui.theme.AssistiveCallTheme class MainActivity : ComponentActivity() { private lateinit var callAssistService: CallAssistService private var isServiceBound = false private val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { val binder = service as CallAssistService.LocalBinder callAssistService = binder.getService() isServiceBound = true } override fun onServiceDisconnected(name: ComponentName?) { isServiceBound = false } } // 权限请求合约 private val requestPermissionsLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> val allGranted = permissions.all { it.value } if (allGranted) { // 权限全部授予,启动服务 startAndBindService() } else { // 处理权限被拒绝的情况 // 可以提示用户或关闭应用 } } // 用于接收识别结果的广播接收器 private val recognitionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == CallAssistService.ACTION_RECOGNITION_RESULT) { val text = intent.getStringExtra(CallAssistService.EXTRA_RECOGNIZED_TEXT) text?.let { recognizedText -> // 更新 UI 状态 // 这里需要通过 ViewModel 或其它方式更新 Compose 状态 } } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 注册广播接收器 LocalBroadcastManager.getInstance(this).registerReceiver( recognitionReceiver, IntentFilter(CallAssistService.ACTION_RECOGNITION_RESULT) ) // 检查并申请权限 checkAndRequestPermissions() setContent { AssistiveCallTheme { MainAppScreen() } } } private fun checkAndRequestPermissions() { val requiredPermissions = mutableListOf<String>() requiredPermissions.add(Manifest.permission.RECORD_AUDIO) requiredPermissions.add(Manifest.permission.READ_PHONE_STATE) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { requiredPermissions.add(Manifest.permission.POST_NOTIFICATIONS) } // 检查是否已授权 val permissionsToRequest = requiredPermissions.filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }.toTypedArray() if (permissionsToRequest.isNotEmpty()) { requestPermissionsLauncher.launch(permissionsToRequest) } else { startAndBindService() } } private fun startAndBindService() { val serviceIntent = Intent(this, CallAssistService::class.java) // 启动服务(使其成为前台服务) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(serviceIntent) } else { startService(serviceIntent) } // 绑定服务以获取实例引用 bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE) } override fun onDestroy() { super.onDestroy() LocalBroadcastManager.getInstance(this).unregisterReceiver(recognitionReceiver) if (isServiceBound) { unbindService(connection) isServiceBound = false } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainAppScreen() { var inputText by remember { mutableStateOf("") } val recognizedMessages = remember { mutableStateListOf<String>() } Surface(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier .fillMaxSize() .padding(16.dp), verticalArrangement = Arrangement.SpaceBetween ) { // 区域1:显示对方语音转换成的文字(识别结果) Card( modifier = Modifier .fillMaxWidth() .weight(1f), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { LazyColumn(modifier = Modifier.padding(8.dp)) { items(recognizedMessages) { message -> Text( text = "对方说:$message", modifier = Modifier.padding(vertical = 4.dp) ) Divider() } } } Spacer(modifier = Modifier.height(16.dp)) // 区域2:文本输入和发送区域 Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { OutlinedTextField( value = inputText, onValueChange = { inputText = it }, label = { Text("输入你想说的话") }, modifier = Modifier.weight(1f), singleLine = false ) Spacer(modifier = Modifier.width(8.dp)) Button( onClick = { // 调用服务的 speakText 方法 // 需要从 Activity 中获取 service 实例,这里简化处理 // 实际应通过 ViewModel 或回调调用 if (inputText.isNotBlank()) { // callAssistService.speakText(inputText) inputText = "" // 清空输入框 } }, enabled = inputText.isNotBlank() ) { Text("发送") } } // 区域3:快捷短语按钮 Text("快捷短语:", modifier = Modifier.padding(top = 8.dp)) FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { listOf("你好", "是的", "不是", "谢谢", "请稍等").forEach { phrase -> FilterChip( selected = false, onClick = { // callAssistService.speakText(phrase) }, label = { Text(phrase) } ) } } } } }4.2 在服务中实现 LocalBinder 供 Activity 调用
为了让 Activity 能调用服务的speakText方法,需要在服务中添加一个LocalBinder。
// 在 CallAssistService 类中添加内部类 class CallAssistService : LifecycleService() { // ... 已有代码 ... private val binder = LocalBinder() inner class LocalBinder : Binder() { fun getService(): CallAssistService = this@CallAssistService } override fun onBind(intent: Intent): IBinder { super.onBind(intent) return binder } }5. 运行验证与关键测试点
完成编码后,需要在真实设备(建议)或支持电话功能的模拟器上进行测试。
5.1 测试流程
- 安装与授权:安装 APK,首次打开会弹出权限申请对话框,务必全部允许。
- 启动服务:进入应用后,前台服务应自动启动,状态栏会出现常驻通知。
- 模拟通话:
- 使用两部手机:一部安装本应用作为发送方,另一部作为接收方。
- 使用模拟器:Android Studio 的模拟器可以模拟来电和去电(通过 Extended Controls 的 Phone 标签页)。
- 测试 TTS(发送方 -> 接收方):
- 在发送方应用输入框输入文字,点击“发送”。
- 接收方手机应能清晰地听到合成语音。
- 测试 STT(接收方 -> 发送方):
- 让接收方对着手机说话。
- 观察发送方应用界面,是否能在“对方说:”区域看到实时转换的文字。
5.2 预期结果与日志观察
- 服务启动:Logcat 过滤
CallAssistService,应看到“TTS 初始化成功”。 - 来电/去电:当电话状态变化时,应看到
“Incoming call from: ...”或进入onCallConnected。 - TTS 播报:点击发送后,Logcat 应看到
“TTS 初始化成功”(如果已初始化)和音频焦点请求的日志。接收方听到语音。 - STT 识别:接收方说话时,Logcat 应看到
“检测到语音开始”、“检测到语音结束”以及“识别结果: ...”。发送方界面更新文字。
6. 常见问题排查与优化
在实际开发和测试中,你可能会遇到以下问题。
6.1 权限问题导致功能失效
| 问题现象 | 可能原因 | 检查与解决方式 |
|---|---|---|
| 应用启动后立刻崩溃或无法监听电话 | 未动态申请READ_PHONE_STATE权限 | 检查MainActivity中的权限申请逻辑,确保在 Android 6.0+ 设备上弹窗并用户同意。 |
| 点击发送无声音,或 STT 不工作 | 未授予RECORD_AUDIO权限 | 同上,确保录音权限已获取。在应用设置中检查权限状态。 |
| Android 12+ 通知不显示,服务被杀死 | 未申请POST_NOTIFICATIONS权限 | 针对 API 33+ 设备,必须动态申请此权限,否则前台服务通知可能不显示,导致服务被系统限制。 |
| Android 9+ 前台服务启动崩溃 | 未声明FOREGROUND_SERVICE权限 | 在AndroidManifest.xml中确认已添加此权限。 |
6.2 音频相关问题
| 问题现象 | 可能原因 | 检查与解决方式 |
|---|---|---|
| TTS 声音从手机扬声器而不是听筒发出 | TTS 未设置正确的音频流或音频焦点请求失败 | 检查speakText方法中TextToSpeech.speak的参数params是否设置了KEY_PARAM_STREAM为STREAM_VOICE_CALL。确保requestAudioFocus成功。 |
| TTS 播报时对方听到回声或杂音 | 音频管理冲突,麦克风拾取了扬声器声音 | 确保在 TTS 播报期间,STT 监听是否暂停。可以在speakText开始时调用speechRecognizer.stopListening(),播报完成后再restartListening()。 |
STT 无法启动,报ERROR_INSUFFICIENT_PERMISSIONS | 麦克风被其它应用占用或系统策略限制 | 确保没有其他录音应用在运行。在通话中,系统电话应用拥有最高优先级,但我们的应用作为辅助工具,通常可以共享。重启应用或手机。 |
6.3 网络与识别问题
| 问题现象 | 可能原因 | 检查与解决方式 |
|---|---|---|
STT 一直返回ERROR_NETWORK_TIMEOUT或没有结果 | 设备无网络连接,或 Google 语音服务不可用 | 检查设备网络。在中国大陆,可能需要确认网络环境能访问 Google 服务。考虑添加离线识别备选方案。 |
| 识别准确率低 | 环境嘈杂、麦克风距离远、语言设置不匹配 | 确保接收方在安静环境下清晰说话。检查RecognizerIntent.EXTRA_LANGUAGE是否设置为正确的语言区域(如“zh-CN”)。 |
| 识别延迟高 | 网络延迟或SpeechRecognizer配置问题 | 考虑使用EXTRA_PARTIAL_RESULTS先显示中间结果提升体验。对于实时性要求极高的场景,需评估离线引擎。 |
6.4 服务与生命周期问题
| 问题现象 | 可能原因 | 检查与解决方式 |
|---|---|---|
| 退到后台后,服务被系统杀死 | 前台服务通知未正确设置或进程优先级低 | 确保startForeground被成功调用,且通知渠道已创建。检查是否在onCreate中启动了前台服务。考虑将服务设置为START_STICKY。 |
| 电话挂断后,STT 仍在监听 | onCallEnded中资源释放逻辑未执行 | 检查PhoneStateListener的onCallStateChanged中CALL_STATE_IDLE分支是否被触发,并正确调用了stopSpeechRecognition()。 |
7. 生产环境最佳实践与扩展方向
将上述 demo 转化为一个稳定、可用的产品,还需要考虑更多方面。
7.1 稳定性与健壮性增强
- 服务保活与进程优先级:
- 将服务设置为
START_STICKY,并在onStartCommand中返回该标志,使服务被系统杀死后能尝试重启。 - 考虑使用
JobScheduler或WorkManager在特定条件下(如检测到服务不在运行且处于通话中)重新启动服务。
- 将服务设置为
- 音频焦点精细管理:
- 实现
AudioManager.OnAudioFocusChangeListener,监听音频焦点变化。当焦点被其他应用(如音乐播放器)临时夺走时,应暂停 TTS 播报,并在焦点返回后恢复。 - 在 TTS 播报前,不仅请求焦点,还应检查当前通话状态,确保确实在通话中。
- 实现
- 错误处理与降级:
- TTS 初始化失败时,尝试切换语音引擎或提示用户安装语音数据。
- STT 网络不可用时,提供“离线模式”,允许用户预录制常用短语或使用更简单的输入方式(如快捷短语矩阵)。
- 电池优化:
- 在非通话状态下,停止 STT 的持续监听,仅当通话接通后才启动,通话结束后立即释放。
- 使用
WakeLock时要谨慎,确保在不需要时及时释放。
7.2 用户体验优化
- 自定义 TTS 语音:允许用户选择发音人、语速、音调,使合成语音更自然。
- 语音活动检测 (VAD):在 STT 持续监听中,集成简单的 VAD 算法,只在检测到人声时才触发识别,减少误触发和资源消耗。
- 实时字幕界面:优化识别结果的显示,支持滚动、字体大小调整、历史记录查看。
- 快捷短语库:允许用户自定义、分类管理常用语句,支持一键发送。
- 通话录音与回放:在用户授权下,录制完整通话(双方语音),并可将录音文件与文字转写关联,供事后回顾。
7.3 扩展方向
- 离线语音识别集成:集成如
Mozilla DeepSpeech或Picovoice Cheetah等离线 STT 引擎,实现完全离线的通话辅助,解决网络依赖问题。 - 多语言支持:根据用户设置,动态切换 TTS 和 STT 的语言。
- 与系统拨号盘集成:通过
AccessibilityService或BroadcastReceiver监听系统拨号事件,实现更无缝的启动体验。 - 云端同步与个性化:将用户的快捷短语、设置同步到云端,实现多设备间同步。
- 辅助功能 (Accessibility) 深度集成:为视力障碍用户提供完整的 TalkBack 支持,使其能独立操作整个应用。
开发此类工具的核心,在于深刻理解残障用户的实际场景和细腻需求,技术实现上则要格外关注稳定性、实时性和隐私保护。每一个环节的可靠性,都直接关系到工具能否在关键时刻真正帮到用户。