#!/usr/bin/env python3 """ 录音后端服务 - 语音转文字 (FunASR + Vosk 双引擎) 优先 FunASR(阿里达摩院,中文识别效果好),失败回退 Vosk(轻量离线) """ from flask import Flask, request, jsonify, send_from_directory from flask_cors import CORS from werkzeug.utils import secure_filename import os import ssl import subprocess import wave import json import warnings from datetime import datetime warnings.filterwarnings('ignore') app = Flask(__name__) CORS(app) # 配置 UPLOAD_FOLDER = '/home/ubuntu/nanocode/audio-recorder/uploads' VOSK_MODEL_PATH = '/home/ubuntu/nanocode/audio-recorder/models/cn-model' ALLOWED_EXTENSIONS = {'webm', 'mp4', 'wav', 'mp3', 'ogg'} MAX_CONTENT_LENGTH = 100 * 1024 * 1024 # 100MB app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH os.makedirs(UPLOAD_FOLDER, exist_ok=True) # 全局模型(延迟加载) funasr_model = None vosk_model = None def get_funasr_model(): """加载 FunASR 模型(阿里,中文识别效果好)""" global funasr_model if funasr_model is None: try: from funasr import AutoModel print("🔄 加载 FunASR 模型(阿里达摩院)...") funasr_model = AutoModel( model="paraformer-zh", vad_model="fsmn-vad", punc_model="ct-punc", ) print("✅ FunASR 模型加载完成") except Exception as e: print(f"⚠️ FunASR 加载失败: {e}") return None return funasr_model def get_vosk_model(): """加载 Vosk 模型(轻量离线,备用)""" global vosk_model if vosk_model is None: try: from vosk import Model if os.path.exists(VOSK_MODEL_PATH): print(f"🔄 加载 Vosk 模型...") vosk_model = Model(VOSK_MODEL_PATH) print("✅ Vosk 模型加载完成") else: print(f"⚠️ Vosk 模型不存在: {VOSK_MODEL_PATH}") return None except Exception as e: print(f"⚠️ Vosk 加载失败: {e}") return None return vosk_model def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS def convert_to_wav(input_path, output_path): """将音频转换为 WAV 格式(16kHz, 单声道)""" try: cmd = [ 'ffmpeg', '-y', '-i', input_path, '-ar', '16000', '-ac', '1', '-f', 'wav', output_path ] subprocess.run(cmd, check=True, capture_output=True, timeout=30) return os.path.exists(output_path) except Exception as e: print(f"转换失败: {e}") return False def transcribe_with_funasr(wav_path): """使用 FunASR 转写(首选)""" model = get_funasr_model() if not model: return None, "FunASR 模型未加载" try: res = model.generate(input=wav_path, batch_size_s=300) text = res[0]["text"] if res and len(res) > 0 else "" return text, None except Exception as e: return None, f"FunASR 错误: {str(e)}" def transcribe_with_vosk(wav_path): """使用 Vosk 转写(备用)""" from vosk import KaldiRecognizer model = get_vosk_model() if not model: return None, "Vosk 模型未加载" try: wf = wave.open(wav_path, 'rb') if wf.getnchannels() != 1 or wf.getsampwidth() != 2 or wf.getcomptype() != 'NONE': return None, "音频格式不支持" rec = KaldiRecognizer(model, wf.getframerate()) rec.SetWords(True) results = [] while True: data = wf.readframes(4000) if len(data) == 0: break if rec.AcceptWaveform(data): result = json.loads(rec.Result()) if result.get('text'): results.append(result['text']) final_result = json.loads(rec.FinalResult()) if final_result.get('text'): results.append(final_result['text']) wf.close() return ' '.join(results), None except Exception as e: return None, f"Vosk 错误: {str(e)}" def transcribe_audio_file(audio_path): """ 转写音频文件 策略:先尝试 FunASR(效果好),失败则回退 Vosk """ # 转换为 WAV wav_path = audio_path + '.wav' if not convert_to_wav(audio_path, wav_path): return None, "音频格式转换失败" try: # 1. 优先尝试 FunASR text, error = transcribe_with_funasr(wav_path) if text: return text, "funasr" # 2. 回退到 Vosk print(f"⚠️ FunASR 失败({error}),回退到 Vosk...") text, error = transcribe_with_vosk(wav_path) if text: return text, "vosk" return None, error or "识别失败" finally: # 清理临时文件 if os.path.exists(wav_path): os.remove(wav_path) @app.route('/api/upload', methods=['POST']) def upload_audio(): """接收音频文件上传""" if 'audio' not in request.files: return jsonify({'error': '没有文件'}), 400 file = request.files['audio'] if file.filename == '': return jsonify({'error': '文件名为空'}), 400 if file and allowed_file(file.filename): timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') filename = f"recording_{timestamp}.webm" filepath = os.path.join(UPLOAD_FOLDER, filename) file.save(filepath) file_size = os.path.getsize(filepath) return jsonify({ 'success': True, 'filename': filename, 'filepath': filepath, 'size': file_size, 'size_human': f"{file_size / 1024:.1f} KB", 'timestamp': timestamp }), 200 return jsonify({'error': '不支持的文件类型'}), 400 @app.route('/api/transcribe', methods=['POST']) def transcribe(): """语音转文字 API""" if 'audio' not in request.files: return jsonify({'success': False, 'error': '没有音频文件'}), 400 file = request.files['audio'] if file.filename == '': return jsonify({'success': False, 'error': '文件名为空'}), 400 try: # 保存上传的音频 timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') filename = f"recording_{timestamp}.webm" filepath = os.path.join(UPLOAD_FOLDER, filename) file.save(filepath) # 转写 text, engine = transcribe_audio_file(filepath) if text is None: return jsonify({ 'success': False, 'error': engine, # 错误信息 'filename': filename }), 500 return jsonify({ 'success': True, 'text': text or '(未识别到语音)', 'engine': engine, # 显示使用的引擎 'filename': filename }) except Exception as e: print(f"转写异常: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 @app.route('/api/recordings', methods=['GET']) def list_recordings(): """列出所有录音文件""" recordings = [] for filename in sorted(os.listdir(UPLOAD_FOLDER), reverse=True): if allowed_file(filename): filepath = os.path.join(UPLOAD_FOLDER, filename) stat = os.stat(filepath) recordings.append({ 'filename': filename, 'size': stat.st_size, 'size_human': f"{stat.st_size / 1024:.1f} KB", 'created': datetime.fromtimestamp(stat.st_mtime).isoformat() }) return jsonify({ 'count': len(recordings), 'recordings': recordings }) @app.route('/api/recordings/', methods=['GET']) def get_recording(filename): """获取单个录音文件""" return send_from_directory(UPLOAD_FOLDER, filename) @app.route('/api/recordings/', methods=['DELETE']) def delete_recording(filename): """删除录音文件""" filepath = os.path.join(UPLOAD_FOLDER, secure_filename(filename)) if os.path.exists(filepath): os.remove(filepath) return jsonify({'success': True, 'message': '已删除'}) return jsonify({'error': '文件不存在'}), 404 @app.route('/api/health', methods=['GET']) def health_check(): """健康检查""" vosk_exists = os.path.exists(VOSK_MODEL_PATH) return jsonify({ 'status': 'ok', 'vosk_model': 'available' if vosk_exists else 'not_found', 'funasr_model': 'lazy_load', # 懒加载 'upload_folder': UPLOAD_FOLDER, 'recordings_count': len([f for f in os.listdir(UPLOAD_FOLDER) if allowed_file(f)]) }) @app.route('/api/analyze', methods=['POST']) def analyze_text(): """分析转写文字内容""" try: data = request.get_json() if not data or 'text' not in data: return jsonify({'success': False, 'error': '缺少 text 参数'}), 400 text = data['text'].strip() if len(text) < 10: return jsonify({'success': False, 'error': '文字内容太短(至少10个字符)'}), 400 # 获取分析类型,默认为 general analysis_type = data.get('type', 'general') # 调用大模型分析 analysis = call_llm_for_analysis(text, analysis_type) return jsonify({ 'success': True, 'analysis': analysis, 'type': analysis_type }) except Exception as e: print(f"分析异常: {e}") return jsonify({'success': False, 'error': str(e)}), 500 def get_analysis_prompt(analysis_type): """根据分析类型返回对应的系统提示语""" prompts = { 'meeting': """你是一位专业的会议纪要助手。请对提供的会议内容进行整理,要求: ## 会议纪要结构: 📋 会议基本信息: - 会议主题/议题(根据内容推断) - 参会人员(提取提到的角色) - 会议时间(如有提及) 📌 会议决议/共识: 逐条列出会议达成的主要决议,每条包含: - 决议事项 - 具体内容和依据 ✅ 行动项(TODO): 明确列出需要执行的任务: - 任务内容 - 负责人(如提及) - 时间节点/截止日期(如提及) ⚠️ 待确认/风险点: - 需要进一步明确的事项 - 提到的风险或注意事项 💬 其他重要讨论: 记录其他有价值的讨论内容 ⚠️ 要求: - 不歪曲原意 - 行动项必须具体可执行 - 使用中文回答""", 'weekly': """你是一位专业的工作周报助手。请对提供的工作内容进行周报整理,要求: ## 周报结构: 📊 本周工作完成情况: 逐条列出已完成的工作,每条包含: - 工作内容 - 完成情况/成果 - 相关数据或指标(如有) 🔄 本周进行中工作: - 工作内容 - 当前进度 - 预计完成时间 ❓ 遇到的问题/困难: - 问题描述 - 已尝试的解决方案 - 需要的支持或资源 📅 下周工作计划: - 计划完成的工作项 - 优先级排序 - 预期目标 💡 其他事项: - 心得体会 - 建议或想法 ⚠️ 要求: - 量化成果(数字、百分比等) - 问题要具体 - 计划要可衡量 - 使用中文回答""", 'daily': """你是一位专业的工作日报助手。请对提供的工作内容进行日报整理,要求: ## 日报结构: ✅ 今日完成工作: 逐条列出今日完成的工作,按重要性排序: - 工作事项 - 完成结果 - 耗时(如可推断) 🔄 进行中工作: - 工作内容 - 今日进展 - 明日继续计划 ❓ 今日问题/阻塞: - 遇到的问题 - 影响程度 - 尝试的解决方案 📅 明日工作计划: - 计划工作项 - 预期目标 - 需要协调的事项 🙋 需要支持: - 需要领导/同事协助的事项 - 需要的资源 ⚠️ 要求: - 简洁明了 - 问题不回避 - 计划具体 - 使用中文回答""", 'general': """你是一位专业的内容分析专家。请对提供的文字内容进行深度分析和总结,要求: ## 分析原则: 1. 忠实原文:不歪曲、不遗漏原文的核心信息 2. 深入挖掘:提取隐含的观点、态度和意图 3. 结构清晰:层次分明,便于阅读理解 4. 内容充实:每个要点都要有具体支撑 ## 输出格式: 📌 核心要点(3-5条): 每条要点需包含: - 要点概括(一句话) - 具体内容(详细说明,引用原文关键句) 🔍 深度分析: - 说话者/作者的主要观点或立场 - 情绪态度(积极/消极/中性) - 潜在的意图或目的 💡 关键信息提取: - 涉及的人物、时间、地点 - 重要数据或事实 - 行动项或待办事项 📝 详细总结: 用3-5句话全面概括全文内容 ⚠️ 注意事项: - 不要添加原文没有的信息 - 不要过度解读,保持客观 - 使用中文回答""" } return prompts.get(analysis_type, prompts['general']) def call_llm_for_analysis(text, analysis_type='general'): """调用 Moonshot 大模型进行内容分析总结""" system_prompt = get_analysis_prompt(analysis_type) try: # 使用 Moonshot API import openai api_key = os.environ.get('MOONSHOT_API_KEY') if not api_key: print("Moonshot API key 未配置") return simple_analysis(text) # Moonshot 使用 OpenAI 兼容接口 client = openai.OpenAI( api_key=api_key, base_url="https://api.moonshot.cn/v1" ) # 根据类型调整提示语 type_names = { 'meeting': '会议', 'weekly': '周报', 'daily': '日报', 'general': '内容' } type_name = type_names.get(analysis_type, '内容') response = client.chat.completions.create( model="moonshot-v1-8k", # 使用 8k 上下文模型 messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": f"请对以下{type_name}进行整理分析:\n\n{text}"} ], temperature=0.3, max_tokens=2500 # 增加token数以获得更详细的回答 ) result = response.choices[0].message.content print(f"Moonshot 分析完成,返回 {len(result)} 字符") return result except Exception as e: print(f"Moonshot API 调用失败: {e}") # 失败时回退到本地分析 return simple_analysis(text) def simple_analysis(text): """本地简单分析(无需外部 API)""" lines = text.split('\n') sentences = [s.strip() for s in text.replace('。', '\n').replace('!', '\n').replace('?', '\n').split('\n') if s.strip()] # 提取关键句子(长度适中,包含信息量) key_sentences = [s for s in sentences if 20 <= len(s) <= 100][:5] # 统计信息 char_count = len(text) word_count = len(text.split()) result = f"""📌 核心要点: """ if key_sentences: for i, sent in enumerate(key_sentences[:4], 1): result += f"{i}. {sent}\n" else: result += "1. (内容较短,未提取到明显要点)\n" result += f""" 💡 关键信息: - 总字数:约 {char_count} 字 - 句子数:约 {len(sentences)} 句 - 内容类型:{"对话/访谈" if "?" in text or "说" in text else "叙述/说明"} 📝 简要总结: {text[:100]}{"..." if len(text) > 100 else ""} """ return result.strip() @app.route('/') def index(): return send_from_directory('../frontend', 'index.html') @app.route('/') def static_files(path): return send_from_directory('../frontend', path) if __name__ == '__main__': cert_file = '/home/ubuntu/nanocode/audio-recorder/cert.pem' key_file = '/home/ubuntu/nanocode/audio-recorder/key.pem' print(f"🎙️ 录音服务启动中...") print(f"📁 上传目录: {UPLOAD_FOLDER}") print(f"🔤 Vosk 模型: {VOSK_MODEL_PATH}") print(f"🤖 FunASR: 延迟加载(首次转写时加载,约100MB内存)") # 检查证书 if os.path.exists(cert_file) and os.path.exists(key_file): print(f"🔒 HTTPS 模式: https://0.0.0.0:8891") context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) context.load_cert_chain(cert_file, key_file) app.run( host='0.0.0.0', port=8891, ssl_context=context, debug=False ) else: print(f"⚠️ 证书不存在,使用 HTTP: http://0.0.0.0:8890") app.run( host='0.0.0.0', port=8890, debug=False )