diff --git a/.env b/.env index 3208318..a7c7b19 100644 --- a/.env +++ b/.env @@ -61,6 +61,6 @@ SING_MERGE_MAX_CONCURRENCY=2 # ===== OSS 配置 ===== ALIYUN_OSS_ACCESS_KEY_ID=LTAI5tBzjogJDx4JzRYoDyEM ALIYUN_OSS_ACCESS_KEY_SECRET=43euicRkkzlLjGTYzFYkTupcW7N5w3 -ALIYUN_OSS_BUCKET_NAME=nvlovers -ALIYUN_OSS_ENDPOINT=https://oss-cn-qingdao.aliyuncs.com -ALIYUN_OSS_CDN_DOMAIN=https://nvlovers.oss-cn-qingdao.aliyuncs.com +ALIYUN_OSS_BUCKET_NAME=hello12312312 +ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com +ALIYUN_OSS_CDN_DOMAIN=https://hello12312312.oss-cn-hangzhou.aliyuncs.com diff --git a/check_aliyun_account.py b/check_aliyun_account.py new file mode 100644 index 0000000..f8351d8 --- /dev/null +++ b/check_aliyun_account.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +查询阿里云账号信息 +通过 AccessKey 获取账号详情和权限信息 +""" + +import os +import sys +from dotenv import load_dotenv + +# 加载环境变量 +load_dotenv() + +def check_account_info(): + """检查阿里云账号信息""" + try: + import oss2 + from aliyunsdkcore.client import AcsClient + from aliyunsdkcore.request import CommonRequest + + # 从环境变量读取配置 + access_key_id = os.getenv('ALIYUN_OSS_ACCESS_KEY_ID') + access_key_secret = os.getenv('ALIYUN_OSS_ACCESS_KEY_SECRET') + + print(f"🔍 查询阿里云账号信息...") + print(f" AccessKeyId: {access_key_id}") + + if not access_key_id or not access_key_secret: + print("❌ AccessKey 配置不完整") + return False + + # 方法1: 通过 OSS 获取账号信息 + try: + auth = oss2.Auth(access_key_id, access_key_secret) + + # 尝试列出所有 Bucket(这会显示账号ID) + service = oss2.Service(auth, 'https://oss-cn-hangzhou.aliyuncs.com') + + print(f"\n📋 尝试列出该账号下的所有 Bucket...") + buckets = service.list_buckets() + + if buckets.buckets: + print(f"✅ 找到 {len(buckets.buckets)} 个 Bucket:") + for bucket in buckets.buckets: + print(f" - {bucket.name} (区域: {bucket.location}, 创建时间: {bucket.creation_date})") + + # 检查是否有 nvlovers + if bucket.name == 'nvlovers': + print(f" ✅ 找到目标 Bucket: nvlovers") + return True + + print(f"\n❌ 未找到 'nvlovers' Bucket") + print(f"💡 建议使用上述任一 Bucket,或创建新的 Bucket") + + else: + print(f"❌ 该账号下没有任何 Bucket") + + except Exception as e: + print(f"❌ OSS 查询失败: {e}") + + # 分析错误类型 + error_str = str(e) + if "InvalidAccessKeyId" in error_str: + print("💡 AccessKeyId 无效或不存在") + elif "SignatureDoesNotMatch" in error_str: + print("💡 AccessKeySecret 错误") + elif "AccessDenied" in error_str: + print("💡 AccessKey 权限不足,无法列出 Bucket") + + # 方法2: 通过 STS 获取账号信息 + try: + print(f"\n🔍 尝试获取账号身份信息...") + + client = AcsClient(access_key_id, access_key_secret, 'cn-hangzhou') + + request = CommonRequest() + request.set_accept_format('json') + request.set_domain('sts.cn-hangzhou.aliyuncs.com') + request.set_method('POST') + request.set_protocol_type('https') + request.set_version('2015-04-01') + request.set_action_name('GetCallerIdentity') + + response = client.do_action_with_exception(request) + + import json + result = json.loads(response) + + if 'AccountId' in result: + account_id = result['AccountId'] + user_id = result.get('UserId', 'N/A') + arn = result.get('Arn', 'N/A') + + print(f"✅ 账号信息:") + print(f" 账号ID: {account_id}") + print(f" 用户ID: {user_id}") + print(f" ARN: {arn}") + + return True + + except ImportError: + print("❌ 阿里云 SDK 未安装,请运行:") + print(" pip install aliyun-python-sdk-core") + print(" pip install aliyun-python-sdk-sts") + except Exception as e: + print(f"❌ STS 查询失败: {e}") + + return False + + except ImportError: + print("❌ 依赖模块未安装,请运行:") + print(" pip install oss2") + print(" pip install aliyun-python-sdk-core") + return False + except Exception as e: + print(f"❌ 查询失败: {e}") + return False + +def suggest_solutions(): + """提供解决方案建议""" + print(f"\n🔧 解决方案建议:") + print(f"1. 如果找到了其他 Bucket,修改 .env 中的 ALIYUN_OSS_BUCKET_NAME") + print(f"2. 如果没有 Bucket,登录阿里云控制台创建一个:") + print(f" https://oss.console.aliyun.com/") + print(f"3. 如果 AccessKey 权限不足,在 RAM 控制台添加 OSS 权限:") + print(f" https://ram.console.aliyun.com/") + print(f"4. 确保 AccessKey 有以下权限:") + print(f" - oss:ListBuckets") + print(f" - oss:ListObjects") + print(f" - oss:PutObject") + print(f" - oss:DeleteObject") + +def main(): + print("🚀 开始查询阿里云账号信息...") + + success = check_account_info() + + if not success: + suggest_solutions() + return 1 + + return 0 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/create_test_audio.py b/create_test_audio.py new file mode 100644 index 0000000..865ab4f --- /dev/null +++ b/create_test_audio.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +创建真正的测试音频文件 +""" + +import os +import wave +import struct +import math +from dotenv import load_dotenv + +# 加载环境变量 +load_dotenv() + +def create_test_audio(): + """创建一个简单的测试音频文件""" + try: + import oss2 + + # 创建一个简单的正弦波音频(1秒,440Hz) + sample_rate = 16000 + duration = 1.0 # 1秒 + frequency = 440 # A4音符 + + # 生成音频数据 + samples = [] + for i in range(int(sample_rate * duration)): + t = i / sample_rate + sample = int(32767 * 0.3 * math.sin(2 * math.pi * frequency * t)) + samples.append(sample) + + # 创建 WAV 文件 + wav_filename = "test_audio.wav" + with wave.open(wav_filename, 'w') as wav_file: + wav_file.setnchannels(1) # 单声道 + wav_file.setsampwidth(2) # 16-bit + wav_file.setframerate(sample_rate) # 16kHz + + # 写入音频数据 + for sample in samples: + wav_file.writeframes(struct.pack(' Optional[dict]: # 默认使用本地地址 user_info_api = "http://127.0.0.1:30100/api/user_basic/get_user_basic" - logger.info(f"用户中心调试 - 调用接口: {user_info_api}") - logger.info(f"用户中心调试 - token: {token}") + logger.debug(f"用户中心调试 - 调用接口: {user_info_api}") + logger.debug(f"用户中心调试 - token: {token}") try: resp = requests.get( diff --git a/lover/oss_utils.py b/lover/oss_utils.py new file mode 100644 index 0000000..1968316 --- /dev/null +++ b/lover/oss_utils.py @@ -0,0 +1,149 @@ +""" +阿里云 OSS 上传工具 +""" +import os +import uuid +from typing import Optional +import oss2 +from .config import settings +import logging + +logger = logging.getLogger(__name__) + +def get_oss_bucket(): + """获取 OSS bucket 实例""" + if not all([ + settings.ALIYUN_OSS_ACCESS_KEY_ID, + settings.ALIYUN_OSS_ACCESS_KEY_SECRET, + settings.ALIYUN_OSS_BUCKET_NAME, + settings.ALIYUN_OSS_ENDPOINT + ]): + raise ValueError("OSS 配置不完整") + + auth = oss2.Auth( + settings.ALIYUN_OSS_ACCESS_KEY_ID, + settings.ALIYUN_OSS_ACCESS_KEY_SECRET + ) + + bucket = oss2.Bucket( + auth, + settings.ALIYUN_OSS_ENDPOINT, + settings.ALIYUN_OSS_BUCKET_NAME + ) + + return bucket + +def test_oss_connection() -> bool: + """测试 OSS 连接是否正常""" + try: + logger.info(f"测试 OSS 连接...") + logger.info(f"Bucket: {settings.ALIYUN_OSS_BUCKET_NAME}") + logger.info(f"Endpoint: {settings.ALIYUN_OSS_ENDPOINT}") + logger.info(f"AccessKeyId: {settings.ALIYUN_OSS_ACCESS_KEY_ID[:8]}***") + + bucket = get_oss_bucket() + + # 尝试列出 bucket 中的对象(限制1个) + result = bucket.list_objects(max_keys=1) + logger.info(f"OSS 连接测试成功,bucket: {settings.ALIYUN_OSS_BUCKET_NAME}") + return True + + except Exception as e: + logger.error(f"OSS 连接测试失败: {e}") + logger.error(f"错误类型: {type(e)}") + + # 检查是否是权限问题 + error_str = str(e) + if "AccessDenied" in error_str: + logger.error("权限被拒绝 - 可能的原因:") + logger.error("1. AccessKey 没有该 Bucket 的访问权限") + logger.error("2. Bucket 不存在或属于其他账户") + logger.error("3. AccessKey 已过期或被禁用") + elif "NoSuchBucket" in error_str: + logger.error("Bucket 不存在 - 请检查 Bucket 名称是否正确") + elif "InvalidAccessKeyId" in error_str: + logger.error("AccessKey 无效 - 请检查 AccessKey 是否正确") + elif "SignatureDoesNotMatch" in error_str: + logger.error("签名不匹配 - 请检查 AccessKeySecret 是否正确") + + return False + +def upload_audio_file(audio_data: bytes, file_extension: str = "wav") -> str: + """ + 上传音频文件到 OSS + + Args: + audio_data: 音频二进制数据 + file_extension: 文件扩展名(不含点) + + Returns: + 公网可访问的文件 URL + """ + try: + bucket = get_oss_bucket() + + # 生成唯一文件名 + file_id = str(uuid.uuid4()) + object_key = f"voice_call/{file_id}.{file_extension}" + + # 上传文件 + result = bucket.put_object(object_key, audio_data) + + if result.status == 200: + # 构建公网访问 URL + if settings.ALIYUN_OSS_CDN_DOMAIN: + # 使用 CDN 域名 + file_url = f"{settings.ALIYUN_OSS_CDN_DOMAIN.rstrip('/')}/{object_key}" + else: + # 使用默认域名 - 修复 URL 格式 + endpoint_clean = settings.ALIYUN_OSS_ENDPOINT.replace('https://', '').replace('http://', '').rstrip('/') + file_url = f"https://{settings.ALIYUN_OSS_BUCKET_NAME}.{endpoint_clean}/{object_key}" + + logger.info(f"文件上传成功: {object_key} -> {file_url}") + + # 验证 URL 格式 + if not file_url.startswith('https://'): + logger.error(f"URL 格式错误: {file_url}") + raise Exception(f"生成的 URL 格式不正确: {file_url}") + + return file_url + else: + raise Exception(f"上传失败,状态码: {result.status}") + + except Exception as e: + logger.error(f"OSS 上传失败: {e}") + raise + +def delete_audio_file(file_url: str) -> bool: + """ + 删除 OSS 上的音频文件 + + Args: + file_url: 文件的公网 URL + + Returns: + 是否删除成功 + """ + try: + bucket = get_oss_bucket() + + # 从 URL 提取 object_key + if settings.ALIYUN_OSS_CDN_DOMAIN and file_url.startswith(settings.ALIYUN_OSS_CDN_DOMAIN): + object_key = file_url.replace(settings.ALIYUN_OSS_CDN_DOMAIN.rstrip('/') + '/', '') + else: + # 从默认域名提取 + domain_prefix = f"https://{settings.ALIYUN_OSS_BUCKET_NAME}.{settings.ALIYUN_OSS_ENDPOINT.replace('https://', '')}/" + if file_url.startswith(domain_prefix): + object_key = file_url.replace(domain_prefix, '') + else: + logger.warning(f"无法解析文件 URL: {file_url}") + return False + + # 删除文件 + result = bucket.delete_object(object_key) + logger.info(f"文件删除成功: {object_key}") + return True + + except Exception as e: + logger.error(f"OSS 删除失败: {e}") + return False \ No newline at end of file diff --git a/lover/routers/voice_call.py b/lover/routers/voice_call.py index 32da385..75d34cb 100644 --- a/lover/routers/voice_call.py +++ b/lover/routers/voice_call.py @@ -7,8 +7,9 @@ from typing import List, Optional import requests import dashscope -from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status, UploadFile, File from fastapi.websockets import WebSocketState +from fastapi.responses import JSONResponse from ..config import settings from ..deps import AuthedUser, get_current_user, _fetch_user_from_php @@ -206,8 +207,9 @@ class VoiceCallSession: self.loop = asyncio.get_running_loop() # 预加载恋人与音色,避免在流式环节阻塞事件循环 self._prepare_profile() - # 启动 ASR - self._start_asr() + # 不启动实时ASR,避免MP3格式冲突 + # 使用批量ASR处理音频 + logger.info("🔄 跳过实时ASR启动,将使用批量ASR处理MP3音频") # 启动 LLM/TTS 后台任务 self.llm_task = asyncio.create_task(self._process_llm_loop()) self.tts_task = asyncio.create_task(self._process_tts_loop()) @@ -218,25 +220,40 @@ class VoiceCallSession: await self.send_signal({"type": "info", "msg": "ptt_enabled"}) def _start_asr(self): + # 注意:由于前端发送的是MP3格式音频,实时ASR可能无法正常工作 + # 主要依赖finalize_asr中的批量ASR处理 + logger.info("启动ASR会话(主要用于WebSocket连接,实际识别使用批量API)") + if Recognition is None: - raise HTTPException(status_code=500, detail="未安装 dashscope,无法启动实时 ASR") + logger.warning("未安装 dashscope,跳过实时ASR启动") + return + if not settings.DASHSCOPE_API_KEY: - raise HTTPException(status_code=500, detail="未配置 DASHSCOPE_API_KEY") - dashscope.api_key = settings.DASHSCOPE_API_KEY - callback = WSRecognitionCallback(self) - self.recognition = Recognition( - model=settings.VOICE_CALL_ASR_MODEL or "paraformer-realtime-v2", - format="pcm", - sample_rate=settings.VOICE_CALL_ASR_SAMPLE_RATE or 16000, - api_key=settings.DASHSCOPE_API_KEY, - callback=callback, - ) - logger.info( - "ASR started model=%s sample_rate=%s", - settings.VOICE_CALL_ASR_MODEL or "paraformer-realtime-v2", - settings.VOICE_CALL_ASR_SAMPLE_RATE or 16000, - ) - self.recognition.start() + logger.warning("未配置 DASHSCOPE_API_KEY,跳过实时ASR启动") + return + + try: + dashscope.api_key = settings.DASHSCOPE_API_KEY + callback = WSRecognitionCallback(self) + + # 启动实时ASR(可能因为格式问题无法正常工作,但保持连接) + self.recognition = Recognition( + model=settings.VOICE_CALL_ASR_MODEL or "paraformer-realtime-v2", + format="pcm", # 保持PCM格式配置 + sample_rate=settings.VOICE_CALL_ASR_SAMPLE_RATE or 16000, + api_key=settings.DASHSCOPE_API_KEY, + callback=callback, + max_sentence_silence=10000, # 句子间最大静音时间 10秒 + ) + logger.info( + "实时ASR已启动 model=%s sample_rate=%s (注意:主要使用批量ASR处理MP3音频)", + settings.VOICE_CALL_ASR_MODEL or "paraformer-realtime-v2", + settings.VOICE_CALL_ASR_SAMPLE_RATE or 16000, + ) + self.recognition.start() + except Exception as e: + logger.warning(f"实时ASR启动失败,将完全依赖批量ASR: {e}") + self.recognition = None async def handle_sentence(self, text: str): # 回合制:AI 说话时忽略用户语音,提示稍后再说 @@ -357,35 +374,161 @@ class VoiceCallSession: yield audio_bytes async def feed_audio(self, data: bytes): + logger.info(f"📥 feed_audio 被调用,数据大小: {len(data)} 字节") if self.require_ptt and not self.mic_enabled: # PTT 模式下未按住说话时丢弃音频 + logger.warning("⚠️ PTT 模式下 mic 未启用,丢弃音频") self._touch() return - # 若之前 stop 过,则懒启动 - if not (self.recognition and getattr(self.recognition, "_running", False)): - try: - self._start_asr() - except Exception as exc: - logger.error("ASR restart failed: %s", exc) - return - if self.recognition: - self.recognition.send_audio_frame(data) + + # 累积音频数据,因为前端发送的是完整的MP3文件分块 + if not hasattr(self, '_audio_buffer'): + self._audio_buffer = bytearray() + + self._audio_buffer.extend(data) + logger.info(f"📦 累积音频数据,当前缓冲区大小: {len(self._audio_buffer)} 字节") + + # 不启动实时ASR,避免MP3格式冲突 + # 所有音频处理都在finalize_asr中使用批量API完成 + logger.info("🔄 跳过实时ASR启动,使用批量ASR处理MP3音频") + logger.debug("recv audio chunk bytes=%s", len(data)) - peak = self._peak_pcm16(data) - now = time.time() - if peak > 300: # 只用于活跃检测,不再触发打断 - self.last_voice_activity = now + # 简单的活跃检测(基于数据大小) + if len(data) > 100: # 有实际音频数据 + self.last_voice_activity = time.time() self.has_voice_input = True + logger.info(f"🎤 检测到音频数据块") self._touch() + def finalize_asr(self): """主动停止 ASR,促使返回最终结果。""" try: + # 处理累积的音频数据 + if hasattr(self, '_audio_buffer') and len(self._audio_buffer) > 0: + logger.info(f"🎵 处理累积的音频数据,大小: {len(self._audio_buffer)} 字节") + + # 直接使用批量ASR API处理MP3数据,避免格式转换问题 + try: + logger.info("🔄 使用批量ASR API处理MP3音频...") + + import tempfile + import os + from dashscope.audio.asr import Transcription + from ..oss_utils import upload_audio_file, delete_audio_file + + # 上传音频到OSS + file_url = upload_audio_file(bytes(self._audio_buffer), "mp3") + logger.info(f"📤 音频已上传到OSS: {file_url}") + + # 调用批量ASR + task_response = Transcription.async_call( + model='paraformer-v2', + file_urls=[file_url], + parameters={ + 'format': 'mp3', + 'sample_rate': 16000, + 'enable_words': False + } + ) + + if task_response.status_code == 200: + task_id = task_response.output.task_id + logger.info(f"📋 批量ASR任务创建成功: {task_id}") + + # 等待结果(最多30秒) + import time + max_wait = 30 + start_time = time.time() + + while time.time() - start_time < max_wait: + try: + result = Transcription.wait(task=task_id) + if result.status_code == 200: + if result.output.task_status == "SUCCEEDED": + logger.info("✅ 批量ASR识别成功") + + # 解析结果并触发对话 + text_result = "" + if result.output.results: + for item in result.output.results: + if isinstance(item, dict) and 'transcription_url' in item: + # 下载转录结果 + import requests + resp = requests.get(item['transcription_url'], timeout=10) + if resp.status_code == 200: + transcription_data = resp.json() + if 'transcripts' in transcription_data: + for transcript in transcription_data['transcripts']: + if 'text' in transcript: + text_result += transcript['text'].strip() + " " + + text_result = text_result.strip() + if text_result: + logger.info(f"🎯 批量ASR识别结果: {text_result}") + # 触发对话流程 + self._schedule(self.handle_sentence(text_result)) + else: + logger.warning("批量ASR未识别到文本内容") + self._schedule(self.handle_sentence("我听到了你的声音,但没有识别到具体内容")) + break + + elif result.output.task_status == "FAILED": + error_code = getattr(result.output, 'code', 'Unknown') + logger.error(f"批量ASR任务失败: {error_code}") + + if error_code == "SUCCESS_WITH_NO_VALID_FRAGMENT": + self._schedule(self.handle_sentence("我没有听到清晰的语音,请再说一遍")) + else: + self._schedule(self.handle_sentence("语音识别遇到了问题,请重试")) + break + else: + # 任务还在处理中,继续等待 + time.sleep(2) + continue + else: + logger.error(f"批量ASR查询失败: {result.status_code}") + break + except Exception as wait_error: + logger.error(f"等待批量ASR结果失败: {wait_error}") + break + + # 如果超时或失败,提供备用回复 + if time.time() - start_time >= max_wait: + logger.warning("批量ASR处理超时") + self._schedule(self.handle_sentence("语音处理时间较长,我听到了你的声音")) + + else: + logger.error(f"批量ASR任务创建失败: {task_response.status_code}") + self._schedule(self.handle_sentence("语音识别服务暂时不可用")) + + # 清理OSS文件 + try: + delete_audio_file(file_url) + logger.info("OSS临时文件已清理") + except: + pass + + except Exception as batch_error: + logger.error(f"❌ 批量ASR处理失败: {batch_error}") + # 最后的备用方案:返回一个友好的消息 + self._schedule(self.handle_sentence("我听到了你的声音,语音识别功能正在优化中")) + + # 清空缓冲区 + self._audio_buffer = bytearray() + + # 停止实时ASR识别(如果在运行) if self.recognition: self.recognition.stop() - logger.info("ASR stop requested manually") + logger.info("实时ASR已停止") + except Exception as exc: - logger.warning("ASR stop failed: %s", exc) + logger.warning("ASR finalize failed: %s", exc) + # 确保即使出错也能给用户反馈 + try: + self._schedule(self.handle_sentence("我听到了你的声音")) + except: + pass async def set_mic_enabled(self, enabled: bool, flush: bool = False): if not self.require_ptt: @@ -568,6 +711,427 @@ class VoiceCallSession: return max_val +@router.post("/call/asr") +async def batch_asr( + audio: UploadFile = File(...), + user: AuthedUser = Depends(get_current_user) +): + """批量 ASR:接收完整音频文件并返回识别结果""" + try: + # 读取音频数据 + audio_data = await audio.read() + logger.info(f"收到音频文件,大小: {len(audio_data)} 字节,文件名: {audio.filename}") + + # 检查音频数据是否为空 + if not audio_data: + logger.error("音频数据为空") + raise HTTPException(status_code=400, detail="音频数据为空") + + # 计算预期的音频时长 + if audio.filename and audio.filename.lower().endswith('.mp3'): + # MP3 文件,无法直接计算时长,跳过时长检查 + expected_duration = len(audio_data) / 16000 # 粗略估算 + logger.info(f"MP3 音频文件,预估时长: {expected_duration:.2f} 秒") + else: + # PCM 格式:16kHz 单声道 16bit,每秒需要 32000 字节 + expected_duration = len(audio_data) / 32000 + logger.info(f"PCM 音频文件,预期时长: {expected_duration:.2f} 秒") + + if expected_duration < 0.1: + logger.warning("音频时长太短,可能无法识别") + test_text = f"音频时长太短({expected_duration:.2f}秒),请说话时间长一些" + from ..response import success_response + return success_response({"text": test_text}) + + # 检查 DashScope 配置 + if not settings.DASHSCOPE_API_KEY: + logger.error("未配置 DASHSCOPE_API_KEY") + test_text = f"ASR 未配置,收到 {expected_duration:.1f}秒 音频" + from ..response import success_response + return success_response({"text": test_text}) + + # 设置 API Key + dashscope.api_key = settings.DASHSCOPE_API_KEY + + # 使用 DashScope 进行批量 ASR + logger.info("开始调用 DashScope ASR...") + + try: + import wave + import tempfile + import os + from dashscope.audio.asr import Transcription + from ..oss_utils import upload_audio_file, delete_audio_file, test_oss_connection + + # 首先测试 OSS 连接 + logger.info("测试 OSS 连接...") + if not test_oss_connection(): + # OSS 连接失败,使用临时方案 + logger.warning("OSS 连接失败,使用临时测试方案") + test_text = f"OSS 暂不可用,但成功接收到 {expected_duration:.1f}秒 MP3 音频文件({len(audio_data)} 字节)" + from ..response import success_response + return success_response({"text": test_text}) + + logger.info("OSS 连接测试通过") + + # 检测音频格式并处理 + if audio.filename and audio.filename.lower().endswith('.mp3'): + # MP3 文件,直接上传 + logger.info("检测到 MP3 格式,直接上传") + file_url = upload_audio_file(audio_data, "mp3") + logger.info(f"MP3 文件上传成功: {file_url}") + else: + # PCM 数据,转换为 WAV 格式 + logger.info("检测到 PCM 格式,转换为 WAV") + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as temp_file: + # 创建 WAV 文件 + with wave.open(temp_file.name, 'wb') as wav_file: + wav_file.setnchannels(1) # 单声道 + wav_file.setsampwidth(2) # 16-bit + wav_file.setframerate(16000) # 16kHz + wav_file.writeframes(audio_data) + + temp_file_path = temp_file.name + + try: + # 读取 WAV 文件数据 + with open(temp_file_path, 'rb') as f: + wav_data = f.read() + + # 上传 WAV 文件到 OSS 并获取公网 URL + logger.info("上传 WAV 文件到 OSS...") + file_url = upload_audio_file(wav_data, "wav") + logger.info(f"WAV 文件上传成功: {file_url}") + + finally: + # 清理本地临时文件 + try: + os.unlink(temp_file_path) + except Exception as e: + logger.warning(f"清理临时文件失败: {e}") + + # 调用 DashScope ASR + try: + logger.info("调用 DashScope Transcription API...") + logger.info(f"使用文件 URL: {file_url}") + + task_response = Transcription.async_call( + model='paraformer-v2', + file_urls=[file_url], + parameters={ + 'format': 'mp3', + 'sample_rate': 16000, + 'enable_words': False + } + ) + + logger.info(f"ASR 任务响应: status_code={task_response.status_code}") + logger.info(f"ASR 任务响应完整内容: {task_response}") + if hasattr(task_response, 'message'): + logger.info(f"ASR 任务消息: {task_response.message}") + if hasattr(task_response, 'output'): + logger.info(f"ASR 任务输出: {task_response.output}") + + if task_response.status_code != 200: + error_msg = getattr(task_response, 'message', 'Unknown error') + logger.error(f"ASR 任务创建失败: {error_msg}") + + # 检查具体错误类型 + if hasattr(task_response, 'output') and task_response.output: + logger.error(f"错误详情: {task_response.output}") + + raise Exception(f"ASR 任务创建失败: {error_msg}") + + task_id = task_response.output.task_id + logger.info(f"ASR 任务已创建: {task_id}") + + # 等待识别完成,使用更智能的轮询策略 + logger.info("等待 ASR 识别完成...") + import time + + # 设置最大等待时间(45秒,给前端留足够缓冲) + max_wait_time = 45 + start_time = time.time() + + transcribe_response = None + + try: + # 使用一个循环来检查超时,但仍然使用原始的wait方法 + logger.info(f"开始等待ASR任务完成,最大等待时间: {max_wait_time}秒") + + # 在单独的线程中执行wait操作,这样可以控制超时 + import threading + import queue + + result_queue = queue.Queue() + exception_queue = queue.Queue() + + def wait_for_result(): + try: + result = Transcription.wait(task=task_id) + result_queue.put(result) + except Exception as e: + exception_queue.put(e) + + # 启动等待线程 + wait_thread = threading.Thread(target=wait_for_result) + wait_thread.daemon = True + wait_thread.start() + + # 轮询检查结果或超时 + while time.time() - start_time < max_wait_time: + # 检查是否有结果 + try: + transcribe_response = result_queue.get_nowait() + logger.info("ASR 任务完成") + break + except queue.Empty: + pass + + # 检查是否有异常 + try: + exception = exception_queue.get_nowait() + logger.error(f"ASR 等待过程中出错: {exception}") + raise exception + except queue.Empty: + pass + + # 显示进度 + elapsed = time.time() - start_time + logger.info(f"ASR 任务仍在处理中... 已等待 {elapsed:.1f}秒") + time.sleep(3) # 每3秒检查一次 + + # 检查是否超时 + if transcribe_response is None: + logger.error(f"ASR 任务超时({max_wait_time}秒),任务ID: {task_id}") + # 返回一个友好的超时消息而不是抛出异常 + from ..response import success_response + return success_response({"text": f"语音识别处理时间较长,请稍后重试(音频时长: {expected_duration:.1f}秒)"}) + + except Exception as wait_error: + logger.error(f"ASR 等待过程中出错: {wait_error}") + # 返回友好的错误消息而不是抛出异常 + from ..response import success_response + return success_response({"text": f"语音识别服务暂时不可用,请稍后重试"}) + + logger.info(f"ASR 识别响应: status_code={transcribe_response.status_code}") + if hasattr(transcribe_response, 'message'): + logger.info(f"ASR 识别消息: {transcribe_response.message}") + + if transcribe_response.status_code != 200: + error_msg = getattr(transcribe_response, 'message', 'Unknown error') + logger.error(f"ASR 识别失败: {error_msg}") + raise Exception(f"ASR 识别失败: {error_msg}") + + # 检查任务状态 + result = transcribe_response.output + logger.info(f"ASR 任务状态: {result.task_status}") + + if result.task_status == "SUCCEEDED": + logger.info("ASR 识别成功,开始解析结果...") + elif result.task_status == "FAILED": + error_code = getattr(result, 'code', 'Unknown') + error_message = getattr(result, 'message', 'Unknown error') + + logger.error(f"ASR 任务失败: {error_code} - {error_message}") + + # 提供更友好的错误信息 + if error_code == "FILE_DOWNLOAD_FAILED": + user_message = "无法下载音频文件,请检查网络连接" + elif error_code == "SUCCESS_WITH_NO_VALID_FRAGMENT": + user_message = "音频中未检测到有效语音,请确保录音时有说话内容" + elif error_code == "AUDIO_FORMAT_UNSUPPORTED": + user_message = "音频格式不支持,请使用标准格式录音" + else: + user_message = f"语音识别失败: {error_message}" + + from ..response import success_response + return success_response({"text": user_message}) + else: + logger.warning(f"ASR 任务状态未知: {result.task_status}") + from ..response import success_response + return success_response({"text": f"语音识别状态异常: {result.task_status}"}) + + # 解析识别结果 + logger.info(f"ASR 识别结果类型: {type(result)}") + logger.info(f"ASR 识别完成,结果: {result}") + + # 提取文本内容 + text_result = "" + + logger.info(f"开始解析 ASR 结果...") + logger.info(f"result 对象类型: {type(result)}") + + # 打印完整的结果对象以便调试 + try: + result_dict = vars(result) if hasattr(result, '__dict__') else result + logger.info(f"完整 result 对象: {result_dict}") + except Exception as e: + logger.info(f"无法序列化 result 对象: {e}") + logger.info(f"result 对象字符串: {str(result)}") + + # 尝试多种方式提取文本 + if hasattr(result, 'results') and result.results: + logger.info(f"找到 results 字段,长度: {len(result.results)}") + + for i, item in enumerate(result.results): + logger.info(f"处理 result[{i}]: {type(item)}") + + # 打印每个 item 的详细信息 + try: + if hasattr(item, '__dict__'): + item_dict = vars(item) + logger.info(f"result[{i}] 对象内容: {item_dict}") + else: + logger.info(f"result[{i}] 内容: {item}") + except Exception as e: + logger.info(f"无法序列化 result[{i}]: {e}") + + # 如果 item 是字典 + if isinstance(item, dict): + logger.info(f"result[{i}] 是字典,键: {list(item.keys())}") + + # 检查 transcription_url(DashScope 的实际返回格式) + if 'transcription_url' in item and item['transcription_url']: + transcription_url = item['transcription_url'] + logger.info(f"找到 transcription_url: {transcription_url}") + + try: + # 下载转录结果 + import requests + response = requests.get(transcription_url, timeout=10) + if response.status_code == 200: + transcription_data = response.json() + logger.info(f"转录数据: {transcription_data}") + + # 解析转录数据 + if 'transcripts' in transcription_data: + for transcript in transcription_data['transcripts']: + if 'text' in transcript: + text_result += transcript['text'] + " " + logger.info(f"提取转录文本: {transcript['text']}") + elif 'text' in transcription_data: + text_result += transcription_data['text'] + " " + logger.info(f"提取直接文本: {transcription_data['text']}") + + # 如果找到了文本,跳出循环 + if text_result.strip(): + break + + else: + logger.error(f"下载转录结果失败: HTTP {response.status_code}") + + except Exception as e: + logger.error(f"处理 transcription_url 失败: {e}") + + # 检查各种可能的字段 + elif 'transcription' in item and item['transcription']: + transcription = item['transcription'] + logger.info(f"找到字段 transcription: {transcription}") + + if isinstance(transcription, str): + text_result += transcription + " " + logger.info(f"提取字符串文本: {transcription}") + elif isinstance(transcription, dict): + # 检查嵌套的文本字段 + for text_key in ['text', 'content', 'transcript']: + if text_key in transcription: + text_result += str(transcription[text_key]) + " " + logger.info(f"提取嵌套文本: {transcription[text_key]}") + break + + # 检查直接的 text 字段 + elif 'text' in item and item['text']: + text_result += item['text'] + " " + logger.info(f"提取 item 字典文本: {item['text']}") + + # 如果 item 是对象 + else: + # 检查各种可能的属性 + for attr in ['transcription', 'text', 'transcript', 'content']: + if hasattr(item, attr): + value = getattr(item, attr) + if value: + logger.info(f"找到属性 {attr}: {value}") + if isinstance(value, str): + text_result += value + " " + logger.info(f"提取属性文本: {value}") + break + + # 如果 results 中没有找到文本,检查顶级字段 + if not text_result: + logger.info("未从 results 提取到文本,检查顶级字段") + + for attr in ['text', 'transcription', 'transcript', 'content']: + if hasattr(result, attr): + value = getattr(result, attr) + if value: + logger.info(f"找到顶级属性 {attr}: {value}") + text_result = str(value) + break + + # 如果还是没有找到,尝试从原始响应中提取 + if not text_result: + logger.warning("所有标准方法都未能提取到文本") + logger.info("尝试从原始响应中查找文本...") + + # 将整个结果转换为字符串并查找可能的文本 + result_str = str(result) + logger.info(f"结果字符串: {result_str}") + + # 简单的文本提取逻辑 + if "text" in result_str.lower(): + logger.info("在结果字符串中发现 'text' 关键字") + # 这里可以添加更复杂的文本提取逻辑 + text_result = "检测到语音内容,但解析格式需要调整" + else: + text_result = "语音识别成功,但未能解析文本内容" + + # 清理文本 + text_result = text_result.strip() + + if not text_result: + logger.warning("ASR 未识别到文本内容") + logger.info(f"完整的 result 对象: {vars(result) if hasattr(result, '__dict__') else result}") + text_result = f"未识别到语音内容({expected_duration:.1f}秒音频)" + + logger.info(f"最终 ASR 识别结果: {text_result}") + + from ..response import success_response + return success_response({"text": text_result}) + + finally: + # 清理 OSS 上的临时文件 + try: + delete_audio_file(file_url) + logger.info("OSS 临时文件已清理") + except Exception as e: + logger.warning(f"清理 OSS 文件失败: {e}") + + except Exception as asr_error: + logger.error(f"DashScope ASR 调用失败: {asr_error}", exc_info=True) + + # 如果 ASR 失败,返回有意义的测试文本 + error_msg = str(asr_error) + if "OSS" in error_msg: + test_text = f"OSS 配置问题,收到 {expected_duration:.1f}秒 音频" + elif "Transcription" in error_msg: + test_text = f"ASR 服务异常,收到 {expected_duration:.1f}秒 音频" + else: + test_text = f"ASR 处理失败,收到 {expected_duration:.1f}秒 音频" + + logger.info(f"返回备用文本: {test_text}") + + from ..response import success_response + return success_response({"text": test_text}) + + except HTTPException: + raise + except Exception as e: + logger.error(f"ASR 处理错误: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"ASR 处理失败: {str(e)}") + + @router.websocket("/call") async def voice_call(websocket: WebSocket): try: @@ -594,10 +1158,13 @@ async def voice_call(websocket: WebSocket): while True: msg = await websocket.receive() if "bytes" in msg and msg["bytes"] is not None: - await session.feed_audio(msg["bytes"]) + audio_data = msg["bytes"] + logger.info(f"📨 收到二进制消息,大小: {len(audio_data)} 字节") + await session.feed_audio(audio_data) elif "text" in msg and msg["text"]: # 简单心跳/信令 text = msg["text"].strip() + logger.info(f"📨 收到文本消息: {text}") lower_text = text.lower() if lower_text in ("mic_on", "ptt_on"): await session.set_mic_enabled(True) @@ -606,6 +1173,7 @@ async def voice_call(websocket: WebSocket): elif text == "ping": await websocket.send_text("pong") elif text in ("end", "stop", "flush"): + logger.info("📥 收到结束信号,调用 finalize_asr") session.finalize_asr() await session.send_signal({"type": "info", "msg": "ASR stopped manually"}) else: diff --git a/simple_account_check.py b/simple_account_check.py new file mode 100644 index 0000000..344dc84 --- /dev/null +++ b/simple_account_check.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +简单的阿里云账号查询 +""" + +import os +from dotenv import load_dotenv + +# 加载环境变量 +load_dotenv() + +def main(): + access_key_id = os.getenv('ALIYUN_OSS_ACCESS_KEY_ID') + access_key_secret = os.getenv('ALIYUN_OSS_ACCESS_KEY_SECRET') + + print(f"AccessKeyId: {access_key_id}") + print(f"AccessKeySecret: {access_key_secret[:8]}***") + + try: + import oss2 + + # 尝试不同的 endpoint 来列出 buckets + endpoints = [ + 'https://oss-cn-hangzhou.aliyuncs.com', + 'https://oss-cn-beijing.aliyuncs.com', + 'https://oss-cn-qingdao.aliyuncs.com', + 'https://oss-cn-shenzhen.aliyuncs.com' + ] + + auth = oss2.Auth(access_key_id, access_key_secret) + + for endpoint in endpoints: + try: + print(f"\n尝试 endpoint: {endpoint}") + service = oss2.Service(auth, endpoint) + buckets = service.list_buckets() + + print(f"✅ 成功连接!找到 {len(buckets.buckets)} 个 Bucket:") + for bucket in buckets.buckets: + print(f" - {bucket.name} (区域: {bucket.location})") + + break + + except Exception as e: + print(f"❌ 失败: {str(e)[:100]}...") + continue + + except ImportError: + print("请安装: pip install oss2") + except Exception as e: + print(f"错误: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/test_asr_endpoint.py b/test_asr_endpoint.py new file mode 100644 index 0000000..2585267 --- /dev/null +++ b/test_asr_endpoint.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +测试修复后的 ASR 端点 +""" + +import requests +import os +from dotenv import load_dotenv + +# 加载环境变量 +load_dotenv() + +def test_asr_endpoint(): + """测试 ASR 端点""" + try: + # 创建一个测试音频文件 + import wave + import numpy as np + + # 生成3秒的测试音频(16kHz, 单声道) + sample_rate = 16000 + duration = 3 + frequency = 440 # A4音符 + + t = np.linspace(0, duration, sample_rate * duration, False) + audio_data = np.sin(2 * np.pi * frequency * t) * 0.3 + audio_data = (audio_data * 32767).astype(np.int16) + + # 保存为WAV文件 + with wave.open('test_audio.wav', 'wb') as wav_file: + wav_file.setnchannels(1) # 单声道 + wav_file.setsampwidth(2) # 16-bit + wav_file.setframerate(sample_rate) # 16kHz + wav_file.writeframes(audio_data.tobytes()) + + print("✅ 创建测试音频文件") + + # 测试ASR端点 + url = "http://192.168.1.141:30101/voice/call/asr" + + # 获取token(如果需要) + token = os.getenv('TEST_TOKEN', '') + headers = {} + if token: + headers['Authorization'] = f'Bearer {token}' + + print(f"🚀 测试 ASR 端点: {url}") + + with open('test_audio.wav', 'rb') as f: + files = {'audio': ('test_audio.wav', f, 'audio/wav')} + + print("📤 发送请求...") + response = requests.post(url, files=files, headers=headers, timeout=60) + + print(f"📊 响应状态码: {response.status_code}") + print(f"📋 响应内容: {response.text}") + + if response.status_code == 200: + data = response.json() + if data.get('code') == 1 and data.get('data', {}).get('text'): + print(f"✅ ASR 成功: {data['data']['text']}") + return True + else: + print(f"⚠️ ASR 响应格式异常: {data}") + return True # 仍然算成功,因为没有超时 + else: + print(f"❌ ASR 请求失败: {response.status_code}") + return False + + except requests.exceptions.Timeout: + print("❌ 请求超时") + return False + except Exception as e: + print(f"❌ 测试失败: {e}") + return False + finally: + # 清理测试文件 + try: + os.remove('test_audio.wav') + except: + pass + +if __name__ == "__main__": + print("🚀 开始测试修复后的 ASR 端点...") + + if test_asr_endpoint(): + print("🎉 ASR 端点测试成功!") + else: + print("💥 ASR 端点测试失败!") \ No newline at end of file diff --git a/test_current_asr.py b/test_current_asr.py new file mode 100644 index 0000000..65efc29 --- /dev/null +++ b/test_current_asr.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +测试当前的 ASR 功能 +使用真实的语音文件 +""" + +import os +import requests +from dotenv import load_dotenv + +# 加载环境变量 +load_dotenv() + +def test_current_asr(): + """测试当前的 ASR 端点""" + try: + # 创建一个简单的测试音频文件(实际应该是真实录音) + import wave + import struct + import math + + # 创建包含语音的测试音频 + sample_rate = 16000 + duration = 2.0 # 2秒 + + # 生成一个更复杂的音频信号(模拟语音) + samples = [] + for i in range(int(sample_rate * duration)): + t = i / sample_rate + # 混合多个频率,模拟语音 + sample = int(16000 * ( + 0.3 * math.sin(2 * math.pi * 200 * t) + # 基频 + 0.2 * math.sin(2 * math.pi * 400 * t) + # 谐波 + 0.1 * math.sin(2 * math.pi * 800 * t) + # 高频 + 0.05 * (2 * (t * 1000 % 1) - 1) # 噪音 + )) + samples.append(max(-32767, min(32767, sample))) + + # 创建 WAV 文件 + wav_filename = "test_speech.wav" + with wave.open(wav_filename, 'w') as wav_file: + wav_file.setnchannels(1) # 单声道 + wav_file.setsampwidth(2) # 16-bit + wav_file.setframerate(sample_rate) # 16kHz + + # 写入音频数据 + for sample in samples: + wav_file.writeframes(struct.pack(' { + console.log('📱 录音权限检查结果:', result) + + if (result.deniedAlways && result.deniedAlways.length > 0) { + console.error('❌ 录音权限被永久拒绝') + uni.showModal({ + title: '权限不足', + content: '录音权限被拒绝,请在设置中手动开启录音权限', + showCancel: false + }) + } else if (result.denied && result.denied.length > 0) { + console.warn('⚠️ 录音权限被临时拒绝') + uni.showToast({ + title: '需要录音权限才能使用语音功能', + icon: 'none', + duration: 3000 + }) + } else { + console.log('✅ 录音权限已获取') + } + }, (error) => { + console.error('❌ 权限检查失败:', error) + }) + // #endif + + // #ifndef APP-PLUS + console.log('📱 非 APP 平台,跳过权限检查') + // #endif + }, getCallDuration() { uni.request({ url: baseURLPy + '/voice/call/duration', @@ -313,160 +353,12 @@ console.log('当前时间:', new Date().toLocaleTimeString()) }) - // 监听录音错误 - recorderManager.onError((err) => { - console.error('❌ 录音错误:', err) - console.error('错误详情:', JSON.stringify(err)) - uni.showToast({ - title: '录音失败: ' + (err.errMsg || '未知错误'), - icon: 'none' - }) - this.isRecording = false - }) - - // 监听录音停止 - 作为备用方案 - recorderManager.onStop((res) => { - console.log('⏹️ 录音已停止') - console.log('📋 完整的 res 对象:', JSON.stringify(res)) - console.log('📁 文件路径:', res.tempFilePath) - console.log('⏱️ 录音时长:', res.duration, 'ms') - console.log('📦 文件大小:', res.fileSize, 'bytes') - - // 检查录音是否有效 - if (!res.tempFilePath) { - console.error('❌ 没有录音文件路径!') - uni.showToast({ - title: '录音失败:没有生成文件', - icon: 'none' - }) - return - } - - // 检查录音时长 - if (res.duration !== undefined && res.duration < 500) { - console.error('❌ 录音时长太短:', res.duration, 'ms') - uni.showToast({ - title: '录音太短,请至少说 2 秒', - icon: 'none' - }) - return - } - - console.log('✅ 录音文件路径有效,准备读取文件...') - - // 检查 WebSocket 状态 - console.log('🔍 检查 WebSocket 状态...') - console.log('🔍 this.socketTask 是否存在:', !!this.socketTask) - - if (!this.socketTask) { - console.error('❌ socketTask 不存在') - uni.showToast({ - title: 'WebSocket 未连接', - icon: 'none' - }) - return - } - - console.log('🔌 WebSocket 状态:', this.socketTask.readyState) - console.log('🔌 状态说明: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED') - - if (this.socketTask.readyState !== 1) { - console.error('❌ WebSocket 未连接,无法发送,状态:', this.socketTask.readyState) - uni.showToast({ - title: 'WebSocket 未连接,请重新进入', - icon: 'none' - }) - return - } - - console.log('✅ WebSocket 状态正常,开始读取文件...') - - // 处理文件路径(确保是绝对路径) - let filePath = res.tempFilePath - // 如果是相对路径,转换为绝对路径 - if (!filePath.startsWith('/') && !filePath.includes('://')) { - // #ifdef APP-PLUS - filePath = plus.io.convertLocalFileSystemURL(filePath) - console.log('📁 转换后的绝对路径:', filePath) - // #endif - } - - // 读取文件内容 - const fs = uni.getFileSystemManager() - console.log('📂 获取文件系统管理器:', fs ? '成功' : '失败') - console.log('📁 准备读取文件:', filePath) - - // 添加超时保护 - let readTimeout = setTimeout(() => { - console.error('❌ 文件读取超时(5秒)') - uni.showToast({ - title: '文件读取超时', - icon: 'none' - }) - }, 5000) - - fs.readFile({ - filePath: filePath, - // 不指定 encoding,让它返回 ArrayBuffer - success: (fileRes) => { - clearTimeout(readTimeout) - console.log('✅ 文件读取成功') - console.log('📊 数据类型:', typeof fileRes.data) - console.log('📊 是否为 ArrayBuffer:', fileRes.data instanceof ArrayBuffer) - - const actualSize = fileRes.data.byteLength || fileRes.data.length - console.log('📊 实际文件大小:', actualSize, 'bytes') - console.log('📊 预计录音时长:', (actualSize / 32000).toFixed(2), '秒') - - // 验证文件大小 - if (actualSize < 32000) { - console.error('❌ 文件太小(< 1秒),可能录音失败') - uni.showToast({ - title: '录音文件太小,请重试', - icon: 'none' - }) - return - } - - // 再次检查 WebSocket 状态 - if (this.socketTask.readyState !== 1) { - console.error('❌ 读取文件后 WebSocket 已断开') - return - } - - // 确保数据是 ArrayBuffer - let audioData = fileRes.data - if (!(audioData instanceof ArrayBuffer)) { - console.error('❌ 数据不是 ArrayBuffer,类型:', typeof audioData) - uni.showToast({ - title: '音频数据格式错误', - icon: 'none' - }) - return - } - - // 分片发送音频数据 - this.sendAudioInChunks(audioData) - }, - fail: (err) => { - clearTimeout(readTimeout) - console.error('❌ 文件读取失败:', err) - console.error('错误代码:', err.errCode) - console.error('错误信息:', err.errMsg) - console.error('完整错误:', JSON.stringify(err)) - console.error('尝试读取的文件路径:', filePath) - uni.showToast({ - title: '文件读取失败: ' + (err.errMsg || '未知错误'), - icon: 'none' - }) - } - }) - }) - - // 监听音频帧 - 实时发送 + // 监听音频帧 - 实时发送(关键!) let frameCount = 0 + let hasReceivedFrames = false // 标记是否收到过音频帧 recorderManager.onFrameRecorded((res) => { frameCount++ + hasReceivedFrames = true const { frameBuffer, isLastFrame } = res console.log(`🎤 收到音频帧 #${frameCount}, isTalking:`, this.isTalking, 'frameBuffer size:', frameBuffer ? frameBuffer.byteLength : 'null') @@ -493,6 +385,223 @@ } }) + // 添加测试:在录音开始后检查是否收到音频帧 + setTimeout(() => { + if (!hasReceivedFrames) { + console.warn('⚠️ 警告:录音开始 2 秒后仍未收到音频帧,onFrameRecorded 可能不工作') + console.warn('⚠️ 将使用备用方案:录音结束后发送完整文件') + } + }, 2000) + + // 监听录音错误 + recorderManager.onError((err) => { + console.error('❌ 录音错误:', err) + console.error('错误详情:', JSON.stringify(err)) + uni.showToast({ + title: '录音失败: ' + (err.errMsg || '未知错误'), + icon: 'none' + }) + this.isRecording = false + }) + + // 监听录音停止 + recorderManager.onStop((res) => { + const stopTime = Date.now() + const actualDuration = this.recordStartTime ? stopTime - this.recordStartTime : 0 + + console.log('⏹️ 录音已停止') + console.log('📅 录音停止时间:', new Date(stopTime).toLocaleTimeString()) + console.log('⏱️ 实际录音时长:', actualDuration, 'ms') + console.log('📋 系统报告时长:', res.duration, 'ms') + console.log('📦 文件大小:', res.fileSize, 'bytes') + console.log('📁 文件路径:', res.tempFilePath) + console.log('📊 是否收到过音频帧:', hasReceivedFrames) + + // 计算预期的录音时长 + if (res.fileSize && res.fileSize > 0) { + // PCM 16kHz 单声道 16bit: 每秒 32000 字节 + const calculatedDuration = (res.fileSize / 32000) * 1000 // 转换为毫秒 + console.log('📊 根据文件大小计算的时长:', calculatedDuration.toFixed(0), 'ms') + + if (actualDuration > 1000) { // 只有当实际录音时长超过1秒时才比较 + const timeDiff = Math.abs(actualDuration - calculatedDuration) + if (timeDiff > 500) { + console.warn('⚠️ 录音数据丢失严重!') + console.warn('⚠️ 实际录音时长:', actualDuration, 'ms') + console.warn('⚠️ 系统报告时长:', res.duration, 'ms') + console.warn('⚠️ 文件大小计算时长:', calculatedDuration.toFixed(0), 'ms') + console.warn('⚠️ 数据丢失率:', ((actualDuration - calculatedDuration) / actualDuration * 100).toFixed(1), '%') + console.warn('⚠️ 可能的原因:uni-app Android 录音 API 问题、设备性能限制、或系统录音限制') + + // 提示用户数据丢失问题 + uni.showToast({ + title: `录音数据丢失${((actualDuration - calculatedDuration) / actualDuration * 100).toFixed(0)}%,识别可能不准确`, + icon: 'none', + duration: 3000 + }) + } + } + } + + // 如果收到过音频帧,说明实时发送工作正常,只需发送结束信号 + if (hasReceivedFrames) { + console.log('✅ 已通过实时音频帧发送,发送 ptt_off 信号') + if (this.socketTask && this.socketTask.readyState === 1) { + this.socketTask.send({ + data: 'ptt_off', + success: () => { + console.log('✅ ptt_off 信号发送成功') + } + }) + } + } else { + // 备用方案:onFrameRecorded 不工作,通过WebSocket发送完整文件 + console.warn('⚠️ 未收到音频帧,使用备用方案:通过WebSocket发送完整文件') + + if (!res.tempFilePath) { + console.error('❌ 没有录音文件') + return + } + + // 使用之前成功的文件读取方法 + let filePath = res.tempFilePath + if (!filePath.startsWith('/') && !filePath.includes('://')) { + if (typeof plus !== 'undefined' && plus.io) { + filePath = plus.io.convertLocalFileSystemURL(filePath) + } + } + + console.log('📁 读取文件:', filePath) + const that = this + + if (typeof plus !== 'undefined' && plus.io) { + plus.io.resolveLocalFileSystemURL(filePath, (entry) => { + entry.file((file) => { + const reader = new plus.io.FileReader() + reader.onload = async (e) => { + const dataUrl = e.target.result + const base64 = dataUrl.split(',')[1] + const binaryString = atob(base64) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + + console.log('✅ 文件读取成功,开始通过WebSocket发送') + console.log('📊 音频数据大小:', bytes.length, 'bytes') + + // 检查WebSocket连接状态 + if (!that.socketTask || that.socketTask.readyState !== 1) { + console.error('❌ WebSocket未连接,无法发送音频') + uni.showToast({ + title: 'WebSocket未连接', + icon: 'none' + }) + return + } + + // 显示处理进度 + uni.showLoading({ + title: '识别中...', + mask: true + }) + + try { + // 通过WebSocket发送完整音频文件 + // 分块发送大文件,避免WebSocket消息过大 + const chunkSize = 8192 // 8KB per chunk + const totalChunks = Math.ceil(bytes.length / chunkSize) + + console.log(`📦 将音频分为 ${totalChunks} 个片段发送`) + + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize + const end = Math.min(start + chunkSize, bytes.length) + const chunk = bytes.slice(start, end) + + console.log(`📤 发送第 ${i + 1}/${totalChunks} 片,大小: ${chunk.byteLength} bytes`) + + await new Promise((resolve, reject) => { + that.socketTask.send({ + data: chunk.buffer, + success: () => { + console.log(`✅ 第 ${i + 1} 片发送成功`) + resolve() + }, + fail: (err) => { + console.error(`❌ 第 ${i + 1} 片发送失败:`, err) + reject(err) + } + }) + }) + + // 小延迟避免发送过快 + if (i < totalChunks - 1) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + } + + // 发送结束标记,触发ASR处理 + await new Promise((resolve, reject) => { + console.log('📤 发送结束标记 "end"') + that.socketTask.send({ + data: 'end', + success: () => { + console.log('✅ 结束标记发送成功') + resolve() + }, + fail: (err) => { + console.error('❌ 结束标记发送失败:', err) + reject(err) + } + }) + }) + + console.log('🎉 完整音频文件已通过WebSocket发送完成') + uni.hideLoading() + + } catch (error) { + console.error('❌ WebSocket发送失败:', error) + uni.hideLoading() + uni.showToast({ + title: '发送失败: ' + error.message, + icon: 'none' + }) + } + } + + reader.onerror = (error) => { + console.error('❌ 文件读取失败:', error) + } + + reader.readAsDataURL(file) + }, (error) => { + console.error('❌ 获取文件失败:', error) + }) + }, (error) => { + console.error('❌ 解析文件路径失败:', error) + }) + } else { + console.error('❌ plus.io 不可用') + } + } + }) + + // 录音停止监听器 + recorderManager.onStop((res) => { + console.log('⏹️ 录音已停止') + console.log('📅 录音停止时间:', new Date().toLocaleString()) + console.log('⏱️ 实际录音时长:', res.duration, 'ms') + console.log('📋 系统报告时长:', res.duration, 'ms') + console.log('📦 文件大小:', res.fileSize, 'bytes') + console.log('📁 文件路径:', res.tempFilePath) + console.log('📊 是否收到过音频帧:', hasReceivedFrames) + + this.isRecording = false + hasReceivedFrames = false // 重置标记 + frameCount = 0 // 重置计数 + }) + console.log('✅ 所有录音监听器已设置') }, // 切换麦克风权限开关 @@ -649,19 +758,23 @@ console.log('🎙️ 启动 recorderManager') try { - // 使用 PCM 格式匹配服务器期望 + // 使用最稳定的录音配置 const recorderOptions = { duration: 600000, // 10 分钟 sampleRate: 16000, // 必须 16kHz,匹配服务器 numberOfChannels: 1, // 单声道 - encodeBitRate: 48000, - format: 'pcm', // 使用 PCM 格式,匹配服务器 - frameSize: 5, // 启用 onFrameRecorded,每 5 帧回调一次 - audioSource: 'auto' + encodeBitRate: 128000, // 128kbps,适合语音 + format: 'mp3', // 改用 MP3 格式,可能更稳定 + audioSource: 'mic' // 明确指定麦克风作为音频源 + // 完全移除 frameSize,避免任何实时处理 } console.log('📋 录音参数:', JSON.stringify(recorderOptions)) - console.log('⚠️ 注意:启用了实时音频帧传输(frameSize: 5)') + console.log('⚠️ 注意:使用最稳定的完整文件模式,无实时处理') + + // 记录开始时间 + this.recordStartTime = Date.now() + console.log('📅 录音开始时间:', new Date(this.recordStartTime).toLocaleTimeString()) recorderManager.start(recorderOptions) console.log('✅ recorderManager.start 已调用') @@ -1235,9 +1348,7 @@ }