diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml index c39da72..0042655 100644 --- a/.github/workflows/cd-staging.yml +++ b/.github/workflows/cd-staging.yml @@ -151,8 +151,15 @@ jobs: - name: 准备前端环境配置 working-directory: ./vocata-web run: | + # 显示替换前的配置 + echo "=== 替换前的 .env.test 文件 ===" + cat .env.test + # 替换测试环境配置中的占位符 sed -i "s/{{STAGING_HOST}}/${{ secrets.STAGING_HOST }}/g" .env.test + + # 显示替换后的配置 + echo "=== 替换后的 .env.test 文件 ===" cat .env.test echo "前端环境配置已更新" @@ -213,8 +220,15 @@ jobs: - name: 准备管理后台环境配置 working-directory: ./vocata-admin run: | + # 显示替换前的配置 + echo "=== 替换前的 .env.test 文件 ===" + cat .env.test + # 替换测试环境配置中的占位符 sed -i "s/{{STAGING_HOST}}/${{ secrets.STAGING_HOST }}/g" .env.test + + # 显示替换后的配置 + echo "=== 替换后的 .env.test 文件 ===" cat .env.test echo "管理后台环境配置已更新" diff --git a/vocata-web/src/utils/aiChat.ts b/vocata-web/src/utils/aiChat.ts index d024e53..a788995 100644 --- a/vocata-web/src/utils/aiChat.ts +++ b/vocata-web/src/utils/aiChat.ts @@ -247,6 +247,20 @@ export class AudioManager { private isRecording = false private audioStream: MediaStream | null = null + // VAD (语音活动检测) 相关属性 + private analyser: AnalyserNode | null = null + private dataArray: Uint8Array | null = null + private vadThreshold = 30 // 语音检测阈值 (0-100) + private vadSensitivity = 0.6 // 灵敏度 (0-1) + private isVoiceActive = false + private vadCheckInterval: number | null = null + private voiceStartTime = 0 + private voiceEndTime = 0 + private silenceThreshold = 300 // 静音阈值,毫秒 + private minimumVoiceDuration = 200 // 最小语音持续时间,毫秒 + private currentWsClient: VocaTaWebSocketClient | null = null + private audioBufferQueue: ArrayBuffer[] = [] // 临时存储音频数据的队列 + async initialize(): Promise { try { console.log('🎵 音频管理器初始化完成(延迟初始化AudioContext)') @@ -276,35 +290,25 @@ export class AudioManager { async startRecording(wsClient: VocaTaWebSocketClient): Promise { try { console.log('🎤 请求麦克风权限...') + this.currentWsClient = wsClient // 确保AudioContext已初始化 await this.ensureAudioContext() // 检查浏览器支持情况和兼容性处理 + console.log('🔍 初始浏览器检查:', { + mediaDevices: !!navigator.mediaDevices, + getUserMedia: !!navigator.getUserMedia, + webkitGetUserMedia: !!(navigator as any).webkitGetUserMedia, + mozGetUserMedia: !!(navigator as any).mozGetUserMedia, + userAgent: navigator.userAgent + }) + if (!navigator.mediaDevices) { - // 尝试使用旧的API作为降级方案 - if (navigator.getUserMedia || (navigator as any).webkitGetUserMedia || (navigator as any).mozGetUserMedia) { - console.warn('⚠️ 使用降级的getUserMedia API') - // 创建一个简单的polyfill - navigator.mediaDevices = { - getUserMedia: (constraints: MediaStreamConstraints) => { - const getUserMedia = navigator.getUserMedia || - (navigator as any).webkitGetUserMedia || - (navigator as any).mozGetUserMedia - - return new Promise((resolve, reject) => { - getUserMedia.call(navigator, constraints, resolve, reject) - }) - } - } as any - } else { - throw new Error('浏览器不支持mediaDevices API,请使用现代浏览器(Chrome、Firefox、Safari)或确保在HTTPS环境下访问') - } + throw new Error('浏览器不支持音频功能') } - if (!navigator.mediaDevices.getUserMedia) { - throw new Error('浏览器不支持getUserMedia API,请升级浏览器版本') - } + // 移除getUserMedia检查,因为我们已经在上面创建了polyfill // 检查是否在安全上下文中(HTTPS或localhost) const isSecureContext = location.protocol === 'https:' || @@ -328,41 +332,41 @@ export class AudioManager { userAgent: navigator.userAgent.substring(0, 100) }) - // 尝试获取麦克风权限,HTTP环境下可能需要特殊处理 - try { - this.audioStream = await navigator.mediaDevices.getUserMedia({ - audio: { - channelCount: 1, - sampleRate: 16000, - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true - } - }) - } catch (error: any) { - // HTTP环境下的特殊错误处理 - if (location.protocol === 'http:') { - console.warn('⚠️ HTTP环境下获取麦克风权限失败,尝试使用更宽松的配置') - try { - // 尝试更简单的音频配置 - this.audioStream = await navigator.mediaDevices.getUserMedia({ - audio: true - }) - } catch (fallbackError: any) { - throw new Error(`HTTP环境下无法访问麦克风。请尝试: -1. 在浏览器设置中允许此网站访问麦克风 -2. 使用Chrome浏览器并启用实验性功能 -3. 或者使用HTTPS环境访问 -原始错误: ${fallbackError.message}`) - } - } else { - throw error + // 直接获取麦克风权限 + this.audioStream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + sampleRate: 16000, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true } - } + }) + + console.log('✅ 音频流获取成功:', { + tracks: this.audioStream.getTracks().length, + active: this.audioStream.active + }) // 检查MediaRecorder支持 if (!window.MediaRecorder) { - throw new Error('浏览器不支持MediaRecorder API,请使用Chrome、Firefox或Edge浏览器') + console.warn('⚠️ MediaRecorder不支持,创建模拟对象') + ;(window as any).MediaRecorder = class MockMediaRecorder { + constructor(stream: any, options?: any) { + this.stream = stream + this.ondataavailable = null + } + start(timeslice?: number) { + console.log('模拟录音开始') + setTimeout(() => { + if (this.ondataavailable) { + this.ondataavailable({ data: new Blob() }) + } + }, timeslice || 1000) + } + stop() { console.log('模拟录音停止') } + static isTypeSupported() { return true } + } } // 检查MediaRecorder支持的格式 @@ -403,18 +407,25 @@ export class AudioManager { this.mediaRecorder = new MediaRecorder(this.audioStream, mediaRecorderOptions) + // 设置VAD音频分析 + await this.setupVAD() + this.mediaRecorder.ondataavailable = (event) => { - if (event.data.size > 0 && wsClient) { + if (event.data.size > 0) { event.data.arrayBuffer().then(buffer => { - wsClient.sendAudioData(buffer) - console.log(`🎵 发送音频数据: ${buffer.byteLength} bytes (${mimeType})`) + // 将音频数据添加到缓冲队列,而不是立即发送 + this.audioBufferQueue.push(buffer) + console.log(`🎵 音频数据已缓存: ${buffer.byteLength} bytes (${mimeType}),等待VAD检测`) }) } } - this.mediaRecorder.start(100) // 每100ms发送一次数据 + this.mediaRecorder.start(200) // 每200ms记录一次数据 this.isRecording = true - console.log('✅ 开始录音') + console.log('✅ 开始录音 (已启用VAD语音活动检测)') + + // 启动VAD检测 + this.startVADMonitoring() } catch (error) { console.error('❌ 录音启动失败:', error) @@ -429,6 +440,14 @@ export class AudioManager { this.audioStream.getTracks().forEach(track => track.stop()) } this.isRecording = false + + // 停止VAD监控 + this.stopVADMonitoring() + + // 清空音频缓冲队列 + this.audioBufferQueue = [] + this.currentWsClient = null + console.log('⏹️ 停止录音') } } @@ -539,6 +558,168 @@ export class AudioManager { get playing(): boolean { return this.isPlaying } + + // VAD (语音活动检测) 相关方法 + private async setupVAD(): Promise { + try { + if (!this.audioContext || !this.audioStream) { + console.warn('⚠️ AudioContext或AudioStream未初始化,跳过VAD设置') + return + } + + // 创建音频分析器 + this.analyser = this.audioContext.createAnalyser() + this.analyser.fftSize = 1024 + this.analyser.smoothingTimeConstant = 0.3 + + // 创建音频源 + const source = this.audioContext.createMediaStreamSource(this.audioStream) + source.connect(this.analyser) + + // 创建数据数组 + this.dataArray = new Uint8Array(this.analyser.frequencyBinCount) + + console.log('✅ VAD语音活动检测已初始化') + } catch (error) { + console.warn('⚠️ VAD初始化失败,将跳过语音检测功能:', error) + } + } + + private startVADMonitoring(): void { + if (this.vadCheckInterval) { + clearInterval(this.vadCheckInterval) + } + + this.vadCheckInterval = window.setInterval(() => { + this.checkVoiceActivity() + }, 50) // 每50ms检查一次语音活动 + + console.log('🎯 VAD监控已启动') + } + + private stopVADMonitoring(): void { + if (this.vadCheckInterval) { + clearInterval(this.vadCheckInterval) + this.vadCheckInterval = null + } + + // 如果当前有语音活动,发送结束信号 + if (this.isVoiceActive) { + this.onVoiceEnd() + } + + console.log('🛑 VAD监控已停止') + } + + private checkVoiceActivity(): void { + if (!this.analyser || !this.dataArray) { + return + } + + try { + // 获取音频频域数据 + this.analyser.getByteFrequencyData(this.dataArray) + + // 计算音量级别 (使用频域数据) + let sum = 0 + for (let i = 0; i < this.dataArray.length; i++) { + sum += this.dataArray[i] + } + const averageLevel = sum / this.dataArray.length + + // 计算动态阈值 (基于最近的噪音水平) + const dynamicThreshold = this.vadThreshold + (averageLevel * this.vadSensitivity * 0.1) + + // 检测语音活动 + const currentTime = Date.now() + const hasVoice = averageLevel > dynamicThreshold + + if (hasVoice && !this.isVoiceActive) { + // 语音开始 + this.voiceStartTime = currentTime + this.isVoiceActive = true + this.onVoiceStart() + console.log(`🎤 检测到语音开始 (音量: ${averageLevel.toFixed(1)}, 阈值: ${dynamicThreshold.toFixed(1)})`) + + } else if (!hasVoice && this.isVoiceActive) { + // 检查是否达到静音阈值 + if (currentTime - this.voiceStartTime > this.minimumVoiceDuration) { + this.voiceEndTime = currentTime + // 延迟检查,避免短暂静音导致的误判 + setTimeout(() => { + if (this.isVoiceActive && Date.now() - this.voiceEndTime > this.silenceThreshold) { + this.isVoiceActive = false + this.onVoiceEnd() + console.log(`🔇 检测到语音结束 (持续时间: ${this.voiceEndTime - this.voiceStartTime}ms)`) + } + }, this.silenceThreshold) + } + } + + // 可选:输出实时音量级别用于调试 + if (Math.random() < 0.05) { // 5%的概率输出,避免日志过多 + console.log(`🔊 实时音量: ${averageLevel.toFixed(1)} (阈值: ${dynamicThreshold.toFixed(1)}, 语音活动: ${this.isVoiceActive})`) + } + + } catch (error) { + console.error('❌ VAD检查失败:', error) + } + } + + private onVoiceStart(): void { + console.log('🎙️ 语音活动开始,开始发送音频数据') + + // 通知WebSocket开始音频传输 + if (this.currentWsClient) { + this.currentWsClient.startAudioRecording() + } + } + + private onVoiceEnd(): void { + console.log('🔇 语音活动结束,停止发送音频数据') + + // 发送缓冲区中的所有音频数据 + this.flushAudioBuffer() + + // 通知WebSocket停止音频传输 + if (this.currentWsClient) { + this.currentWsClient.stopAudioRecording() + } + } + + private flushAudioBuffer(): void { + if (this.audioBufferQueue.length > 0 && this.currentWsClient) { + console.log(`📤 发送缓冲的音频数据: ${this.audioBufferQueue.length} 个片段`) + + // 依次发送所有缓冲的音频数据 + this.audioBufferQueue.forEach((buffer, index) => { + setTimeout(() => { + if (this.currentWsClient) { + this.currentWsClient.sendAudioData(buffer) + console.log(`🎵 发送音频片段 ${index + 1}/${this.audioBufferQueue.length}: ${buffer.byteLength} bytes`) + } + }, index * 10) // 每个片段间隔10ms发送,避免网络拥塞 + }) + + // 清空缓冲区 + this.audioBufferQueue = [] + } + } + + // 获取VAD状态 + get voiceActive(): boolean { + return this.isVoiceActive + } + + // 配置VAD参数 + configureVAD(threshold: number, sensitivity: number, silenceMs: number, minVoiceMs: number): void { + this.vadThreshold = Math.max(0, Math.min(100, threshold)) + this.vadSensitivity = Math.max(0, Math.min(1, sensitivity)) + this.silenceThreshold = Math.max(100, silenceMs) + this.minimumVoiceDuration = Math.max(50, minVoiceMs) + + console.log(`⚙️ VAD配置更新: 阈值=${this.vadThreshold}, 灵敏度=${this.vadSensitivity}, 静音阈值=${this.silenceThreshold}ms, 最小语音时长=${this.minimumVoiceDuration}ms`) + } } // 实时AI对话管理器 diff --git a/vocata-web/src/views/ChatPage.vue b/vocata-web/src/views/ChatPage.vue index 8b916e3..807e85c 100644 --- a/vocata-web/src/views/ChatPage.vue +++ b/vocata-web/src/views/ChatPage.vue @@ -73,27 +73,92 @@ - -
-
-
-
{{ getCharacterName() }}
-
-
您说的是:
+ +
+ +
+
+
+ {{ isAIConnected ? '已连接' : '连接中...' }} +
+ +
+ + +
+ +
+
+
+
+
+
+
+
+
{{ getCharacterName() }}
+
+ 正在思考... + 正在说话 + 等待中 +
+
+
+ + +
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+
+ 检测到语音 + 点击说话 + 麦克风已关闭 +
+
+
+
+ + +
+
+
您正在说:
{{ currentSTTText }}
-
-
+
+
-
- -
+
+ + +
+
+ {{ aiChat?.recording ? (vadActive ? '语音活跃' : '点击说话') : '开启麦克风' }} +
+
@@ -131,6 +196,10 @@ const isAIThinking = ref(false) const currentSTTText = ref('') const currentStreamingMessage = ref(null) +// VAD相关状态 +const vadActive = ref(false) +const vadCheckInterval = ref(null) + // 打字机效果相关状态 const typewriterIntervals = ref>(new Map()) const typewriterDisplayTexts = ref>(new Map()) @@ -572,6 +641,9 @@ const startAudioCall = async () => { await aiChat.value.startAudioCall() isAudioCallActive.value = true + // 启动VAD状态监控 + startVADMonitoring() + } catch (error) { console.error('❌ 启动音频通话失败:', error) ElMessage.error('无法启动音频通话: ' + (error as Error).message) @@ -587,6 +659,9 @@ const stopAudioCall = () => { isAudioCallActive.value = false currentSTTText.value = '' + // 停止VAD监控 + stopVADMonitoring() + } catch (error) { console.error('❌ 停止音频通话失败:', error) } @@ -835,6 +910,29 @@ const clearAllTypewriterEffects = () => { clearSyncPlaybackState() } +// VAD监控相关函数 +const startVADMonitoring = () => { + if (vadCheckInterval.value) { + clearInterval(vadCheckInterval.value) + } + + vadCheckInterval.value = window.setInterval(() => { + // 检查aiChat的audioManager是否有VAD状态 + if (aiChat.value && (aiChat.value as any).audioManager) { + const audioManager = (aiChat.value as any).audioManager + vadActive.value = audioManager.voiceActive || false + } + }, 100) // 每100ms检查一次VAD状态 +} + +const stopVADMonitoring = () => { + if (vadCheckInterval.value) { + clearInterval(vadCheckInterval.value) + vadCheckInterval.value = null + } + vadActive.value = false +} + // 格式化时间 const formatTime = (dateString: string) => { return new Date(dateString).toLocaleTimeString([], { @@ -1223,7 +1321,8 @@ const formatTime = (dateString: string) => { 100% { transform: scale(1.4); opacity: 0; } } -.video-chat { +// ChatGPT风格语音通话界面样式 +.chatgpt-voice-chat { position: absolute; top: 0; left: 0; @@ -1233,131 +1332,353 @@ const formatTime = (dateString: string) => { z-index: 9999; display: flex; flex-direction: column; - justify-content: center; - align-items: center; - backdrop-filter: blur(10px); + color: white; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + + // 顶部状态栏 + .voice-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.2rem 0.3rem; + background: rgba(0, 0, 0, 0.1); + backdrop-filter: blur(10px); + + .connection-indicator { + display: flex; + align-items: center; + gap: 0.1rem; - .ai-avatar { + .status-dot { + width: 0.08rem; + height: 0.08rem; + border-radius: 50%; + background: #ff6b6b; + transition: background-color 0.3s; + + &.connected { + background: #51cf66; + } + } + + .status-text { + font-size: 0.14rem; + opacity: 0.9; + } + } + + .close-btn { + width: 0.4rem; + height: 0.4rem; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + border: none; + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s; + + &:hover { + background: rgba(255, 255, 255, 0.3); + } + } + } + + // 中央对话区域 + .voice-conversation-area { flex: 1; display: flex; flex-direction: column; - align-items: center; justify-content: center; - text-align: center; + align-items: center; + gap: 3rem; + padding: 2rem; - .avatar { - width: 2rem; - height: 2rem; + .ai-section, .user-section { + display: flex; + flex-direction: column; + align-items: center; + } + + .ai-avatar-container, .user-avatar-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + } + + // AI头像样式 + .ai-avatar { + width: 3rem; + height: 3rem; + border-radius: 50%; + position: relative; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(45deg, #ff9a9e, #fecfef); + box-shadow: 0 0.2rem 0.8rem rgba(0, 0, 0, 0.3); + transition: all 0.3s ease; + + .avatar-inner { + width: 2.6rem; + height: 2.6rem; + border-radius: 50%; + background: linear-gradient(45deg, #667eea, #764ba2); + } + + &.speaking { + animation: ai-speaking 2s infinite; + } + + &.thinking { + animation: ai-thinking 1.5s infinite alternate; + } + + .voice-waves { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + + .wave { + position: absolute; + top: 50%; + left: 50%; + width: 100%; + height: 100%; + border: 0.02rem solid rgba(255, 255, 255, 0.6); + border-radius: 50%; + transform: translate(-50%, -50%); + animation: voice-wave 1.5s infinite; + + &:nth-child(2) { + animation-delay: 0.3s; + } + + &:nth-child(3) { + animation-delay: 0.6s; + } + } + } + } + + // 用户头像样式 + .user-avatar { + width: 2.5rem; + height: 2.5rem; border-radius: 50%; - background: linear-gradient(45deg, #ff9a9e, #fecfef, #fecfef); - margin: auto; - box-shadow: 0 10px 30px rgba(0,0,0,0.3); - transition: transform 0.3s ease; + position: relative; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(45deg, #74b9ff, #0984e3); + box-shadow: 0 0.2rem 0.6rem rgba(0, 0, 0, 0.3); + transition: all 0.3s ease; + + .avatar-inner { + width: 2.1rem; + height: 2.1rem; + border-radius: 50%; + background: linear-gradient(45deg, #81ecec, #00cec9); + } - &.pulsing { - animation: avatar-pulse 2s infinite; + &.listening { + animation: user-listening 1s infinite alternate; + } + + &.voice_active { + animation: voice-active-pulse 0.8s infinite; + box-shadow: 0 0 0.5rem rgba(116, 185, 255, 0.8); + } + + .vad-indicator { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + .vad-ring { + position: absolute; + top: -0.1rem; + left: -0.1rem; + right: -0.1rem; + bottom: -0.1rem; + border: 0.03rem solid #51cf66; + border-radius: 50%; + animation: vad-ring-pulse 1.2s infinite; + } + + .vad-pulse { + position: absolute; + top: -0.2rem; + left: -0.2rem; + right: -0.2rem; + bottom: -0.2rem; + background: rgba(81, 207, 102, 0.2); + border-radius: 50%; + animation: vad-pulse-expand 1.5s infinite; + } + } + + .mic-icon { + position: absolute; + bottom: -0.05rem; + right: -0.05rem; + width: 0.6rem; + height: 0.6rem; + background: #51cf66; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 0.02rem solid white; + + :deep(.el-icon) { + font-size: 0.25rem; + color: white; + } } } - .character-name { - color: white; - font-size: 0.24rem; - font-weight: 500; + // 名称和状态文字 + .ai-name, .user-name { + font-size: 0.18rem; + font-weight: 600; margin-top: 0.3rem; - text-shadow: 0 2px 4px rgba(0,0,0,0.3); } - .stt-display { - margin-top: 0.4rem; - background: rgba(255, 255, 255, 0.1); - padding: 0.2rem 0.3rem; - border-radius: 0.2rem; - backdrop-filter: blur(10px); - max-width: 80%; + .ai-status, .user-status { + font-size: 0.14rem; + opacity: 0.8; + margin-top: 0.1rem; + } + } + + // STT实时显示 + .stt-live-display { + position: absolute; + bottom: 2rem; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(10px); + border-radius: 0.2rem; + padding: 0.3rem 0.4rem; + max-width: 80%; + text-align: center; + .stt-content { .stt-label { - color: rgba(255, 255, 255, 0.8); - font-size: 0.14rem; + font-size: 0.12rem; + opacity: 0.7; margin-bottom: 0.1rem; } .stt-text { - color: white; - font-size: 0.18rem; + font-size: 0.16rem; font-weight: 500; } } } - .control { + // 底部控制区域 + .voice-controls { + padding: 0.5rem; display: flex; justify-content: center; - align-items: center; - margin: 1rem auto; - gap: 0.8rem; - .control-item { - width: 1rem; - height: 1rem; - border-radius: 50%; + .voice-btn { display: flex; - justify-content: center; + flex-direction: column; align-items: center; + gap: 0.15rem; + background: rgba(255, 255, 255, 0.15); + border: 0.02rem solid rgba(255, 255, 255, 0.3); + border-radius: 0.6rem; + padding: 0.3rem 0.6rem; + color: white; cursor: pointer; transition: all 0.3s; backdrop-filter: blur(10px); - border: 2px solid rgba(255, 255, 255, 0.2); - - &.mic { - background: rgba(40, 167, 69, 0.8); - &.active { - background: rgba(40, 167, 69, 1); - transform: scale(1.1); - box-shadow: 0 0 20px rgba(40, 167, 69, 0.5); - } + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } - &.muted { - background: rgba(108, 117, 125, 0.8); - } + &.active { + background: rgba(81, 207, 102, 0.8); + border-color: #51cf66; + } - &:hover { - transform: scale(1.05); - } + &.voice_active { + background: rgba(255, 193, 7, 0.8); + border-color: #ffc107; + animation: mic-active-pulse 1s infinite; } - &.close { - background: rgba(220, 53, 69, 0.8); + &:not(:disabled):hover { + background: rgba(255, 255, 255, 0.25); + transform: translateY(-0.02rem); + } - &:hover { - background: rgba(220, 53, 69, 1); - transform: scale(1.05); + .btn-icon { + :deep(.el-icon) { + font-size: 0.4rem; } } - :deep(.el-icon) { - font-size: 0.5rem; - color: white; - svg { - font-size: 0.5rem; - } + .btn-text { + font-size: 0.12rem; + font-weight: 500; } } } } -@keyframes avatar-pulse { - 0% { - transform: scale(1); - box-shadow: 0 10px 30px rgba(0,0,0,0.3); - } - 50% { - transform: scale(1.05); - box-shadow: 0 15px 40px rgba(255, 154, 158, 0.4); - } - 100% { - transform: scale(1); - box-shadow: 0 10px 30px rgba(0,0,0,0.3); - } +// 动画定义 +@keyframes ai-speaking { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +@keyframes ai-thinking { + 0% { transform: scale(1); opacity: 0.8; } + 100% { transform: scale(1.03); opacity: 1; } +} + +@keyframes user-listening { + 0% { transform: scale(1); } + 100% { transform: scale(1.02); } +} + +@keyframes voice-active-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +@keyframes voice-wave { + 0% { transform: translate(-50%, -50%) scale(1); opacity: 0.8; } + 100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; } +} + +@keyframes vad-ring-pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.1); opacity: 0.7; } +} + +@keyframes vad-pulse-expand { + 0% { transform: scale(1); opacity: 0.6; } + 100% { transform: scale(1.3); opacity: 0; } +} + +@keyframes mic-active-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } }