diff --git a/lover/routers/voice_call.py b/lover/routers/voice_call.py index ce9af09..88eff5e 100644 --- a/lover/routers/voice_call.py +++ b/lover/routers/voice_call.py @@ -1649,14 +1649,20 @@ async def voice_conversation( logger.info(f"TTS 合成完成,音频大小: {len(audio_bytes)} 字节") - # 5. 返回结果 + # 5. 上传音频到 OSS 并返回 URL + logger.info("上传 TTS 音频到 OSS...") + audio_url = upload_audio_file(audio_bytes, 'mp3') + logger.info(f"TTS 音频已上传: {audio_url}") + + # 同时返回 base64(兼容旧版本) audio_base64_result = base64.b64encode(audio_bytes).decode('utf-8') from ..response import success_response return success_response({ "user_text": user_text, "ai_text": ai_text, - "audio_data": audio_base64_result, + "audio_data": audio_base64_result, # 保留 base64(兼容) + "audio_url": audio_url, # 新增:OSS URL "audio_format": "mp3" }) diff --git a/xuniYou/pages/chat/phone.vue b/xuniYou/pages/chat/phone.vue index c6747b8..8ee0442 100644 --- a/xuniYou/pages/chat/phone.vue +++ b/xuniYou/pages/chat/phone.vue @@ -1310,14 +1310,16 @@ if (response.statusCode === 200 && response.data) { const result = response.data - // 后端返回格式: {code: 1, msg: "ok", data: {user_text, ai_text, audio_data}} + // 后端返回格式: {code: 1, msg: "ok", data: {user_text, ai_text, audio_data, audio_url}} const data = result.data || result const userText = data.user_text const aiText = data.ai_text const audioData = data.audio_data + const audioUrl = data.audio_url // 新增:OSS URL console.log('✅ 识别结果:', userText) console.log('✅ AI回复:', aiText) + console.log('✅ 音频 URL:', audioUrl || '无') console.log('✅ 音频数据:', audioData ? `${audioData.length} 字符` : '无') // 显示识别结果 @@ -1329,9 +1331,12 @@ }) } - // 播放 AI 的语音回复 - if (audioData) { - console.log('🔊 开始播放 AI 语音回复...') + // 播放 AI 的语音回复 - 优先使用 URL + if (audioUrl) { + console.log('🔊 使用 OSS URL 播放 AI 语音...') + await this.playAIVoiceFromUrl(audioUrl, aiText) + } else if (audioData) { + console.log('🔊 使用 base64 播放 AI 语音...') console.log('🔊 音频数据前100字符:', audioData.substring(0, 100)) await this.playAIVoice(audioData, aiText) } else { @@ -1366,6 +1371,70 @@ throw error } }, + async playAIVoiceFromUrl(audioUrl, aiText) { + console.log('🔊 playAIVoiceFromUrl 被调用') + console.log('🔊 音频 URL:', audioUrl) + console.log('🔊 aiText:', aiText) + + try { + // 显示 AI 回复文字 + if (aiText) { + uni.showToast({ + title: `AI: ${aiText.substring(0, 20)}...`, + icon: 'none', + duration: 3000 + }) + } + + // 直接使用 URL 播放 + const audioContext = uni.createInnerAudioContext() + audioContext.src = audioUrl + audioContext.autoplay = true + audioContext.volume = 1.0 + + console.log('🎵 音频上下文已创建,开始播放...') + + audioContext.onPlay(() => { + console.log('🔊🔊🔊 AI 语音开始播放!') + uni.showToast({ + title: 'AI 正在说话', + icon: 'none', + duration: 1000 + }) + }) + + audioContext.onEnded(() => { + console.log('✅ AI 语音播放完成') + audioContext.destroy() + }) + + audioContext.onError((error) => { + console.error('❌ 播放失败:', error) + console.error('错误码:', error.errCode) + console.error('错误信息:', error.errMsg) + + uni.showToast({ + title: '播放失败', + icon: 'none', + duration: 2000 + }) + + audioContext.destroy() + }) + + } catch (error) { + console.error('❌ 播放 AI 语音失败:', error) + console.error('错误类型:', error.constructor.name) + console.error('错误消息:', error.message) + console.error('错误堆栈:', error.stack) + + uni.showToast({ + title: '播放失败', + icon: 'none', + duration: 2000 + }) + } + }, async playAIVoice(base64Audio, aiText) { console.log('🔊 playAIVoice 被调用') console.log('🔊 base64Audio 长度:', base64Audio ? base64Audio.length : 0) @@ -1397,134 +1466,100 @@ console.log('✅ 音频数据解码完成,大小:', bytes.length, 'bytes') // #ifdef APP-PLUS - // APP 环境 - 使用 plus.io 写入文件 + // APP 环境 - 使用 uni.saveFile 保存 base64 数据 console.log('📱 APP 环境,保存音频文件') - console.log('📊 音频数据大小:', bytes.length, 'bytes') + console.log('📊 base64 数据大小:', base64Audio.length, '字符') - const fileName = `ai_voice_${Date.now()}.mp3` + // 先将 base64 转换为临时文件路径 + // 使用 uni.base64ToArrayBuffer 和 uni.arrayBufferToBase64 + const tempFileName = `temp_audio_${Date.now()}.mp3` + const tempPath = `_doc/${tempFileName}` - // 使用 plus.io 保存文件 + console.log('� 临时文件路径:', tempPath) + + // 方法:使用 XMLHttpRequest 将 base64 转换为 Blob,然后保存 + // 创建一个 data URL + const dataUrl = 'data:audio/mp3;base64,' + base64Audio + + // 使用 fetch API(如果可用)或 XMLHttpRequest + console.log('🔄 开始转换 base64 为文件...') + + // 使用 plus.io 直接写入 plus.io.resolveLocalFileSystemURL('_doc/', (docEntry) => { console.log('✅ 获取 _doc 目录成功') - docEntry.getFile(fileName, {create: true, exclusive: false}, (fileEntry) => { - console.log('✅ 文件对象创建成功') - console.log('📁 文件路径:', fileEntry.fullPath) + const fileName = `ai_voice_${Date.now()}.mp3` + + docEntry.getFile(fileName, {create: true}, (fileEntry) => { + console.log('✅ 文件创建成功:', fileEntry.fullPath) - // 创建 FileWriter fileEntry.createWriter((writer) => { - console.log('✅ FileWriter 创建成功') + console.log('✅ Writer 创建成功') - // 设置写入完成事件 - writer.onwriteend = function(evt) { + writer.onwriteend = function() { console.log('✅✅✅ 文件写入完成!') - // 验证文件大小 fileEntry.file((file) => { - console.log('📊 文件大小:', file.size, 'bytes') + console.log('� 文件大小:', file.size, 'bytes') if (file.size === 0) { console.error('❌ 文件大小为 0') - uni.showToast({ - title: '文件保存失败', - icon: 'none' - }) return } - // 获取文件 URL const audioUrl = fileEntry.toLocalURL() - console.log('🎵 准备播放,URL:', audioUrl) + console.log('🎵 文件 URL:', audioUrl) - // 使用 plus.audio 播放 - console.log('🎵 创建播放器...') + // 播放 const player = plus.audio.createPlayer(audioUrl) - player.play(() => { - console.log('🔊🔊🔊 AI 语音开始播放!') + console.log('�🔊🔊 AI 语音开始播放!') uni.showToast({ title: 'AI 正在说话', icon: 'none', duration: 1000 }) }, (e) => { - console.error('❌ plus.audio 播放失败:', e) - console.error('错误详情:', JSON.stringify(e)) - - // 如果 plus.audio 失败,尝试 uni.createInnerAudioContext - console.log('🔄 尝试使用 InnerAudioContext') - const ctx = uni.createInnerAudioContext() - ctx.src = audioUrl - ctx.autoplay = true - - ctx.onPlay(() => { - console.log('🔊 InnerAudioContext 播放成功') - uni.showToast({ - title: 'AI 正在说话', - icon: 'none' - }) - }) - - ctx.onError((err) => { - console.error('❌ InnerAudioContext 也失败:', err) - uni.showToast({ - title: '播放失败', - icon: 'none', - duration: 3000 - }) - }) - - ctx.onEnded(() => { - console.log('✅ 播放完成') - ctx.destroy() - // 清理文件 - fileEntry.remove(() => console.log('🗑️ 文件已清理')) - }) + console.error('❌ 播放失败:', e) }) player.addEventListener('ended', () => { console.log('✅ 播放完成') player.close() - // 清理文件 fileEntry.remove(() => console.log('🗑️ 文件已清理')) }) - - player.addEventListener('error', (e) => { - console.error('❌ 播放错误:', e) - player.close() - }) - - }, (error) => { - console.error('❌ 获取文件信息失败:', error) }) } - // 设置写入错误事件 writer.onerror = function(e) { - console.error('❌❌❌ 文件写入失败!') - console.error('错误对象:', e) - - uni.showToast({ - title: '文件保存失败', - icon: 'none' - }) + console.error('❌ 写入失败:', e) } - // 直接写入 Uint8Array - console.log('📝 开始写入二进制数据...') - console.log('📝 数据大小:', bytes.length, 'bytes') - writer.write(bytes) + // 关键:将 base64 字符串直接写入 + // FileWriter 支持字符串,我们写入原始 base64 + console.log('📝 写入 base64 字符串...') - }, (error) => { - console.error('❌ 创建 FileWriter 失败:', error) + // 将 Uint8Array 转换为 Blob(标准方式) + try { + // 尝试创建 Blob + const blob = new Blob([bytes.buffer], {type: 'audio/mpeg'}) + console.log('✅ Blob 创建成功,大小:', blob.size) + writer.write(blob) + } catch (err) { + console.error('❌ Blob 创建失败:', err) + // 降级:写入 base64 字符串 + console.log('🔄 降级:写入 base64 字符串') + writer.write(base64Audio) + } + + }, (err) => { + console.error('❌ 创建 Writer 失败:', err) }) - - }, (error) => { - console.error('❌ 创建文件失败:', error) + }, (err) => { + console.error('❌ 创建文件失败:', err) }) - - }, (error) => { - console.error('❌ 获取文件系统失败:', error) + }, (err) => { + console.error('❌ 获取目录失败:', err) }) // #endif