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