Files

1180 lines
42 KiB
Markdown
Raw Permalink Normal View History

2026-07-20 19:01:03 +08:00
# 步骤 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 解码器