42 KiB
步骤 08:语音通信
实现语音通信:PTT 按钮、Opus 编码、SendVoice、OnVoiceData 接收。 对应流程:
docs/流程/05_语音通信.md依赖步骤:06(频道切换)
一、目标
- PTT 按钮交互(按住发言、松开停止)
- 麦克风采集与 Opus 编码(VoiceService 已实现基础管线)
- SendVoice 发送循环(通过 TSBridge 调用 Go SDK)
- OnVoiceData 接收与解码播放
- VoiceViewModel 状态管理(语音状态机)
- 语音控制栏与语音卡 UI
二、任务清单
8.1 PTT 按钮组件
目标:实现 PTT(Push-To-Talk)按住发言按钮,支持触摸按下/松开/取消手势。
前置条件:
- 步骤 05 的 VoiceControlBar 已有基础框架
- VoiceViewModel 已创建(本步骤 8.6)
对应 UI 设计:docs/UI架构设计.md 2.2 底部语音控制区
对应流程:docs/流程/05_语音通信.md 时序图 — PttPressed / PttReleased / PointerCancelled
任务:
-
PTT 按钮手势处理
PTT 按钮使用
pointerInput+detectDragGestures实现按住发言:onDragStart→ 触发VoiceViewModel.startTransmit()onDragEnd/onDragCancel→ 触发VoiceViewModel.stopTransmit()
注意:不能使用普通
onClick,因为 PTT 需要区分"按下"和"松开"两个时机。// ui/components/PTTButton.kt @Composable fun PTTButton( isTransmitting: Boolean, onStartTransmit: () -> Unit, onStopTransmit: () -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier ) { Button( onClick = {}, // 不使用 onClick,手势由 pointerInput 处理 enabled = enabled, modifier = modifier .height(48.dp) .pointerInput(enabled) { if (!enabled) return@pointerInput detectDragGestures( onDragStart = { onStartTransmit() }, onDragEnd = { onStopTransmit() }, onDragCancel = { onStopTransmit() }, onDrag = { _, _ -> } // 不需要处理拖动距离 ) }, colors = ButtonDefaults.buttonColors( containerColor = if (isTransmitting) { Color(0xFF4CAF50) // 绿色 — 正在发言 } else { MaterialTheme.colorScheme.primaryContainer } ) ) { Text( text = if (isTransmitting) "正在发言..." else "PTT 按住发言", style = MaterialTheme.typography.labelLarge ) } } -
VoiceControlBar 集成
将 PTTButton 集成到 VoiceControlBar 中(替换步骤 05 的占位实现):
// ui/components/VoiceControlBar.kt @Composable fun VoiceControlBar( voiceViewModel: VoiceViewModel, onExpand: () -> Unit ) { val isMuted by voiceViewModel.isMuted.collectAsState() val isTransmitting by voiceViewModel.isTransmitting.collectAsState() val voiceState by voiceViewModel.voiceState.collectAsState() // 语音不可用时(未连接/未入频道)禁用 PTT val pttEnabled = voiceState is VoiceState.Idle || voiceState is VoiceState.Transmitting Row( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surface) .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { // 静音按钮 IconButton(onClick = { voiceViewModel.toggleMute() }) { Icon( imageVector = if (isMuted) Icons.Default.MicOff else Icons.Default.Mic, contentDescription = if (isMuted) "取消静音" else "静音", tint = if (isMuted) Color.Red else MaterialTheme.colorScheme.onSurface ) } // PTT 按钮 PTTButton( isTransmitting = isTransmitting, onStartTransmit = { voiceViewModel.startTransmit() }, onStopTransmit = { voiceViewModel.stopTransmit() }, enabled = pttEnabled, modifier = Modifier.weight(1f) ) // 展开语音卡按钮 IconButton(onClick = onExpand) { Icon(Icons.Default.ExpandLess, contentDescription = "展开语音卡") } } }
8.2 音频采集
目标:管理麦克风采集生命周期,确保权限和资源正确处理。
对应流程:docs/流程/05_语音通信.md — StartCapture / StopCapture
已有实现:voice/VoiceService.kt 已实现完整的音频采集管线。
VoiceService 采集管线概览:
AudioRecord (48kHz mono, PCM 16-bit)
→ NoiseSuppressor (系统级降噪)
→ RMS 音量检测 (说话检测)
→ OpusEncoder (MediaCodec, 20ms 帧, 32kbps)
→ onVoiceData 回调 (ByteArray, codec)
关键参数:
| 参数 | 值 | 说明 |
|---|---|---|
| SAMPLE_RATE | 48000 | Opus 标准采样率 |
| CHANNELS | 1 | 单声道 |
| FRAME_DURATION_MS | 20 | 20ms 一帧(Opus 标准) |
| FRAME_SIZE | 960 | 48000 × 20 / 1000 |
| BIT_RATE | 32000 | 32kbps,VOIP 场景足够 |
| SPEAKING_THRESHOLD | 0.015f | RMS 阈值,用于说话检测 |
权限处理:
// VoiceService.isVoiceAvailable() 检查两项前置条件:
// 1. API 29+ (Android 10) — MediaCodec Opus 支持
// 2. RECORD_AUDIO 权限
// 在 VoiceViewModel.startTransmit() 中调用前检查:
fun checkVoicePermission(context: Context): Boolean {
return context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
}
任务:
-
权限请求(在 MainActivity 或频道列表页)
// MainActivity.kt 或通过 Accompanist permissions val launcher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> if (granted) { voiceViewModel.onPermissionGranted() } else { // 显示提示:需要麦克风权限才能发言 } } // 在需要时请求 LaunchedEffect(Unit) { if (!voiceViewModel.checkVoiceAvailable()) { launcher.launch(Manifest.permission.RECORD_AUDIO) } } -
VoiceService 生命周期管理
VoiceService 的
startCapture()/stopCapture()由 VoiceViewModel 管理,不直接由 UI 调用:UI (PTT 按钮) → VoiceViewModel.startTransmit() → 前置条件检查(连接、频道) → VoiceService.startCapture() → 状态变为 Transmitting
8.3 Opus 编解码集成
目标:确保 Opus 编码器/解码器正确集成到语音管线。
已有实现:
voice/OpusEncoder.kt— 使用 MediaCodec 硬件编码(API 29+)voice/OpusDecoder.kt— 使用 MediaCodec 硬件解码(API 29+)
编码参数:
| 参数 | 值 | 说明 |
|---|---|---|
| MIME | audio/opus | Opus 编解码格式 |
| SAMPLE_RATE | 48000 | Opus 标准 |
| CHANNELS | 1 | 单声道 |
| BIT_RATE | 32000 | 32kbps VOIP |
| COMPLEXITY | 5 | 中等复杂度(平衡 CPU 与质量) |
| PCM_ENCODING | PCM_16BIT | 16-bit 整型 PCM |
Codec 值说明(对应 docs/sdk文档-go.md 6. 语音命令):
| 值 | 类型 | 适用场景 | 本应用使用 |
|---|---|---|---|
| 4 | Opus Voice | 语音通话 | ✅ 默认 |
| 5 | Opus Music | 音乐/高保真 | ❌ 暂不支持 |
任务:
-
编码器集成验证
确认 VoiceService 的
captureLoop()正确调用 OpusEncoder:// VoiceService.captureLoop() 已实现: // 1. AudioRecord.read() → recordBuffer (960 samples) // 2. calculateRMS() → 说话检测 // 3. opusEncoder.encode(recordBuffer) → opusData // 4. onVoiceData?.invoke(opusData, 4) → 回调发送 -
解码器集成验证
确认 VoiceService 的
handleVoiceData()正确调用 OpusDecoder:// VoiceService.handleVoiceData() 已实现: // 1. 检查 speakerEnabled 和 codec // 2. opusDecoder.decode(data) → pcm (ShortArray) // 3. 说话检测 → onClientSpeaking // 4. audioTrack.write(pcm) → 播放 -
API 兼容性处理
// OpusEncoder.isAvailable() 和 OpusDecoder.isAvailable() 均检查 API 29+ // VoiceService.isVoiceAvailable() 组合检查 API 版本 + 权限 // UI 层根据 isVoiceAvailable() 决定是否显示 PTT 按钮或显示提示
8.4 语音发送流程
目标:实现从 PTT 按下到语音帧发送的完整流程。
对应流程:docs/流程/05_语音通信.md 时序图
关键原则:
- 语音只在连接和频道前置条件满足时发送
- 断开或被踢必须立即停止发送
SendVoice返回值只表示本地发送调用结果,不代表对方播放成功
发送时序:
用户按下 PTT
→ VoiceViewModel.startTransmit()
→ 检查 connected && joinedChannel
→ 不满足 → 状态变为 Blocked(reason)
→ 满足 → VoiceService.startCapture()
→ AudioRecord 启动
→ captureLoop 开始
→ 每 20ms 一帧
→ OpusEncoder.encode() → opusData
→ TSBridge.sendVoice(opusData, 4)
→ Go SDK SendVoice(data, codec) → UDP 发送
用户松开 PTT
→ VoiceViewModel.stopTransmit()
→ VoiceService.stopCapture()
→ AudioRecord 停止
→ OpusEncoder 释放
→ 状态恢复 Idle
任务:
-
VoiceViewModel 发送逻辑(详见 8.6)
fun startTransmit() { // 前置条件检查 if (!repository.isConnected.value) { _voiceState.value = VoiceState.Blocked("未连接到服务器") return } if (repository.currentChannelId.value == 0L) { _voiceState.value = VoiceState.Blocked("未加入频道") return } // 启动采集 if (!voiceService.startCapture()) { _voiceState.value = VoiceState.Blocked("麦克风启动失败") return } // 连接 VoiceService 回调到 TSBridge voiceService.onVoiceData = { opusData, codec -> TSBridge.sendVoice(opusData, codec) } _voiceState.value = VoiceState.Transmitting _isTransmitting.value = true } -
断开连接时自动停止
// ServerViewModel 或 Repository 监听断开事件 // 对应 docs/流程/05_语音通信.md:连接断开 → StopCapture → 状态改为 blocked fun onDisconnected() { voiceViewModel.stopVoice() // 立即停止语音 // ... 其他清理 } -
被踢时自动停止
// 对应 docs/流程/05_语音通信.md:被踢 → 立即停止 fun onKicked(reason: String) { voiceViewModel.stopVoice() // ... 显示被踢提示 }
8.5 语音接收流程
目标:实现 OnVoiceData 事件处理,解码并播放其他用户的语音。
对应流程:docs/流程/05_语音通信.md — OnVoiceData 接收
对应 SDK:docs/sdk文档-go.md — VoiceDataEvent 结构
VoiceDataEvent 字段:
| 字段 | 类型 | 描述 |
|---|---|---|
| ClientID | uint16 | 发送者客户端 ID |
| Data | []byte | Opus 编码的语音帧原始数据 |
| Codec | byte | 4=Opus Voice, 5=Opus Music |
接收时序:
TeamSpeak 服务器
→ UDP 语音包
→ Go SDK 解密 + 解析
→ OnVoiceData 回调
→ TSBridge.onVoiceData(clientID, data, codec)
→ VoiceViewModel.handleVoiceData()
→ VoiceService.handleVoiceData()
→ OpusDecoder.decode(data) → pcm
→ AudioTrack.write(pcm) → 扬声器播放
→ 说话检测 → onClientSpeaking(clientID, true)
任务:
-
TSBridge 回调注册
TSBridge 已实现
onVoiceData回调(见TSBridge.kt):// TSBridge.kt 中已有: interface Callbacks { // ... fun onVoiceData(clientID: Int, data: ByteArray, codec: Int) } // Go bridge.go 中已注册: // client.OnVoiceData(func(evt VoiceDataEvent) { // cb.OnVoiceData(d.ClientID, d.Data, d.Codec) // }) -
VoiceViewModel 接收处理
// VoiceViewModel.kt /** * 处理收到的语音数据(由 ServerViewModel 的 onVoiceData 回调触发) * * 对应 docs/流程/05_语音通信.md: * "OnVoiceData 回调在事件循环 goroutine 中串行执行, * 不要在回调中做耗时操作,应将数据推入 channel 由独立协程处理" * * VoiceService.handleVoiceData() 内部已使用 scope.launch(Dispatchers.IO) 异步处理。 */ fun handleVoiceData(clientID: Int, data: ByteArray, codec: Int) { if (!speakerEnabled.value) return voiceService.handleVoiceData(clientID, data, codec) } -
ServerViewModel 注册 OnVoiceData 回调
// ServerViewModel.kt - 在 registerEventHandlers 中添加 fun registerEventHandlers() { TSBridge.setCallbacks(object : TSBridge.Callbacks { // ... 已有回调 ... override fun onVoiceData(clientID: Int, data: ByteArray, codec: Int) { voiceViewModel.handleVoiceData(clientID, data, codec) } }) } -
说话状态跟踪
VoiceService 已实现说话检测(基于 RMS 超时),通过回调通知 ViewModel:
// VoiceService 中: // onClientSpeaking: ((Int, Boolean) -> Unit)? // (clientId, isSpeaking) // onSelfSpeaking: ((Boolean) -> Unit)? // VoiceViewModel 中连接: voiceService.onClientSpeaking = { clientId, isSpeaking -> val current = _speakingClients.value.toMutableMap() if (isSpeaking) { current[clientId] = System.currentTimeMillis() } else { current.remove(clientId) } _speakingClients.value = current } voiceService.onSelfSpeaking = { speaking -> _isSelfSpeaking.value = speaking }
8.6 VoiceViewModel 实现
目标:实现语音通信的核心状态管理,整合 VoiceService 和 TSBridge。
对应流程:docs/流程/05_语音通信.md 状态树
语音状态定义:
┌─────────────────────────────────────────────┐
│ 语音状态 │
│ │
│ Idle (静默) │
│ ├─ startTransmit() → 前置检查 │
│ │ ├─ 失败 → Blocked(reason) │
│ │ └─ 成功 → Transmitting │
│ └─ 无操作 │
│ │
│ Transmitting (发送中) │
│ ├─ stopTransmit() → Idle │
│ ├─ 连接断开 → Blocked("连接已断开") │
│ └─ 被踢 → Blocked("已被踢出") │
│ │
│ Blocked (受阻) │
│ ├─ 重连成功 → Idle │
│ └─ 用户离开 → Idle │
└─────────────────────────────────────────────┘
任务:
-
语音状态密封类
// data/Models.kt /** * 语音发送状态机 * 对应 docs/流程/05_语音通信.md 状态树 * * 状态转换: * Idle → Transmitting:用户按下 PTT 且前置条件满足 * Idle → Blocked:用户按下 PTT 但前置条件不满足 * Transmitting → Idle:用户松开 PTT * Transmitting → Blocked:连接断开或被踢 * Blocked → Idle:重连成功或用户手动恢复 */ sealed class VoiceState { /** 静默 — 没有发送语音帧 */ object Idle : VoiceState() /** 发送中 — 正在采集、编码并发送 Opus 帧 */ object Transmitting : VoiceState() /** 受阻 — 未连接、采集失败或发送异常 */ data class Blocked(val reason: String) : VoiceState() } -
VoiceViewModel 完整实现
// viewmodel/VoiceViewModel.kt package com.tsmobile.app.viewmodel import android.app.Application import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.tsmobile.app.TSBridge import com.tsmobile.app.data.Repository import com.tsmobile.app.data.VoiceState import com.tsmobile.app.voice.VoiceService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class VoiceViewModel( private val repository: Repository, private val application: Application ) : AndroidViewModel(application) { companion object { private const val TAG = "VoiceViewModel" } // ── 底层服务 ── private val voiceService = VoiceService(application, viewModelScope) // ── 状态 ── private val _voiceState = MutableStateFlow<VoiceState>(VoiceState.Idle) val voiceState: StateFlow<VoiceState> = _voiceState private val _isMuted = MutableStateFlow(true) // 默认静音 val isMuted: StateFlow<Boolean> = _isMuted private val _isTransmitting = MutableStateFlow(false) val isTransmitting: StateFlow<Boolean> = _isTransmitting private val _speakerEnabled = MutableStateFlow(true) val speakerEnabled: StateFlow<Boolean> = _speakerEnabled private val _denoiseEnabled = MutableStateFlow(true) val denoiseEnabled: StateFlow<Boolean> = _denoiseEnabled // ── 说话检测 ── private val _isSelfSpeaking = MutableStateFlow(false) val isSelfSpeaking: StateFlow<Boolean> = _isSelfSpeaking /** 正在说话的客户端 {clientId → 开始时间} */ private val _speakingClients = MutableStateFlow<Map<Int, Long>>(emptyMap()) val speakingClients: StateFlow<Map<Int, Long>> = _speakingClients init { // 连接 VoiceService 回调 voiceService.onVoiceData = { opusData, codec -> TSBridge.sendVoice(opusData, codec) } voiceService.onSelfSpeaking = { speaking -> _isSelfSpeaking.value = speaking } voiceService.onClientSpeaking = { clientId, isSpeaking -> val current = _speakingClients.value.toMutableMap() if (isSpeaking) { current[clientId] = System.currentTimeMillis() } else { current.remove(clientId) } _speakingClients.value = current } } // ═══════════════════ 公开 API ═══════════════════ /** * 检查语音是否可用(API 版本 + 权限) */ fun isVoiceAvailable(): Boolean = voiceService.isVoiceAvailable() /** * 权限授予后调用 */ fun onPermissionGranted() { Log.d(TAG, "RECORD_AUDIO permission granted") // 权限授予后,如果当前在频道中,状态自动变为 Idle if (_voiceState.value is VoiceState.Blocked && (_voiceState.value as VoiceState.Blocked).reason == "需要麦克风权限") { _voiceState.value = VoiceState.Idle } } /** * 开始语音发送(PTT 按下) * * 对应 docs/流程/05_语音通信.md: * "检查 connected 和 joinedChannel — 未连接或未入频道时禁止发送" */ fun startTransmit() { Log.d(TAG, "startTransmit called") // 前置条件检查 if (!isVoiceAvailable()) { _voiceState.value = VoiceState.Blocked("需要麦克风权限") return } if (!repository.isConnected.value) { _voiceState.value = VoiceState.Blocked("未连接到服务器") return } if (repository.currentChannelId.value == 0L) { _voiceState.value = VoiceState.Blocked("未加入频道") return } // 取消静音(PTT 时自动取消静音) _isMuted.value = false voiceService.setMuted(false) // 启动采集 if (!voiceService.startCapture()) { _voiceState.value = VoiceState.Blocked("麦克风启动失败") return } _voiceState.value = VoiceState.Transmitting _isTransmitting.value = true Log.i(TAG, "Voice transmitting started") } /** * 停止语音发送(PTT 松开) * * 对应 docs/流程/05_语音通信.md: * "PttReleased / PointerCancelled → StopCapture → 状态恢复 silent" */ fun stopTransmit() { Log.d(TAG, "stopTransmit called") voiceService.stopCapture() _isTransmitting.value = false // 恢复静音状态 _isMuted.value = true voiceService.setMuted(true) if (_voiceState.value is VoiceState.Transmitting) { _voiceState.value = VoiceState.Idle } Log.i(TAG, "Voice transmitting stopped") } /** * 切换静音状态 */ fun toggleMute() { val newMuted = !_isMuted.value _isMuted.value = newMuted voiceService.setMuted(newMuted) Log.d(TAG, "Mute toggled: $newMuted") } /** * 切换扬声器 */ fun toggleSpeaker() { val newEnabled = !_speakerEnabled.value _speakerEnabled.value = newEnabled voiceService.setSpeakerEnabled(newEnabled) Log.d(TAG, "Speaker toggled: $newEnabled") } /** * 切换降噪 */ fun toggleDenoise() { val newEnabled = !_denoiseEnabled.value _denoiseEnabled.value = newEnabled voiceService.setDenoiseEnabled(newEnabled) Log.d(TAG, "Denoise toggled: $newEnabled") } /** * 处理收到的语音数据(由 ServerViewModel 调用) * * 对应 docs/sdk文档-go.md VoiceDataEvent: * "OnVoiceData 回调在事件循环 goroutine 中串行执行, * 不要在回调中做耗时操作" * * VoiceService.handleVoiceData() 内部已使用 Dispatchers.IO 异步处理。 */ fun handleVoiceData(clientID: Int, data: ByteArray, codec: Int) { if (!_speakerEnabled.value) return voiceService.handleVoiceData(clientID, data, codec) } /** * 停止所有语音活动(断开连接或被踢时调用) * * 对应 docs/流程/05_语音通信.md: * "断开或被踢必须立即停止" */ fun stopVoice() { Log.i(TAG, "stopVoice: stopping all voice activity") stopTransmit() _voiceState.value = VoiceState.Idle } /** * 连接断开时调用 * * 对应 docs/流程/05_语音通信.md: * "OnDisconnected(error) → StopCapture → 状态改为 blocked" */ fun onDisconnected() { Log.w(TAG, "onDisconnected: voice blocked") voiceService.stopCapture() _isTransmitting.value = false _voiceState.value = VoiceState.Blocked("连接已断开") } /** * 重连成功时调用 * * 对应 docs/流程/05_语音通信.md: * "重连并同步前禁止恢复发送" → 重连成功后恢复 Idle */ fun onReconnected() { Log.i(TAG, "onReconnected: voice idle") _voiceState.value = VoiceState.Idle } /** * 清除阻塞状态(用户手动恢复) */ fun clearBlocked() { if (_voiceState.value is VoiceState.Blocked) { _voiceState.value = VoiceState.Idle } } // ═══════════════════ 生命周期 ═══════════════════ override fun onCleared() { super.onCleared() voiceService.destroy() Log.d(TAG, "VoiceViewModel cleared") } }
8.7 语音卡 UI(基础)
目标:实现语音卡的发言人列表和控制按钮,作为步骤 11 语音卡的前置实现。
对应 UI 设计:docs/UI架构设计.md 3.3 语音卡
任务:
-
语音卡基础布局
// ui/components/VoiceCard.kt @Composable fun VoiceCard( voiceViewModel: VoiceViewModel, channelViewModel: ChannelViewModel, onDismiss: () -> Unit ) { val speakingClients by voiceViewModel.speakingClients.collectAsState() val currentChannel by channelViewModel.currentChannel.collectAsState() val speakerEnabled by voiceViewModel.speakerEnabled.collectAsState() val denoiseEnabled by voiceViewModel.denoiseEnabled.collectAsState() Column( modifier = Modifier .fillMaxWidth() .padding(16.dp) ) { // 标题栏 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = "语音控制", style = MaterialTheme.typography.titleMedium ) IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = "关闭") } } Spacer(Modifier.height(16.dp)) // 正在发言 Text( text = "正在发言", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.height(8.dp)) if (speakingClients.isEmpty()) { Text( text = "暂无发言", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } else { speakingClients.forEach { (clientId, _) -> val client = channelViewModel.findClient(clientId) SpeakingIndicator( name = client?.nickname ?: "未知用户", modifier = Modifier.padding(vertical = 4.dp) ) } } Spacer(Modifier.height(16.dp)) // 频道内人员 val members = currentChannel?.members ?: emptyList() Text( text = "频道内人员 (${members.size})", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.height(8.dp)) LazyColumn(modifier = Modifier.heightIn(max = 200.dp)) { items(members) { member -> MemberRow( name = member.nickname, isSpeaking = speakingClients.containsKey(member.id) ) } } Spacer(Modifier.height(16.dp)) // 控制按钮 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { ToggleButton( label = "🔊 扬声器", checked = speakerEnabled, onToggle = { voiceViewModel.toggleSpeaker() } ) ToggleButton( label = "🎙️ 降噪", checked = denoiseEnabled, onToggle = { voiceViewModel.toggleDenoise() } ) } } } @Composable private fun SpeakingIndicator(name: String, modifier: Modifier = Modifier) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = Icons.Default.Mic, contentDescription = null, tint = Color(0xFF4CAF50), modifier = Modifier.size(16.dp) ) Spacer(Modifier.width(8.dp)) Text(text = name, style = MaterialTheme.typography.bodyMedium) } } @Composable private fun MemberRow(name: String, isSpeaking: Boolean) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = Icons.Default.Person, contentDescription = null, modifier = Modifier.size(20.dp) ) Spacer(Modifier.width(8.dp)) Text( text = name, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f) ) if (isSpeaking) { Text( text = "🎤 发言中", style = MaterialTheme.typography.labelSmall, color = Color(0xFF4CAF50) ) } } } @Composable private fun ToggleButton( label: String, checked: Boolean, onToggle: () -> Unit ) { FilledTonalButton(onClick = onToggle) { Text( text = if (checked) "$label ✓" else label, style = MaterialTheme.typography.labelMedium ) } }
三、状态与数据流
3.1 语音状态机
┌──────────────────────────────────────────┐
│ │
▼ │
┌─────────┐ │
│ Idle │◄───────────────────────────────────┤
└────┬────┘ │
│ PTT 按下 │
▼ │
┌─────────────┐ │
│ 前置条件检查 │ │
└──┬────────┬─┘ │
│ │ │
满足 │ │ 不满足 │
▼ ▼ │
┌──────────────┐ ┌──────────┐ │
│ Transmitting │ │ Blocked │ │
└──────┬───────┘ └────┬─────┘ │
│ │ │
PTT松开 │ 重连成功 │ │
连接断开 │ 用户恢复 │ │
被踢 │ │ │
▼ │ │
Idle ◄────────────┘ │
│ │
└────────────────────────────────────────────────┘
3.2 语音发送数据流
UI (PTT) VoiceViewModel VoiceService TSBridge Go SDK
│ │ │ │ │
│ startTransmit() │ │ │ │
├───────────────────→│ │ │ │
│ │ 前置条件检查 │ │ │
│ │ startCapture() │ │ │
│ ├───────────────────→│ │ │
│ │ │ AudioRecord 启动 │ │
│ │ │ captureLoop() │ │
│ │ │ │ │
│ │ │ encode() → opus │ │
│ │ │ onVoiceData() │ │
│ │ ├─────────────────→│ │
│ │ │ │ sendVoice() │
│ │ │ ├─────────────────→│
│ │ │ │ │ UDP 发送
│ │ │ │ │
│ stopTransmit() │ │ │ │
├───────────────────→│ │ │ │
│ │ stopCapture() │ │ │
│ ├───────────────────→│ │ │
│ │ │ AudioRecord 停止 │ │
│ │ 状态 → Idle │ │ │
3.3 语音接收数据流
Go SDK TSBridge VoiceViewModel VoiceService UI
│ │ │ │ │
│ OnVoiceData │ │ │ │
├─────────────────→│ │ │ │
│ │ onVoiceData() │ │ │
│ ├───────────────────→│ │ │
│ │ │ handleVoiceData() │ │
│ │ ├──────────────────→│ │
│ │ │ │ decode() → pcm │
│ │ │ │ AudioTrack 播放 │
│ │ │ │ │
│ │ │ │ 说话检测 │
│ │ │ │ onClientSpeaking│
│ │ │◄──────────────────┤ │
│ │ │ 更新 speakingMap │ │
│ │ ├───────────────────────────────────→│
│ │ │ │ │ UI 更新
3.4 语音前置依赖矩阵
对应 docs/流程/05_语音通信.md 事件依赖:
| 操作或事件 | 必须依赖 | 建议依赖 | 依赖失败时的处理 |
|---|---|---|---|
startTransmit() |
已连接、已有可发送的 Opus 帧 | 当前用户已在有效频道 | 任一前置失效 → Blocked(reason) |
SendVoice |
VoiceService 正在采集 | — | 采集停止 → 不再发送 |
handleVoiceData |
speakerEnabled = true | — | 静音 → 丢弃数据 |
四、与其他步骤的集成
4.1 与频道列表页集成(步骤 05)
- ChannelListScreen 底部嵌入 VoiceControlBar
- VoiceControlBar 显示静音按钮、PTT 按钮、展开语音卡入口
- PTT 手势使用
pointerInput+detectDragGestures
4.2 与聊天页集成(步骤 07)
- ChatScreen 底部同样嵌入 VoiceControlBar
- 语音控制栏固定在底部,不受键盘影响
- 使用
WindowCompat.setDecorFitsSystemWindows(window, false)+imePadding()实现
4.3 与断开连接集成(步骤 09)
- 断开连接时调用
voiceViewModel.stopVoice() - 被踢时调用
voiceViewModel.stopVoice() - 断线重连成功后调用
voiceViewModel.onReconnected()
4.4 与语音卡集成(步骤 11)
- 语音卡显示发言人列表(来自
speakingClients) - 语音卡显示频道内人员列表
- 语音卡提供扬声器和降噪控制
五、验收标准
功能验收
-
PTT 按钮
- 按住按钮开始发言,松开停止发言
- 按下时按钮变绿,显示 "正在发言..."
- 松开时按钮恢复,显示 "PTT 按住发言"
- 未连接或未入频道时按钮禁用
-
语音发送
- 按下 PTT 后麦克风启动,开始采集音频
- 音频经 Opus 编码后通过 TSBridge.sendVoice 发送
- 松开 PTT 后麦克风停止,不再发送
- 发送期间说话检测正常工作
-
语音接收
- 同频道其他用户说话时,收到 OnVoiceData 回调
- Opus 数据解码后通过 AudioTrack 播放
- 扬声器关闭时不播放
- 说话状态实时更新
-
静音控制
- 点击静音按钮切换静音/取消静音
- 静音状态下按 PTT 不发送语音
- 静音图标正确显示(红色 = 静音)
-
状态管理
- 未连接时 PTT 按下 → 显示 "未连接到服务器"
- 未入频道时 PTT 按下 → 显示 "未加入频道"
- 连接断开时自动停止发送,状态变为 Blocked
- 重连成功后状态恢复 Idle
-
语音卡
- 点击展开按钮弹出语音卡
- 显示正在发言的成员列表
- 显示频道内全部成员
- 扬声器和降噪控制正常切换
错误处理验收
| 错误场景 | 预期行为 |
|---|---|
| 无麦克风权限 | 显示 "需要麦克风权限" 提示,PTT 禁用 |
| API < 29 | 语音功能不可用,隐藏 PTT 或显示提示 |
| 未连接服务器 | PTT 按下 → Blocked("未连接到服务器") |
| 未加入频道 | PTT 按下 → Blocked("未加入频道") |
| 麦克风被占用 | startCapture 失败 → Blocked("麦克风启动失败") |
| 连接断开 | 自动停止发送 → Blocked("连接已断开") |
| 被踢出 | 自动停止发送 → Blocked("已被踢出") |
性能验收
- 语音延迟 < 100ms(从按下 PTT 到首帧发送)
- 编码 CPU 占用 < 10%(单核)
- 播放流畅无杂音
- 说话检测准确(无明显误触发)
测试用例
| 场景 | 操作 | 预期结果 |
|---|---|---|
| 基本 PTT | 按住 PTT → 说话 → 松开 | 同频道其他人听到声音 |
| 静音 | 开启静音 → 按 PTT | 不发送语音 |
| 取消静音 | 关闭静音 → 按 PTT | 正常发送语音 |
| 未连接 | 未连接时按 PTT | 显示 "未连接到服务器" |
| 未入频道 | 已连接但未入频道时按 PTT | 显示 "未加入频道" |
| 断开连接 | 发言中断开连接 | 自动停止发送 |
| 被踢 | 发言中被踢出 | 自动停止发送 |
| 接收语音 | 其他人说话 | 听到声音,显示发言状态 |
| 扬声器关闭 | 关闭扬声器 | 听不到他人声音 |
| 降噪 | 开启降噪 | 背景噪音减少 |
| 语音卡 | 点击展开按钮 | 显示发言人和成员列表 |
| 多人发言 | 多人同时说话 | 语音卡显示多个发言人 |
六、参考文档
docs/流程/05_语音通信.md- 时序图、状态树、事件依赖docs/UI架构设计.md- 2.2 底部语音控制区、3.3 语音卡docs/sdk文档-go.md- 6. 语音命令(SendVoice、OnVoiceData、VoiceDataEvent)docs/implementation/02_Bridge层实现.md- TSBridge.sendVoice、onVoiceData 回调docs/implementation/05_频道列表页.md- VoiceControlBar 基础实现docs/implementation/07_聊天页.md- ChatScreen 底部语音控制栏集成docs/implementation/11_卡片与全局交互.md- 语音卡完整实现android/app/src/main/java/com/tsmobile/app/voice/VoiceService.kt- 音频管线核心android/app/src/main/java/com/tsmobile/app/voice/OpusEncoder.kt- Opus 编码器android/app/src/main/java/com/tsmobile/app/voice/OpusDecoder.kt- Opus 解码器