Files
nanocode/audio-recorder/backend/app.py
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

590 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
)