| <template> |
| <div class="live-mode-container"> |
| <div class="header-controls"> |
| <v-btn icon="mdi-close" @click="handleClose" flat variant="text" /> |
| <v-btn :icon="isCodeMode ? 'mdi-code-tags-check' : 'mdi-code-tags'" @click="toggleCodeMode" flat |
| variant="text" :color="isCodeMode ? 'primary' : ''" /> |
| <v-btn :icon="isNervousMode ? 'mdi-emoticon-confused' : 'mdi-emoticon-confused-outline'" |
| @click="toggleNervousMode" flat variant="text" :color="isNervousMode ? 'primary' : ''" /> |
| </div> |
| |
| <span style="color: gray; padding-left: 16px;">We're developing Astr Live Mode on ChatUI & Desktop right now. Stay tuned!</span> |
| |
| <div class="live-mode-content"> |
| <div class="center-circle-container" @click="handleCircleClick"> |
| |
| <div v-if="isExploding" class="explosion-wave"></div> |
| |
| <SiriOrb :energy="orbEnergy" :mode="isActive ? orbMode : 'idle'" :is-dark="isDark" |
| :code-mode="isCodeMode" :nervous-mode="isNervousMode" class="siri-orb" /> |
| </div> |
| <div class="status-text"> |
| {{ statusText }} |
| </div> |
| <div class="messages-container" v-if="messages.length > 0"> |
| <div v-for="(msg, index) in messages" :key="index" class="message-item" :class="msg.type"> |
| <div class="message-content"> |
| {{ msg.text }} |
| </div> |
| </div> |
| </div> |
| |
| <div class="metrics-container" v-if="Object.keys(metrics).length > 0"> |
| <span v-if="metrics.wav_assemble_time">WAV Assemble: {{ (metrics.wav_assemble_time * 1000).toFixed(0) |
| }}ms</span> |
| <span v-if="metrics.llm_ttft">LLM First Token Latency: {{ (metrics.llm_ttft * 1000).toFixed(0) |
| }}ms</span> |
| <span v-if="metrics.llm_total_time">LLM Total Latency: {{ (metrics.llm_total_time * 1000).toFixed(0) |
| }}ms</span> |
| <span v-if="metrics.tts_first_frame_time">TTS First Frame Latency: {{ (metrics.tts_first_frame_time * |
| 1000).toFixed(0) }}ms</span> |
| <span v-if="metrics.tts_total_time">TTS Total Larency: {{ (metrics.tts_total_time * 1000).toFixed(0) |
| }}ms</span> |
| <span v-if="metrics.speak_to_first_frame">Speak -> First TTS Frame: {{ (metrics.speak_to_first_frame * |
| 1000).toFixed(0) }}ms</span> |
| <span v-if="metrics.wav_to_tts_total_time">Speak -> End: {{ (metrics.wav_to_tts_total_time * |
| 1000).toFixed(0) }}ms</span> |
| <span v-if="metrics.stt">STT Provider: {{ metrics.stt }}</span> |
| <span v-if="metrics.tts">TTS Provider: {{ metrics.tts }}</span> |
| <span v-if="metrics.chat_model">Chat Model: {{ metrics.chat_model }}</span> |
| </div> |
| </div> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, computed, onBeforeUnmount, watch } from 'vue'; |
| import { useTheme } from 'vuetify'; |
| import { useVADRecording } from '@/composables/useVADRecording'; |
| import SiriOrb from './LiveOrb.vue'; |
| |
| const emit = defineEmits<{ |
| 'close': []; |
| }>(); |
| |
| const theme = useTheme(); |
| const isDark = computed(() => theme.global.current.value.dark); |
| |
| |
| const vadRecording = useVADRecording(); |
| |
| |
| const isActive = ref(false); |
| const isExploding = ref(false); |
| const isCodeMode = ref(false); |
| const isNervousMode = ref(false); |
| |
| const isSpeaking = computed(() => vadRecording.isSpeaking.value); |
| const isListening = ref(false); |
| const isProcessing = ref(false); |
| |
| |
| let ws: WebSocket | null = null; |
| |
| |
| let audioContext: AudioContext | null = null; |
| let analyser: AnalyserNode | null = null; |
| const botEnergy = ref(0); |
| let energyLoopId: number; |
| let isPlaying = ref(false); |
| |
| |
| const rawAudioQueue: Uint8Array[] = []; |
| const audioBufferQueue: AudioBuffer[] = []; |
| let isDecoding = false; |
| let isPlayingAudio = false; |
| let currentSource: AudioBufferSourceNode | null = null; |
| |
| |
| |
| const messages = ref<Array<{ type: 'user' | 'bot', text: string }>>([]); |
| |
| interface LiveMetrics { |
| wav_assemble_time?: number; |
| speak_to_first_frame?: number; |
| llm_ttft?: number; |
| llm_total_time?: number; |
| tts_first_frame_time?: number; |
| tts_total_time?: number; |
| wav_to_tts_total_time?: number; |
| stt?: string; |
| tts?: string; |
| chat_model?: string; |
| } |
| const metrics = ref<LiveMetrics>({}); |
| |
| |
| let currentStamp = ''; |
| |
| const statusText = computed(() => { |
| if (!isActive.value) return 'Astr Live'; |
| if (isProcessing.value) return '正在处理...'; |
| if (isSpeaking.value) return '正在说话...'; |
| if (isListening.value) return '正在听...'; |
| return '准备就绪'; |
| }); |
| |
| const getIcon = computed(() => { |
| if (!isActive.value) return 'mdi-microphone'; |
| if (isSpeaking.value) return 'mdi-account-voice'; |
| if (isProcessing.value) return 'mdi-loading'; |
| return 'mdi-check'; |
| }); |
| |
| const getIconColor = computed(() => { |
| if (!isActive.value) return isDark.value ? 'white' : 'black'; |
| if (isSpeaking.value) return 'success'; |
| if (isProcessing.value) return 'warning'; |
| return 'primary'; |
| }); |
| |
| const orbEnergy = computed(() => { |
| if (isPlaying.value) return botEnergy.value; |
| if (isSpeaking.value || isListening.value) return vadRecording.audioEnergy.value; |
| return 0; |
| }); |
| |
| const orbMode = computed(() => { |
| if (isProcessing.value) return 'processing'; |
| if (isPlaying.value) return 'speaking'; |
| if (isSpeaking.value || isListening.value) return 'listening'; |
| return 'idle'; |
| }); |
| |
| async function handleCircleClick() { |
| if (!isActive.value) { |
| |
| isExploding.value = true; |
| setTimeout(() => { |
| isExploding.value = false; |
| }, 1000); |
| |
| await startLiveMode(); |
| } else { |
| await stopLiveMode(); |
| } |
| } |
| |
| async function startLiveMode() { |
| try { |
| |
| await connectWebSocket(); |
| |
| |
| audioContext = new AudioContext({ sampleRate: 16000 }); |
| analyser = audioContext.createAnalyser(); |
| analyser.fftSize = 256; |
| analyser.smoothingTimeConstant = 0.5; |
| |
| |
| updateBotEnergy(); |
| |
| |
| await vadRecording.startRecording( |
| |
| () => { |
| console.log('[Live Mode] VAD 检测到开始说话'); |
| isListening.value = false; |
| currentStamp = generateStamp(); |
| |
| |
| if (ws && ws.readyState === WebSocket.OPEN) { |
| metrics.value = {}; |
| ws.send(JSON.stringify({ |
| t: 'start_speaking', |
| stamp: currentStamp |
| })); |
| } |
| }, |
| |
| (audio: Float32Array) => { |
| console.log('[Live Mode] VAD 检测到语音结束,音频长度:', audio.length); |
| |
| |
| if (ws && ws.readyState === WebSocket.OPEN) { |
| const pcm16 = new Int16Array(audio.length); |
| for (let i = 0; i < audio.length; i++) { |
| const s = Math.max(-1, Math.min(1, audio[i])); |
| pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; |
| } |
| |
| |
| const uint8 = new Uint8Array(pcm16.buffer); |
| let base64 = ''; |
| const chunkSize = 0x8000; |
| for (let i = 0; i < uint8.length; i += chunkSize) { |
| const chunk = uint8.subarray(i, Math.min(i + chunkSize, uint8.length)); |
| base64 += String.fromCharCode.apply(null, Array.from(chunk)); |
| } |
| base64 = btoa(base64); |
| |
| |
| ws.send(JSON.stringify({ |
| t: 'speaking_part', |
| data: base64 |
| })); |
| |
| |
| ws.send(JSON.stringify({ |
| t: 'end_speaking', |
| stamp: currentStamp |
| })); |
| |
| isProcessing.value = true; |
| } |
| } |
| ); |
| |
| isActive.value = true; |
| isListening.value = true; |
| |
| } catch (error) { |
| console.error('启动 Live Mode 失败:', error); |
| alert('启动失败,请检查麦克风权限或网络连接'); |
| await stopLiveMode(); |
| } |
| } |
| |
| async function stopLiveMode() { |
| cancelAnimationFrame(energyLoopId); |
| |
| |
| vadRecording.stopRecording(); |
| |
| |
| stopAudioPlayback(); |
| |
| |
| if (audioContext) { |
| await audioContext.close(); |
| audioContext = null; |
| } |
| |
| |
| if (ws) { |
| ws.close(); |
| ws = null; |
| } |
| |
| isActive.value = false; |
| isListening.value = false; |
| isProcessing.value = false; |
| } |
| |
| function connectWebSocket(): Promise<void> { |
| return new Promise((resolve, reject) => { |
| |
| const token = localStorage.getItem('token'); |
| if (!token) { |
| reject(new Error('未登录,请先登录')); |
| return; |
| } |
| |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| const wsUrl = `${protocol}//localhost:7860/api/live_chat/ws?token=${encodeURIComponent(token)}`; |
| |
| ws = new WebSocket(wsUrl); |
| |
| ws.onopen = () => { |
| console.log('[Live Mode] WebSocket 连接成功'); |
| resolve(); |
| }; |
| |
| ws.onerror = (error) => { |
| console.error('[Live Mode] WebSocket 错误:', error); |
| reject(error); |
| }; |
| |
| ws.onmessage = handleWebSocketMessage; |
| |
| ws.onclose = () => { |
| console.log('[Live Mode] WebSocket 连接关闭'); |
| }; |
| |
| |
| setTimeout(() => { |
| if (ws?.readyState !== WebSocket.OPEN) { |
| reject(new Error('WebSocket 连接超时')); |
| } |
| }, 5000); |
| }); |
| } |
| |
| |
| |
| function handleWebSocketMessage(event: MessageEvent) { |
| try { |
| const message = JSON.parse(event.data); |
| const msgType = message.t; |
| |
| switch (msgType) { |
| case 'user_msg': |
| messages.value.push({ |
| type: 'user', |
| text: message.data.text |
| }); |
| break; |
| |
| case 'bot_text_chunk': |
| messages.value.push({ |
| type: 'bot', |
| text: message.data.text |
| }); |
| break; |
| |
| case 'bot_msg': |
| messages.value.push({ |
| type: 'bot', |
| text: message.data.text |
| }); |
| isProcessing.value = false; |
| isListening.value = true; |
| break; |
| |
| case 'response': |
| |
| playAudioChunk(message.data); |
| break; |
| |
| case 'stop_play': |
| |
| stopAudioPlayback(); |
| break; |
| |
| case 'end': |
| |
| isProcessing.value = false; |
| isListening.value = true; |
| break; |
| |
| case 'error': |
| console.error('[Live Mode] 错误:', message.data); |
| alert('处理出错: ' + message.data); |
| isProcessing.value = false; |
| isListening.value = true; |
| break; |
| |
| case 'metrics': |
| metrics.value = { ...metrics.value, ...message.data }; |
| break; |
| } |
| } catch (error) { |
| console.error('[Live Mode] 处理消息失败:', error); |
| } |
| } |
| |
| function playAudioChunk(base64Data: string) { |
| if (!audioContext) return; |
| |
| try { |
| |
| const binaryString = atob(base64Data); |
| const bytes = new Uint8Array(binaryString.length); |
| for (let i = 0; i < binaryString.length; i++) { |
| bytes[i] = binaryString.charCodeAt(i); |
| } |
| |
| |
| rawAudioQueue.push(bytes); |
| |
| |
| processRawAudioQueue(); |
| |
| } catch (error) { |
| console.error('[Live Mode] 接收音频数据失败:', error); |
| } |
| } |
| |
| async function processRawAudioQueue() { |
| if (isDecoding || rawAudioQueue.length === 0) return; |
| |
| isDecoding = true; |
| |
| try { |
| while (rawAudioQueue.length > 0) { |
| const bytes = rawAudioQueue.shift(); |
| if (!bytes || !audioContext) continue; |
| |
| try { |
| |
| const audioBuffer = await audioContext.decodeAudioData(bytes.buffer as ArrayBuffer); |
| audioBufferQueue.push(audioBuffer); |
| |
| |
| if (!isPlayingAudio) { |
| playNextAudio(); |
| } |
| } catch (err) { |
| console.error('[Live Mode] 解码音频失败:', err); |
| } |
| } |
| } finally { |
| isDecoding = false; |
| |
| if (rawAudioQueue.length > 0) { |
| processRawAudioQueue(); |
| } |
| } |
| } |
| |
| function playNextAudio() { |
| if (audioBufferQueue.length === 0) { |
| isPlayingAudio = false; |
| isPlaying.value = false; |
| return; |
| } |
| |
| if (!audioContext) return; |
| |
| isPlayingAudio = true; |
| isPlaying.value = true; |
| |
| try { |
| const audioBuffer = audioBufferQueue.shift(); |
| if (!audioBuffer) return; |
| |
| const source = audioContext.createBufferSource(); |
| source.buffer = audioBuffer; |
| |
| |
| if (analyser) { |
| source.connect(analyser); |
| analyser.connect(audioContext.destination); |
| } else { |
| source.connect(audioContext.destination); |
| } |
| |
| currentSource = source; |
| source.start(); |
| |
| source.onended = () => { |
| currentSource = null; |
| playNextAudio(); |
| }; |
| |
| } catch (error) { |
| console.error('[Live Mode] 播放音频失败:', error); |
| isPlayingAudio = false; |
| isPlaying.value = false; |
| playNextAudio(); |
| } |
| } |
| |
| function stopAudioPlayback() { |
| |
| if (currentSource) { |
| try { |
| currentSource.stop(); |
| currentSource.disconnect(); |
| } catch (e) { |
| |
| } |
| currentSource = null; |
| } |
| |
| |
| rawAudioQueue.length = 0; |
| audioBufferQueue.length = 0; |
| |
| |
| isPlayingAudio = false; |
| isPlaying.value = false; |
| isDecoding = false; |
| } |
| |
| function generateStamp(): string { |
| return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; |
| } |
| |
| function updateBotEnergy() { |
| if (analyser && isPlaying.value) { |
| const dataArray = new Uint8Array(analyser.frequencyBinCount); |
| analyser.getByteFrequencyData(dataArray); |
| |
| let sum = 0; |
| |
| const range = Math.floor(dataArray.length * 0.7); |
| for (let i = 0; i < range; i++) { |
| sum += dataArray[i]; |
| } |
| const average = sum / range; |
| |
| botEnergy.value = Math.min(1, (average / 255) * 2.0); |
| } else { |
| botEnergy.value = Math.max(0, botEnergy.value - 0.1); |
| } |
| |
| if (isActive.value) { |
| energyLoopId = requestAnimationFrame(updateBotEnergy); |
| } |
| } |
| |
| function handleClose() { |
| stopLiveMode(); |
| emit('close'); |
| } |
| |
| function toggleCodeMode() { |
| isCodeMode.value = !isCodeMode.value; |
| } |
| |
| function toggleNervousMode() { |
| isNervousMode.value = !isNervousMode.value; |
| } |
| |
| |
| watch(isSpeaking, (newVal) => { |
| if (newVal && isPlaying.value) { |
| |
| if (ws && ws.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify({ t: 'interrupt' })); |
| } |
| |
| stopAudioPlayback(); |
| } |
| }); |
| |
| onBeforeUnmount(() => { |
| stopLiveMode(); |
| }); |
| </script> |
| |
| <style scoped> |
| .live-mode-container { |
| display: flex; |
| flex-direction: column; |
| height: 100%; |
| width: 100%; |
| background: linear-gradient(135deg, rgba(103, 58, 183, 0.05) 0%, rgba(63, 81, 181, 0.05) 100%); |
| } |
| |
| .header-controls { |
| display: flex; |
| padding: 8px; |
| gap: 8px; |
| } |
| |
| .live-mode-content { |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| position: relative; |
| padding: 40px; |
| } |
| |
| .center-circle-container { |
| position: relative; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| margin-bottom: 40px; |
| cursor: pointer; |
| |
| min-width: 250px; |
| min-height: 250px; |
| } |
| |
| .siri-orb { |
| |
| z-index: 10; |
| position: relative; |
| } |
| |
| .orb-overlay { |
| position: absolute; |
| |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| z-index: 20; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| pointer-events: none; |
| width: 100%; |
| height: 100%; |
| } |
| |
| .explosion-wave { |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| width: 150px; |
| height: 150px; |
| border-radius: 50%; |
| opacity: 0.8; |
| background: radial-gradient(circle, transparent 50%, rgba(125, 80, 201, 0.8) 70%, transparent 100%); |
| animation: explode 3s cubic-bezier(0.16, 1, 0.3, 1) forwards; |
| filter: blur(30px); |
| z-index: 0; |
| pointer-events: none; |
| } |
| |
| @keyframes explode { |
| 0% { |
| transform: translate(-50%, -50%) scale(1); |
| opacity: 0.8; |
| } |
| |
| 100% { |
| transform: translate(-50%, -50%) scale(50); |
| opacity: 0; |
| } |
| } |
| |
| .status-text { |
| font-size: 24px; |
| color: var(--v-theme-on-surface); |
| margin-bottom: 40px; |
| font-family: 'Outfit', sans-serif; |
| } |
| |
| .messages-container { |
| position: absolute; |
| bottom: 40px; |
| left: 40px; |
| right: 40px; |
| max-height: 300px; |
| overflow-y: auto; |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| } |
| |
| .message-item { |
| color: rgb(var(--v-theme-on-surface)); |
| display: flex; |
| align-items: flex-end; |
| align-self: flex-end; |
| gap: 12px; |
| } |
| |
| .message-content { |
| flex: 1; |
| word-wrap: break-word; |
| } |
| |
| .metrics-container { |
| position: absolute; |
| bottom: 10px; |
| left: 10px; |
| display: flex; |
| flex-direction: column; |
| gap: 4px; |
| font-size: 12px; |
| color: rgba(var(--v-theme-on-surface), 0.6); |
| z-index: 100; |
| } |
| </style> |
| |