From c5d460b413c483bc095a39ebb6bbe4508eca1729 Mon Sep 17 00:00:00 2001 From: yuliang_guo Date: Wed, 28 Jan 2026 15:45:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8F=8C=E4=BA=BA?= =?UTF-8?q?=E5=AF=B9=E7=BB=83=E8=AF=AD=E9=9F=B3=E9=80=9A=E8=AF=9D=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:扩展 SSE 支持 WebRTC 信令消息转发 - 前端:创建 WebRTC 连接管理模块 (webrtc.ts) - 前端:创建 useVoiceCall 组合式函数 - 前端:在对练房间添加语音通话 UI - 集成 Web Speech API 实现语音转文字 --- backend/app/api/v1/practice_room.py | 61 +++ backend/app/services/practice_room_service.py | 40 +- frontend/src/api/duoPractice.ts | 37 +- frontend/src/composables/useVoiceCall.ts | 462 ++++++++++++++++++ frontend/src/utils/webrtc.ts | 324 ++++++++++++ .../src/views/trainee/duo-practice-room.vue | 349 ++++++++++++- 6 files changed, 1254 insertions(+), 19 deletions(-) create mode 100644 frontend/src/composables/useVoiceCall.ts create mode 100644 frontend/src/utils/webrtc.ts diff --git a/backend/app/api/v1/practice_room.py b/backend/app/api/v1/practice_room.py index baefaee..87275f1 100644 --- a/backend/app/api/v1/practice_room.py +++ b/backend/app/api/v1/practice_room.py @@ -48,6 +48,13 @@ class JoinRoomRequest(BaseModel): class SendMessageRequest(BaseModel): """发送消息请求""" content: str = Field(..., description="消息内容") + source: Optional[str] = Field("text", description="消息来源: text/voice") + + +class WebRTCSignalRequest(BaseModel): + """WebRTC 信令请求""" + signal_type: str = Field(..., description="信令类型: voice_offer/voice_answer/ice_candidate/voice_start/voice_end") + payload: dict = Field(..., description="信令数据(SDP/ICE候选等)") class RoomResponse(BaseModel): @@ -399,6 +406,60 @@ async def send_message( } +@router.post("/{room_code}/signal", summary="发送WebRTC信令") +async def send_signal( + room_code: str, + request: WebRTCSignalRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 发送 WebRTC 信令消息 + + 信令类型: + - voice_start: 发起语音通话 + - voice_offer: SDP Offer + - voice_answer: SDP Answer + - ice_candidate: ICE 候选 + - voice_end: 结束语音通话 + """ + service = PracticeRoomService(db) + + # 获取房间 + room = await service.get_room_by_code(room_code.upper()) + if not room: + raise HTTPException(status_code=404, detail="房间不存在") + + # 检查用户是否在房间中 + user_role = room.get_user_role(current_user.id) + if not user_role: + raise HTTPException(status_code=403, detail="您不是房间参与者") + + # 验证信令类型 + valid_signal_types = ["voice_start", "voice_offer", "voice_answer", "ice_candidate", "voice_end"] + if request.signal_type not in valid_signal_types: + raise HTTPException(status_code=400, detail=f"无效的信令类型,必须是: {', '.join(valid_signal_types)}") + + # 发送信令消息(作为系统消息存储,用于 SSE 推送) + message = await service.send_message( + room_id=room.id, + user_id=current_user.id, + content=None, # 信令消息不需要文本内容 + role_name=None, + message_type=request.signal_type, + extra_data=request.payload + ) + + return { + "code": 200, + "message": "信令发送成功", + "data": { + "signal_type": request.signal_type, + "sequence": message.sequence + } + } + + @router.get("/{room_code}/messages", summary="获取消息列表") async def get_messages( room_code: str, diff --git a/backend/app/services/practice_room_service.py b/backend/app/services/practice_room_service.py index d9afa13..60688fc 100644 --- a/backend/app/services/practice_room_service.py +++ b/backend/app/services/practice_room_service.py @@ -271,44 +271,56 @@ class PracticeRoomService: self, room_id: int, user_id: int, - content: str, - role_name: Optional[str] = None + content: Optional[str], + role_name: Optional[str] = None, + message_type: Optional[str] = None, + extra_data: Optional[dict] = None ) -> PracticeRoomMessage: """ - 发送聊天消息 + 发送聊天消息或信令消息 Args: room_id: 房间ID user_id: 发送者ID content: 消息内容 role_name: 角色名称 + message_type: 消息类型(默认为 chat) + extra_data: 额外数据(用于 WebRTC 信令等) Returns: PracticeRoomMessage: 消息对象 """ + import json + # 获取当前消息序号 sequence = await self._get_next_sequence(room_id) + # 如果是信令消息,将 extra_data 序列化到 content 中 + actual_content = content + if extra_data and not content: + actual_content = json.dumps(extra_data) + message = PracticeRoomMessage( room_id=room_id, user_id=user_id, - message_type=self.MSG_TYPE_CHAT, - content=content, + message_type=message_type or self.MSG_TYPE_CHAT, + content=actual_content, role_name=role_name, sequence=sequence ) self.db.add(message) - # 更新房间统计 - room = await self.get_room_by_id(room_id) - if room: - room.total_turns += 1 - user_role = room.get_user_role(user_id) - if user_role == "A": - room.role_a_turns += 1 - elif user_role == "B": - room.role_b_turns += 1 + # 只有聊天消息才更新房间统计 + if (message_type or self.MSG_TYPE_CHAT) == self.MSG_TYPE_CHAT: + room = await self.get_room_by_id(room_id) + if room: + room.total_turns += 1 + user_role = room.get_user_role(user_id) + if user_role == "A": + room.role_a_turns += 1 + elif user_role == "B": + room.role_b_turns += 1 await self.db.commit() await self.db.refresh(message) diff --git a/frontend/src/api/duoPractice.ts b/frontend/src/api/duoPractice.ts index 30f2c8d..660c35f 100644 --- a/frontend/src/api/duoPractice.ts +++ b/frontend/src/api/duoPractice.ts @@ -76,17 +76,35 @@ export interface RoomDetailResponse { is_host: boolean } +export type MessageType = + | 'chat' + | 'system' + | 'join' + | 'leave' + | 'start' + | 'end' + | 'voice_start' + | 'voice_offer' + | 'voice_answer' + | 'ice_candidate' + | 'voice_end' + export interface RoomMessage { id: number room_id: number user_id?: number - message_type: 'chat' | 'system' | 'join' | 'leave' | 'start' | 'end' + message_type: MessageType content?: string role_name?: string sequence: number created_at: string } +export interface WebRTCSignalRequest { + signal_type: 'voice_start' | 'voice_offer' | 'voice_answer' | 'ice_candidate' | 'voice_end' + payload: Record +} + export interface MessagesResponse { messages: RoomMessage[] room_status: string @@ -185,3 +203,20 @@ export function generateShareLink(roomCode: string): string { const baseUrl = window.location.origin return `${baseUrl}/trainee/duo-practice/join/${roomCode}` } + +/** + * 发送 WebRTC 信令 + */ +export function sendSignal(roomCode: string, signalType: string, payload: Record) { + return request.post(`/api/v1/practice/rooms/${roomCode}/signal`, { + signal_type: signalType, + payload + }) +} + +/** + * 获取对练报告 + */ +export function getPracticeReport(roomCode: string) { + return request.get(`/api/v1/practice/rooms/${roomCode}/report`) +} diff --git a/frontend/src/composables/useVoiceCall.ts b/frontend/src/composables/useVoiceCall.ts new file mode 100644 index 0000000..3db33d1 --- /dev/null +++ b/frontend/src/composables/useVoiceCall.ts @@ -0,0 +1,462 @@ +/** + * 语音通话组合式函数 + * + * 功能: + * - 整合 WebRTC 管理和信令服务 + * - 管理通话状态 + * - 处理语音转文字 + */ +import { ref, computed, onUnmounted } from 'vue' +import { ElMessage } from 'element-plus' +import { WebRTCManager, createWebRTCManager, type ConnectionState } from '@/utils/webrtc' +import request from '@/api/request' + +export type VoiceCallState = 'idle' | 'requesting' | 'ringing' | 'connecting' | 'connected' | 'ended' + +export interface UseVoiceCallOptions { + roomCode: string + onTranscript?: (text: string, isFinal: boolean) => void + onRemoteTranscript?: (text: string) => void +} + +export function useVoiceCall(options: UseVoiceCallOptions) { + const { roomCode, onTranscript, onRemoteTranscript } = options + + // ==================== 状态 ==================== + const callState = ref('idle') + const connectionState = ref('idle') + const isMuted = ref(false) + const isRemoteMuted = ref(false) + const localAudioLevel = ref(0) + const remoteAudioLevel = ref(0) + const callDuration = ref(0) + const errorMessage = ref(null) + + // 语音识别相关 + const isTranscribing = ref(false) + const currentTranscript = ref('') + + // 内部状态 + let webrtcManager: WebRTCManager | null = null + let recognition: any = null // SpeechRecognition + let callTimer: number | null = null + let audioLevelTimer: number | null = null + + // ==================== 计算属性 ==================== + const isCallActive = computed(() => + ['requesting', 'ringing', 'connecting', 'connected'].includes(callState.value) + ) + + const canStartCall = computed(() => callState.value === 'idle') + const canEndCall = computed(() => isCallActive.value) + + // ==================== 信令 API ==================== + + async function sendSignal(signalType: string, payload: any) { + try { + await request.post(`/api/v1/practice/rooms/${roomCode}/signal`, { + signal_type: signalType, + payload + }) + } catch (error) { + console.error('[VoiceCall] 发送信令失败:', error) + throw error + } + } + + // ==================== 通话控制 ==================== + + /** + * 发起语音通话 + */ + async function startCall() { + if (!canStartCall.value) { + console.warn('[VoiceCall] 无法发起通话,当前状态:', callState.value) + return + } + + try { + callState.value = 'requesting' + errorMessage.value = null + + // 创建 WebRTC 管理器 + webrtcManager = createWebRTCManager({ + onConnectionStateChange: handleConnectionStateChange, + onIceCandidate: handleIceCandidate, + onRemoteStream: handleRemoteStream, + onError: handleError + }) + + // 创建 Offer + const offer = await webrtcManager.createOffer() + + // 发送开始信令 + await sendSignal('voice_start', {}) + + // 发送 Offer + await sendSignal('voice_offer', { + type: offer.type, + sdp: offer.sdp + }) + + callState.value = 'ringing' + ElMessage.info('正在呼叫对方...') + + } catch (error: any) { + console.error('[VoiceCall] 发起通话失败:', error) + errorMessage.value = error.message || '发起通话失败' + callState.value = 'idle' + webrtcManager?.close() + webrtcManager = null + ElMessage.error(errorMessage.value) + } + } + + /** + * 接听语音通话 + */ + async function answerCall(offer: RTCSessionDescriptionInit) { + try { + callState.value = 'connecting' + errorMessage.value = null + + // 创建 WebRTC 管理器 + webrtcManager = createWebRTCManager({ + onConnectionStateChange: handleConnectionStateChange, + onIceCandidate: handleIceCandidate, + onRemoteStream: handleRemoteStream, + onError: handleError + }) + + // 处理 Offer 并创建 Answer + const answer = await webrtcManager.handleOffer(offer) + + // 发送 Answer + await sendSignal('voice_answer', { + type: answer.type, + sdp: answer.sdp + }) + + ElMessage.success('已接听通话') + + } catch (error: any) { + console.error('[VoiceCall] 接听通话失败:', error) + errorMessage.value = error.message || '接听通话失败' + callState.value = 'idle' + webrtcManager?.close() + webrtcManager = null + ElMessage.error(errorMessage.value) + } + } + + /** + * 拒绝来电 + */ + async function rejectCall() { + try { + await sendSignal('voice_end', { reason: 'rejected' }) + callState.value = 'idle' + } catch (error) { + console.error('[VoiceCall] 拒绝通话失败:', error) + } + } + + /** + * 结束通话 + */ + async function endCall() { + try { + await sendSignal('voice_end', { reason: 'ended' }) + } catch (error) { + console.error('[VoiceCall] 发送结束信令失败:', error) + } + + cleanup() + callState.value = 'ended' + + // 延迟恢复到 idle 状态 + setTimeout(() => { + callState.value = 'idle' + }, 1000) + } + + /** + * 切换静音 + */ + function toggleMute() { + if (webrtcManager) { + isMuted.value = !isMuted.value + webrtcManager.setMuted(isMuted.value) + } + } + + // ==================== 信令处理 ==================== + + /** + * 处理接收到的信令消息 + */ + async function handleSignal(signalType: string, payload: any, fromUserId: number) { + console.log('[VoiceCall] 收到信令:', signalType) + + switch (signalType) { + case 'voice_start': + // 收到通话请求 + if (callState.value === 'idle') { + callState.value = 'ringing' + ElMessage.info('收到语音通话请求') + } + break + + case 'voice_offer': + // 收到 Offer,自动接听 + if (callState.value === 'ringing' || callState.value === 'idle') { + await answerCall({ + type: payload.type, + sdp: payload.sdp + }) + } + break + + case 'voice_answer': + // 收到 Answer + if (webrtcManager && callState.value === 'ringing') { + await webrtcManager.handleAnswer({ + type: payload.type, + sdp: payload.sdp + }) + } + break + + case 'ice_candidate': + // 收到 ICE 候选 + if (webrtcManager && payload.candidate) { + await webrtcManager.addIceCandidate(payload) + } + break + + case 'voice_end': + // 对方结束通话 + cleanup() + callState.value = 'ended' + ElMessage.info('通话已结束') + setTimeout(() => { + callState.value = 'idle' + }, 1000) + break + } + } + + // ==================== WebRTC 回调 ==================== + + function handleConnectionStateChange(state: ConnectionState) { + connectionState.value = state + + if (state === 'connected') { + callState.value = 'connected' + startCallTimer() + startAudioLevelMonitor() + startSpeechRecognition() + ElMessage.success('语音通话已连接') + } else if (state === 'failed' || state === 'disconnected') { + if (callState.value === 'connected') { + ElMessage.warning('通话连接断开') + } + } + } + + async function handleIceCandidate(candidate: RTCIceCandidate) { + try { + await sendSignal('ice_candidate', candidate.toJSON()) + } catch (error) { + console.error('[VoiceCall] 发送 ICE 候选失败:', error) + } + } + + function handleRemoteStream(stream: MediaStream) { + console.log('[VoiceCall] 收到远程音频流') + + // 播放远程音频 + const audio = new Audio() + audio.srcObject = stream + audio.play().catch(e => console.error('[VoiceCall] 播放远程音频失败:', e)) + } + + function handleError(error: Error) { + console.error('[VoiceCall] WebRTC 错误:', error) + errorMessage.value = error.message + } + + // ==================== 语音识别 ==================== + + function startSpeechRecognition() { + // 检查浏览器支持 + const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition + + if (!SpeechRecognition) { + console.warn('[VoiceCall] 浏览器不支持语音识别') + return + } + + recognition = new SpeechRecognition() + recognition.continuous = true + recognition.interimResults = true + recognition.lang = 'zh-CN' + + recognition.onstart = () => { + isTranscribing.value = true + console.log('[VoiceCall] 语音识别已启动') + } + + recognition.onresult = (event: any) => { + let interimTranscript = '' + let finalTranscript = '' + + for (let i = event.resultIndex; i < event.results.length; i++) { + const transcript = event.results[i][0].transcript + if (event.results[i].isFinal) { + finalTranscript += transcript + } else { + interimTranscript += transcript + } + } + + currentTranscript.value = interimTranscript || finalTranscript + + if (finalTranscript) { + onTranscript?.(finalTranscript, true) + } else if (interimTranscript) { + onTranscript?.(interimTranscript, false) + } + } + + recognition.onerror = (event: any) => { + console.error('[VoiceCall] 语音识别错误:', event.error) + if (event.error !== 'no-speech') { + isTranscribing.value = false + } + } + + recognition.onend = () => { + // 如果通话还在进行,重新启动识别 + if (callState.value === 'connected' && !isMuted.value) { + recognition.start() + } else { + isTranscribing.value = false + } + } + + try { + recognition.start() + } catch (error) { + console.error('[VoiceCall] 启动语音识别失败:', error) + } + } + + function stopSpeechRecognition() { + if (recognition) { + recognition.stop() + recognition = null + } + isTranscribing.value = false + } + + // ==================== 辅助功能 ==================== + + function startCallTimer() { + callDuration.value = 0 + callTimer = window.setInterval(() => { + callDuration.value++ + }, 1000) + } + + function stopCallTimer() { + if (callTimer) { + clearInterval(callTimer) + callTimer = null + } + } + + function startAudioLevelMonitor() { + audioLevelTimer = window.setInterval(async () => { + if (webrtcManager) { + const localStream = webrtcManager.getLocalStream() + const remoteStream = webrtcManager.getRemoteStream() + + if (localStream) { + localAudioLevel.value = await webrtcManager.getAudioLevel(localStream) + } + if (remoteStream) { + remoteAudioLevel.value = await webrtcManager.getAudioLevel(remoteStream) + } + } + }, 100) + } + + function stopAudioLevelMonitor() { + if (audioLevelTimer) { + clearInterval(audioLevelTimer) + audioLevelTimer = null + } + } + + function formatDuration(seconds: number): string { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + + // ==================== 清理 ==================== + + function cleanup() { + stopCallTimer() + stopAudioLevelMonitor() + stopSpeechRecognition() + + webrtcManager?.close() + webrtcManager = null + + isMuted.value = false + isRemoteMuted.value = false + localAudioLevel.value = 0 + remoteAudioLevel.value = 0 + currentTranscript.value = '' + } + + // 组件卸载时清理 + onUnmounted(() => { + if (isCallActive.value) { + endCall() + } + cleanup() + }) + + // ==================== 返回 ==================== + + return { + // 状态 + callState, + connectionState, + isMuted, + isRemoteMuted, + localAudioLevel, + remoteAudioLevel, + callDuration, + errorMessage, + isTranscribing, + currentTranscript, + + // 计算属性 + isCallActive, + canStartCall, + canEndCall, + + // 方法 + startCall, + answerCall, + rejectCall, + endCall, + toggleMute, + handleSignal, + formatDuration + } +} diff --git a/frontend/src/utils/webrtc.ts b/frontend/src/utils/webrtc.ts new file mode 100644 index 0000000..2e8fffa --- /dev/null +++ b/frontend/src/utils/webrtc.ts @@ -0,0 +1,324 @@ +/** + * WebRTC 连接管理模块 + * + * 功能: + * - 管理 RTCPeerConnection 生命周期 + * - 处理 SDP 交换 + * - 处理 ICE 候选收集 + * - 音频流管理 + */ + +export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'failed' + +export interface WebRTCConfig { + iceServers?: RTCIceServer[] + onLocalStream?: (stream: MediaStream) => void + onRemoteStream?: (stream: MediaStream) => void + onConnectionStateChange?: (state: ConnectionState) => void + onIceCandidate?: (candidate: RTCIceCandidate) => void + onError?: (error: Error) => void +} + +// 默认 ICE 服务器配置 +const DEFAULT_ICE_SERVERS: RTCIceServer[] = [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + { urls: 'stun:stun2.l.google.com:19302' } +] + +export class WebRTCManager { + private peerConnection: RTCPeerConnection | null = null + private localStream: MediaStream | null = null + private remoteStream: MediaStream | null = null + private config: WebRTCConfig + private connectionState: ConnectionState = 'idle' + private pendingIceCandidates: RTCIceCandidate[] = [] + + constructor(config: WebRTCConfig = {}) { + this.config = { + iceServers: DEFAULT_ICE_SERVERS, + ...config + } + } + + /** + * 获取当前连接状态 + */ + getConnectionState(): ConnectionState { + return this.connectionState + } + + /** + * 获取本地音频流 + */ + getLocalStream(): MediaStream | null { + return this.localStream + } + + /** + * 获取远程音频流 + */ + getRemoteStream(): MediaStream | null { + return this.remoteStream + } + + /** + * 初始化本地音频流 + */ + async initLocalStream(): Promise { + try { + this.localStream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + }, + video: false + }) + + this.config.onLocalStream?.(this.localStream) + return this.localStream + } catch (error) { + const err = error instanceof Error ? error : new Error('获取麦克风权限失败') + this.config.onError?.(err) + throw err + } + } + + /** + * 创建 PeerConnection + */ + private createPeerConnection(): RTCPeerConnection { + const pc = new RTCPeerConnection({ + iceServers: this.config.iceServers + }) + + // 监听 ICE 候选 + pc.onicecandidate = (event) => { + if (event.candidate) { + console.log('[WebRTC] ICE candidate:', event.candidate.candidate?.substring(0, 50)) + this.config.onIceCandidate?.(event.candidate) + } + } + + // 监听连接状态变化 + pc.onconnectionstatechange = () => { + console.log('[WebRTC] Connection state:', pc.connectionState) + this.updateConnectionState(pc.connectionState) + } + + // 监听 ICE 连接状态 + pc.oniceconnectionstatechange = () => { + console.log('[WebRTC] ICE connection state:', pc.iceConnectionState) + if (pc.iceConnectionState === 'failed') { + this.updateConnectionState('failed') + } + } + + // 监听远程流 + pc.ontrack = (event) => { + console.log('[WebRTC] Remote track received') + if (event.streams && event.streams[0]) { + this.remoteStream = event.streams[0] + this.config.onRemoteStream?.(this.remoteStream) + } + } + + return pc + } + + /** + * 更新连接状态 + */ + private updateConnectionState(state: RTCPeerConnectionState | string) { + const stateMap: Record = { + 'new': 'connecting', + 'connecting': 'connecting', + 'connected': 'connected', + 'disconnected': 'disconnected', + 'failed': 'failed', + 'closed': 'disconnected' + } + + this.connectionState = stateMap[state] || 'idle' + this.config.onConnectionStateChange?.(this.connectionState) + } + + /** + * 创建 Offer(发起方调用) + */ + async createOffer(): Promise { + if (!this.localStream) { + await this.initLocalStream() + } + + this.peerConnection = this.createPeerConnection() + this.updateConnectionState('connecting') + + // 添加本地音频轨道 + this.localStream!.getTracks().forEach(track => { + this.peerConnection!.addTrack(track, this.localStream!) + }) + + // 创建 Offer + const offer = await this.peerConnection.createOffer() + await this.peerConnection.setLocalDescription(offer) + + console.log('[WebRTC] Offer created') + return offer + } + + /** + * 处理 Offer(接收方调用) + */ + async handleOffer(offer: RTCSessionDescriptionInit): Promise { + if (!this.localStream) { + await this.initLocalStream() + } + + this.peerConnection = this.createPeerConnection() + this.updateConnectionState('connecting') + + // 添加本地音频轨道 + this.localStream!.getTracks().forEach(track => { + this.peerConnection!.addTrack(track, this.localStream!) + }) + + // 设置远程描述 + await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer)) + + // 处理等待中的 ICE 候选 + for (const candidate of this.pendingIceCandidates) { + await this.peerConnection.addIceCandidate(candidate) + } + this.pendingIceCandidates = [] + + // 创建 Answer + const answer = await this.peerConnection.createAnswer() + await this.peerConnection.setLocalDescription(answer) + + console.log('[WebRTC] Answer created') + return answer + } + + /** + * 处理 Answer(发起方调用) + */ + async handleAnswer(answer: RTCSessionDescriptionInit): Promise { + if (!this.peerConnection) { + throw new Error('PeerConnection not initialized') + } + + await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer)) + + // 处理等待中的 ICE 候选 + for (const candidate of this.pendingIceCandidates) { + await this.peerConnection.addIceCandidate(candidate) + } + this.pendingIceCandidates = [] + + console.log('[WebRTC] Answer handled') + } + + /** + * 添加 ICE 候选 + */ + async addIceCandidate(candidate: RTCIceCandidateInit): Promise { + const iceCandidate = new RTCIceCandidate(candidate) + + if (this.peerConnection && this.peerConnection.remoteDescription) { + await this.peerConnection.addIceCandidate(iceCandidate) + console.log('[WebRTC] ICE candidate added') + } else { + // 如果远程描述还没设置,先缓存候选 + this.pendingIceCandidates.push(iceCandidate) + console.log('[WebRTC] ICE candidate queued') + } + } + + /** + * 静音/取消静音本地音频 + */ + setMuted(muted: boolean): void { + if (this.localStream) { + this.localStream.getAudioTracks().forEach(track => { + track.enabled = !muted + }) + } + } + + /** + * 检查是否静音 + */ + isMuted(): boolean { + if (this.localStream) { + const audioTrack = this.localStream.getAudioTracks()[0] + return audioTrack ? !audioTrack.enabled : true + } + return true + } + + /** + * 获取音频音量级别(用于音量指示器) + */ + async getAudioLevel(stream: MediaStream): Promise { + return new Promise((resolve) => { + const audioContext = new AudioContext() + const analyser = audioContext.createAnalyser() + const source = audioContext.createMediaStreamSource(stream) + + source.connect(analyser) + analyser.fftSize = 256 + + const dataArray = new Uint8Array(analyser.frequencyBinCount) + analyser.getByteFrequencyData(dataArray) + + // 计算平均音量 + const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length + + audioContext.close() + resolve(average / 255) // 归一化到 0-1 + }) + } + + /** + * 关闭连接 + */ + close(): void { + console.log('[WebRTC] Closing connection') + + // 停止本地流 + if (this.localStream) { + this.localStream.getTracks().forEach(track => track.stop()) + this.localStream = null + } + + // 停止远程流 + if (this.remoteStream) { + this.remoteStream.getTracks().forEach(track => track.stop()) + this.remoteStream = null + } + + // 关闭 PeerConnection + if (this.peerConnection) { + this.peerConnection.close() + this.peerConnection = null + } + + this.pendingIceCandidates = [] + this.updateConnectionState('disconnected') + } + + /** + * 重置管理器 + */ + reset(): void { + this.close() + this.connectionState = 'idle' + } +} + +// 导出单例工厂函数 +export function createWebRTCManager(config?: WebRTCConfig): WebRTCManager { + return new WebRTCManager(config) +} diff --git a/frontend/src/views/trainee/duo-practice-room.vue b/frontend/src/views/trainee/duo-practice-room.vue index 3cf152a..66c4141 100644 --- a/frontend/src/views/trainee/duo-practice-room.vue +++ b/frontend/src/views/trainee/duo-practice-room.vue @@ -18,6 +18,16 @@
+ +
+ + + + + {{ voiceCall.formatDuration(voiceCall.callDuration.value) }} + + {{ voiceCallStatusText }} +
{{ statusText }}
@@ -81,6 +91,100 @@ + +
+

语音通话

+ + +
+ + + 发起语音 + +

点击发起语音通话,实时对话

+
+ + +
+
+ +
+

正在呼叫对方...

+ 取消 +
+ + +
+
+ +
+

对方发起语音通话

+
+ + + + + + +
+
+ + +
+ +

正在连接...

+
+ + +
+
+
{{ voiceCall.formatDuration(voiceCall.callDuration.value) }}
+
+
+ +
+
+
+
+
+ 对方 +
+
+
+
+
+
+ + +
+ + {{ voiceCall.currentTranscript.value }} +
+ +
+ + + + + + + + + +
+
+
+