590 lines
17 KiB
Python
590 lines
17 KiB
Python
#!/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
|
||
)
|