This commit is contained in:
Ubuntu
2026-07-03 16:58:28 +08:00
commit 32b43f85bd
6240 changed files with 1987678 additions and 0 deletions
+589
View File
@@ -0,0 +1,589 @@
#!/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/<filename>', methods=['GET'])
def get_recording(filename):
"""获取单个录音文件"""
return send_from_directory(UPLOAD_FOLDER, filename)
@app.route('/api/recordings/<filename>', 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('/<path:path>')
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
)