Files
ts-mobile-go/docs/implementation/08_语音通信.md
T
2026-07-20 19:01:03 +08:00

1180 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 步骤 08:语音通信
> 实现语音通信:PTT 按钮、Opus 编码、SendVoice、OnVoiceData 接收。
> 对应流程:`docs/流程/05_语音通信.md`
> 依赖步骤:06(频道切换)
---
## 一、目标
- [ ] PTT 按钮交互(按住发言、松开停止)
- [ ] 麦克风采集与 Opus 编码(VoiceService 已实现基础管线)
- [ ] SendVoice 发送循环(通过 TSBridge 调用 Go SDK
- [ ] OnVoiceData 接收与解码播放
- [ ] VoiceViewModel 状态管理(语音状态机)
- [ ] 语音控制栏与语音卡 UI
---
## 二、任务清单
### 8.1 PTT 按钮组件
**目标**:实现 PTTPush-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 | 32kbpsVOIP 场景足够 |
| 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 解码器