1180 lines
42 KiB
Markdown
1180 lines
42 KiB
Markdown
# 步骤 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 解码器
|