# 步骤 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 **任务**: 1. **PTT 按钮手势处理** PTT 按钮使用 `pointerInput` + `detectDragGestures` 实现按住发言: - `onDragStart` → 触发 `VoiceViewModel.startTransmit()` - `onDragEnd` / `onDragCancel` → 触发 `VoiceViewModel.stopTransmit()` > 注意:不能使用普通 `onClick`,因为 PTT 需要区分"按下"和"松开"两个时机。 ```kotlin // 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 ) } } ``` 2. **VoiceControlBar 集成** 将 PTTButton 集成到 VoiceControlBar 中(替换步骤 05 的占位实现): ```kotlin // 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 阈值,用于说话检测 | **权限处理**: ```kotlin // 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 } ``` **任务**: 1. **权限请求(在 MainActivity 或频道列表页)** ```kotlin // 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) } } ``` 2. **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 | 音乐/高保真 | ❌ 暂不支持 | **任务**: 1. **编码器集成验证** 确认 VoiceService 的 `captureLoop()` 正确调用 OpusEncoder: ```kotlin // VoiceService.captureLoop() 已实现: // 1. AudioRecord.read() → recordBuffer (960 samples) // 2. calculateRMS() → 说话检测 // 3. opusEncoder.encode(recordBuffer) → opusData // 4. onVoiceData?.invoke(opusData, 4) → 回调发送 ``` 2. **解码器集成验证** 确认 VoiceService 的 `handleVoiceData()` 正确调用 OpusDecoder: ```kotlin // VoiceService.handleVoiceData() 已实现: // 1. 检查 speakerEnabled 和 codec // 2. opusDecoder.decode(data) → pcm (ShortArray) // 3. 说话检测 → onClientSpeaking // 4. audioTrack.write(pcm) → 播放 ``` 3. **API 兼容性处理** ```kotlin // 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 ``` **任务**: 1. **VoiceViewModel 发送逻辑**(详见 8.6) ```kotlin 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 } ``` 2. **断开连接时自动停止** ```kotlin // ServerViewModel 或 Repository 监听断开事件 // 对应 docs/流程/05_语音通信.md:连接断开 → StopCapture → 状态改为 blocked fun onDisconnected() { voiceViewModel.stopVoice() // 立即停止语音 // ... 其他清理 } ``` 3. **被踢时自动停止** ```kotlin // 对应 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) ``` **任务**: 1. **TSBridge 回调注册** TSBridge 已实现 `onVoiceData` 回调(见 `TSBridge.kt`): ```kotlin // 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) // }) ``` 2. **VoiceViewModel 接收处理** ```kotlin // 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) } ``` 3. **ServerViewModel 注册 OnVoiceData 回调** ```kotlin // ServerViewModel.kt - 在 registerEventHandlers 中添加 fun registerEventHandlers() { TSBridge.setCallbacks(object : TSBridge.Callbacks { // ... 已有回调 ... override fun onVoiceData(clientID: Int, data: ByteArray, codec: Int) { voiceViewModel.handleVoiceData(clientID, data, codec) } }) } ``` 4. **说话状态跟踪** VoiceService 已实现说话检测(基于 RMS 超时),通过回调通知 ViewModel: ```kotlin // 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 │ └─────────────────────────────────────────────┘ ``` **任务**: 1. **语音状态密封类** ```kotlin // 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() } ``` 2. **VoiceViewModel 完整实现** ```kotlin // 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.Idle) val voiceState: StateFlow = _voiceState private val _isMuted = MutableStateFlow(true) // 默认静音 val isMuted: StateFlow = _isMuted private val _isTransmitting = MutableStateFlow(false) val isTransmitting: StateFlow = _isTransmitting private val _speakerEnabled = MutableStateFlow(true) val speakerEnabled: StateFlow = _speakerEnabled private val _denoiseEnabled = MutableStateFlow(true) val denoiseEnabled: StateFlow = _denoiseEnabled // ── 说话检测 ── private val _isSelfSpeaking = MutableStateFlow(false) val isSelfSpeaking: StateFlow = _isSelfSpeaking /** 正在说话的客户端 {clientId → 开始时间} */ private val _speakingClients = MutableStateFlow>(emptyMap()) val speakingClients: StateFlow> = _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 语音卡 **任务**: 1. **语音卡基础布局** ```kotlin // 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 解码器