# 步骤 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>(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 语音卡

**任务**：

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 解码器
