feat: complete local version to overwrite remote

This commit is contained in:
DelLevin-Home
2026-06-16 03:30:57 +08:00
parent 1735c19f48
commit 3c78293f4d
129 changed files with 22814 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
# blueprints package
from .pin_tu import bp as pin_tu_bp
from .base64_codec import bp as base64_bp
from .down_video import bp as down_video_bp
from .fen_ci import bp as fen_ci_bp
from .content_tag import bp as content_tag_bp
from .chmod_calc import bp as chmod_calc_bp
from .json_format import bp as json_format_bp
from .qr_code import bp as qr_code_bp
from .http_status import bp as http_status_bp
from .url_parser import bp as url_parser_bp
from .token_gen import bp as token_gen_bp
from .sovits_tts import bp as sovits_tts_bp
from .stt import bp as stt_bp
from .ai_dubbing import bp as ai_dubbing_bp
from .rvc import bp as rvc_bp
__all__ = [
'pin_tu_bp', 'base64_bp', 'down_video_bp', 'fen_ci_bp', 'content_tag_bp',
'chmod_calc_bp', 'json_format_bp', 'qr_code_bp', 'http_status_bp', 'url_parser_bp', 'token_gen_bp',
'sovits_tts_bp', 'stt_bp', 'ai_dubbing_bp', 'rvc_bp',
]

View File

@@ -0,0 +1,440 @@
# -*- coding: utf-8 -*-
"""
ai-dubbing AI 配音蓝图
通过 subprocess 调用 GPT-SoVITS 自带的 runtime\python.exe 实现文本转语音
"""
import os
import sys
import json
import uuid
import glob
import struct
import subprocess
import threading
import tempfile
from flask import Blueprint, render_template, request, jsonify, send_file, after_this_request
bp = Blueprint('ai_dubbing', __name__, url_prefix='/ai-dubbing')
try:
from config import BASE_DIR
except ImportError:
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'ai_dubbing_config.json')
WORKER_SCRIPT = os.path.join(BASE_DIR, 'utils', 'sovits_worker.py')
# 子进程管理
_proc = None
_proc_lock = threading.Lock()
_proc_stderr = []
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'version': None}
# 合成请求锁
_synth_lock = threading.Lock()
# 任务结果缓存
_task_results_local = {}
TEXT_LANGUAGES = {
'zh': '中文', 'en': '英文', 'ja': '日文', 'ko': '韩文',
'yue': '粤语', 'all_zh': '全部中文', 'all_ja': '全部日文',
'all_yue': '全部粤语', 'all_ko': '全部韩文',
'auto': '多语种混合', 'auto_yue': '多语种混合(粤语)',
}
CUT_METHODS = ['cut0', 'cut1', 'cut2', 'cut3', 'cut4', 'cut5']
CUT_METHOD_NAMES = {
'cut0': '不切分', 'cut1': '凑四句一切', 'cut2': '凑50字一切',
'cut3': '按中文句号。切', 'cut4': '按英文句号.切', 'cut5': '按标点符号切',
}
def _default_config():
return {
'sovits_project_path': r'E:\AI\GPT-SoVITS-v4',
'sovits_config_yaml': r'GPT_SoVITS\configs\tts_infer.yaml',
'last_prompt_text': '', 'last_prompt_lang': 'zh',
'last_text_lang': 'zh', 'last_text_split_method': 'cut5',
'top_k': 5, 'top_p': 1.0, 'temperature': 1.0,
'batch_size': 1, 'speed_factor': 1.0, 'seed': -1,
'gpt_model_dir': '', 'sovits_model_dir': '',
'last_gpt_model': '', 'last_sovits_model': '',
'refer_audio_folder': '',
}
def _load_config():
cfg = _default_config()
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
saved = json.load(f)
cfg.update(saved)
return cfg
def _save_config(cfg):
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def _scan_models(directory, pattern):
"""扫描目录下指定格式的模型文件,返回文件名列表"""
if not directory or not os.path.isdir(directory):
return []
files = glob.glob(os.path.join(directory, pattern))
files.sort(key=lambda x: os.path.basename(x).lower())
return [os.path.basename(f) for f in files]
def _send_msg(proc, obj):
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
proc.stdin.write(struct.pack('<I', len(data)))
proc.stdin.write(data)
proc.stdin.flush()
def _recv_msg(proc):
raw_len = proc.stdout.read(4)
if not raw_len:
return None
msg_len = struct.unpack('<I', raw_len)[0]
data = proc.stdout.read(msg_len)
return json.loads(data.decode('utf-8'))
def _init_engine():
global _proc, _engine_status
if _engine_status['initialized'] and _proc and _proc.poll() is None:
return True, None
with _proc_lock:
if _engine_status['initialized'] and _proc and _proc.poll() is None:
return True, None
if _engine_status['loading']:
return False, '引擎正在加载中,请稍候...'
_engine_status['loading'] = True
_engine_status['error'] = None
cfg = _load_config()
sovits_path = cfg.get('sovits_project_path', '').strip()
sovits_config_yaml = cfg.get('sovits_config_yaml', r'GPT_SoVITS\configs\tts_infer.yaml')
if not sovits_path or not os.path.isdir(sovits_path):
_engine_status['loading'] = False
_engine_status['error'] = f'GPT-SoVITS 项目路径不存在: {sovits_path}'
return False, _engine_status['error']
runtime_python = os.path.join(sovits_path, 'runtime', 'python.exe')
if not os.path.exists(runtime_python):
_engine_status['loading'] = False
_engine_status['error'] = f'找不到 GPT-SoVITS 运行时: {runtime_python}'
return False, _engine_status['error']
try:
creationflags = 0x08000000 if os.name == 'nt' else 0
_proc = subprocess.Popen(
[runtime_python, WORKER_SCRIPT, sovits_path, sovits_config_yaml],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
creationflags=creationflags,
)
_proc_stderr.clear()
def _read_stderr():
try:
for line in _proc.stderr:
_proc_stderr.append(line.decode('utf-8', errors='replace').rstrip())
except Exception:
pass
threading.Thread(target=_read_stderr, daemon=True).start()
msg = _recv_msg(_proc)
if msg and msg.get('type') == 'ready':
_engine_status['initialized'] = True
_engine_status['version'] = msg.get('version', 'unknown')
_engine_status['error'] = None
return True, None
else:
err = msg.get('error', '未知错误') if msg else '子进程无响应'
if _proc_stderr:
err += '\n--- 日志 ---\n' + '\n'.join(_proc_stderr[-10:])
_engine_status['error'] = err
_kill_proc()
return False, err
except Exception as e:
_engine_status['error'] = str(e)
_kill_proc()
return False, str(e)
finally:
_engine_status['loading'] = False
def _kill_proc():
global _proc
if _proc:
try:
_proc.kill()
except Exception:
pass
_proc = None
def _reload_model(model_type, path):
"""向子进程发送模型重载请求model_type: 'reload_gpt''reload_sovits'"""
with _synth_lock:
if not _proc or _proc.poll() is not None:
return False, '子进程未运行'
try:
_send_msg(_proc, {'type': model_type, 'path': path})
msg = _recv_msg(_proc)
if msg and msg.get('type') == 'done':
return True, None
else:
return False, msg.get('error', '未知错误') if msg else '子进程无响应'
except Exception as e:
return False, str(e)
def _synthesize(inputs, output_path):
with _synth_lock:
if not _proc or _proc.poll() is not None:
return False, '子进程未运行'
try:
_send_msg(_proc, {
'type': 'synthesize',
'inputs': inputs,
'output_path': output_path,
})
msg = _recv_msg(_proc)
if msg and msg.get('type') == 'done':
return True, msg.get('output_path')
else:
return False, msg.get('error', '未知错误') if msg else '子进程无响应'
except Exception as e:
return False, str(e)
# ==================== 路由 ====================
@bp.route('/')
def page():
cfg = _load_config()
return render_template('ai_dubbing.html',
config=cfg, languages=TEXT_LANGUAGES,
cut_methods=CUT_METHODS, cut_method_names=CUT_METHOD_NAMES)
@bp.route('/config', methods=['GET'])
def get_config():
cfg = _load_config()
cfg['engine_status'] = _engine_status
# 扫描模型列表
gpt_dir = cfg.get('gpt_model_dir', '')
sovits_dir = cfg.get('sovits_model_dir', '')
cfg['gpt_models'] = _scan_models(gpt_dir, '*.ckpt')
cfg['sovits_models'] = _scan_models(sovits_dir, '*.pth')
# 扫描参考音频
refer_folder = cfg.get('refer_audio_folder', '')
if refer_folder and os.path.isdir(refer_folder):
AUDIO_EXTS = ('.wav', '.mp3', '.flac', '.ogg', '.aac', '.m4a', '.wma')
files = [os.path.join(refer_folder, f) for f in os.listdir(refer_folder)
if os.path.isfile(os.path.join(refer_folder, f)) and f.lower().endswith(AUDIO_EXTS)]
files.sort(key=lambda x: os.path.basename(x).lower())
cfg['audio_files'] = files
else:
cfg['audio_files'] = []
return jsonify(cfg)
@bp.route('/config', methods=['POST'])
def save_config():
data = request.get_json()
cfg = _load_config()
for key in ('sovits_project_path', 'sovits_config_yaml',
'last_prompt_text', 'last_prompt_lang',
'last_text_lang', 'last_text_split_method',
'top_k', 'top_p', 'temperature', 'batch_size', 'speed_factor', 'seed',
'gpt_model_dir', 'sovits_model_dir',
'last_gpt_model', 'last_sovits_model', 'refer_audio_folder'):
if key in data:
cfg[key] = data[key]
_save_config(cfg)
return jsonify({'success': True})
@bp.route('/scan-audio-folder', methods=['POST'])
def scan_audio_folder():
data = request.get_json()
folder = (data.get('folder') or '').strip()
if not folder or not os.path.isdir(folder):
return jsonify({'success': False, 'error': '文件夹不存在'})
AUDIO_EXTS = ('.wav', '.mp3', '.flac', '.ogg', '.aac', '.m4a', '.wma')
files = [os.path.join(folder, f) for f in os.listdir(folder)
if os.path.isfile(os.path.join(folder, f)) and f.lower().endswith(AUDIO_EXTS)]
files.sort(key=lambda x: os.path.basename(x).lower())
return jsonify({'success': True, 'files': files, 'count': len(files)})
@bp.route('/engine-status', methods=['GET'])
def engine_status():
status = dict(_engine_status)
status['logs'] = _proc_stderr[-50:]
return jsonify(status)
@bp.route('/clear-logs', methods=['POST'])
def clear_logs():
_proc_stderr.clear()
return jsonify({'success': True})
@bp.route('/init-engine', methods=['POST'])
def init_engine():
ok, err = _init_engine()
if ok:
return jsonify({'success': True, 'version': _engine_status.get('version')})
return jsonify({'success': False, 'error': err})
@bp.route('/reload-engine', methods=['POST'])
def reload_engine():
global _engine_status
_kill_proc()
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'version': None}
ok, err = _init_engine()
if ok:
return jsonify({'success': True, 'version': _engine_status.get('version')})
return jsonify({'success': False, 'error': err})
@bp.route('/stop-engine', methods=['POST'])
def stop_engine():
global _engine_status
_kill_proc()
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'version': None}
return jsonify({'success': True})
@bp.route('/switch-model', methods=['POST'])
def switch_model():
"""切换 GPT 或 SoVITS 模型"""
if not _engine_status['initialized']:
return jsonify({'success': False, 'error': '引擎未初始化'}), 400
data = request.get_json()
model_type = data.get('type') # 'gpt' 或 'sovits'
filename = data.get('filename')
if not model_type or not filename:
return jsonify({'success': False, 'error': '缺少参数'}), 400
cfg = _load_config()
if model_type == 'gpt':
model_dir = cfg.get('gpt_model_dir', '')
full_path = os.path.join(model_dir, filename) if model_dir else filename
ok, err = _reload_model('reload_gpt', full_path)
if ok:
cfg['last_gpt_model'] = filename
_save_config(cfg)
return jsonify({'success': True, 'message': f'GPT 模型已切换: {filename}'})
return jsonify({'success': False, 'error': err})
elif model_type == 'sovits':
model_dir = cfg.get('sovits_model_dir', '')
full_path = os.path.join(model_dir, filename) if model_dir else filename
ok, err = _reload_model('reload_sovits', full_path)
if ok:
cfg['last_sovits_model'] = filename
_save_config(cfg)
return jsonify({'success': True, 'message': f'SoVITS 模型已切换: {filename}'})
return jsonify({'success': False, 'error': err})
else:
return jsonify({'success': False, 'error': '未知模型类型'}), 400
@bp.route('/synthesize', methods=['POST'])
def synthesize():
if not _engine_status['initialized']:
return jsonify({'success': False, 'error': '引擎未初始化,请先初始化'}), 400
data = request.get_json()
text = (data.get('text') or '').strip()
if not text:
return jsonify({'success': False, 'error': '请输入要合成的文本'}), 400
ref_audio_path = (data.get('ref_audio_path') or '').strip()
if not ref_audio_path:
return jsonify({'success': False, 'error': '请选择参考音频'}), 400
cfg = _load_config()
for key in ('last_prompt_text', 'last_prompt_lang',
'last_text_lang', 'last_text_split_method',
'top_k', 'top_p', 'temperature', 'batch_size', 'speed_factor', 'seed'):
if key in data:
cfg[key] = data[key]
_save_config(cfg)
task_id = uuid.uuid4().hex
output_path = os.path.join(tempfile.gettempdir(), f'ai_dub_{task_id}.wav')
inputs = {
'text': text,
'text_lang': data.get('text_lang', 'zh'),
'ref_audio_path': ref_audio_path,
'prompt_text': data.get('prompt_text', ''),
'prompt_lang': data.get('prompt_lang', 'zh'),
'top_k': data.get('top_k', 5),
'top_p': data.get('top_p', 1.0),
'temperature': data.get('temperature', 1.0),
'text_split_method': data.get('text_split_method', 'cut5'),
'batch_size': data.get('batch_size', 1),
'speed_factor': data.get('speed_factor', 1.0),
'seed': data.get('seed', -1),
'parallel_infer': True,
'repetition_penalty': 1.35,
}
def _do_synth():
ok, result = _synthesize(inputs, output_path)
if ok:
_task_results_local[task_id] = {'status': 'done', 'file': output_path}
else:
_task_results_local[task_id] = {'status': 'error', 'error': result}
_task_results_local[task_id] = {'status': 'synthesizing'}
t = threading.Thread(target=_do_synth, daemon=True)
t.start()
return jsonify({'success': True, 'task_id': task_id})
@bp.route('/status/<task_id>')
def task_status(task_id):
info = _task_results_local.get(task_id)
if not info:
return jsonify({'success': False, 'error': '任务不存在'}), 404
resp = {'success': True, 'progress': info}
if info['status'] == 'done':
resp['audio_url'] = f'/ai-dubbing/audio/{task_id}'
elif info['status'] == 'error':
resp['progress'] = {'status': 'error', 'error': info.get('error', '未知错误')}
_task_results_local.pop(task_id, None)
return jsonify(resp)
@bp.route('/audio/<task_id>')
def get_audio(task_id):
info = _task_results_local.pop(task_id, None)
audio_path = info['file'] if info else os.path.join(tempfile.gettempdir(), f'ai_dub_{task_id}.wav')
if not os.path.exists(audio_path):
return jsonify({'error': '音频文件不存在'}), 404
@after_this_request
def cleanup(resp):
try:
os.remove(audio_path)
except OSError:
pass
return resp
return send_file(audio_path, mimetype='audio/wav')

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""
base64-de-in-code 编解码蓝图
"""
import base64
from flask import Blueprint, render_template, request, jsonify
bp = Blueprint('base64_codec', __name__, url_prefix='/base64-de-in-code')
def _fallback_decode(data_bytes):
"""降级解码:当 encoding_utils 不可用时使用"""
for enc in ['utf-8', 'gbk', 'gb2312', 'cp936']:
try:
return data_bytes.decode(enc)
except UnicodeDecodeError:
continue
return "解码失败,无法识别编码。"
@bp.route('/')
def page():
return render_template('base64.html')
@bp.route('/decode', methods=['POST'])
def decode():
data = request.get_json()
base64_input = data.get('input_text', '').strip()
if not base64_input:
return jsonify({'success': False, 'error': '输入不能为空。'})
try:
decoded_bytes = base64.b64decode(base64_input)
except Exception as e:
return jsonify({'success': False, 'error': f'Base64 解码失败: {str(e)}'})
try:
from utils.encoding_utils import detect_and_decode
decoded_result = detect_and_decode(decoded_bytes)
except Exception:
decoded_result = _fallback_decode(decoded_bytes)
return jsonify({'success': True, 'result': decoded_result})
@bp.route('/encode', methods=['POST'])
def encode():
data = request.get_json()
text_to_encode = data.get('text_to_encode', '')
encoding = data.get('encoding', 'utf-8')
if text_to_encode is None:
return jsonify({'success': False, 'error': '输入不能为空。'})
try:
encoded_bytes = text_to_encode.encode(encoding)
except LookupError:
return jsonify({'success': False, 'error': f'未知的编码格式: {encoding}'})
except UnicodeEncodeError as e:
return jsonify({'success': False, 'error': f'编码失败: {str(e)}'})
encoded_string = base64.b64encode(encoded_bytes).decode('ascii')
return jsonify({'success': True, 'result': encoded_string})

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from flask import Blueprint, render_template
bp = Blueprint('chmod_calc', __name__, url_prefix='/chmod-calc')
@bp.route('/')
def page():
return render_template('chmod_calc.html')

View File

@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
"""
content-tag 文章标签生成蓝图
支持 Ollama (/api/generate) 和 llama.cpp/OpenAI 兼容 (/v1/chat/completions)
"""
import requests
import re
import time
import json
from flask import Blueprint, render_template, request, jsonify
from utils.stats_db import get_content_tag_settings, update_content_tag_settings
bp = Blueprint('content_tag', __name__, url_prefix='/content-tag')
@bp.route('/')
def page():
return render_template('content_tag.html')
@bp.route('/settings', methods=["GET"])
def get_settings():
return jsonify({"success": True, "settings": get_content_tag_settings()})
@bp.route('/settings', methods=["POST"])
def save_settings():
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "缺少数据"}), 400
update_content_tag_settings(data)
return jsonify({"success": True, "settings": get_content_tag_settings()})
@bp.route('/models', methods=["POST"])
def list_models():
"""获取 Ollama 可用模型列表"""
data = request.get_json()
api_url = (data.get("api_url") or "").strip()
if not api_url:
return jsonify({"success": False, "error": "请填写 API 地址"})
# /api/generate -> base url -> /api/tags
base_url = api_url.rsplit('/api/', 1)[0]
tags_url = base_url + '/api/tags'
try:
r = requests.get(tags_url, timeout=10)
if r.status_code == 200:
models = [m["name"] for m in r.json().get("models", [])]
return jsonify({"success": True, "models": models})
else:
return jsonify({"success": False, "error": f"获取模型失败: {r.status_code}"})
except requests.exceptions.ConnectionError:
return jsonify({"success": False, "error": f"无法连接到: {tags_url}"})
except Exception as e:
return jsonify({"success": False, "error": str(e)})
@bp.route('/test-api', methods=["POST"])
def test_api():
data = request.get_json()
api_url = (data.get("api_url") or "").strip()
model = (data.get("model") or "").strip()
software = data.get("software", "llamacpp")
if not api_url:
return jsonify({"success": False, "error": "请填写 API 地址"})
try:
if software == "ollama":
base_url = api_url.rsplit('/api/', 1)[0]
r = requests.get(base_url, timeout=10)
if r.status_code != 200 or 'Ollama is running' not in r.text:
return jsonify({"success": False, "error": f"Ollama 未运行: {base_url}"})
tags_url = base_url + '/api/tags'
r2 = requests.get(tags_url, timeout=10)
if r2.status_code != 200:
return jsonify({"success": False, "error": "Ollama 获取模型列表失败"})
models = [m["name"] for m in r2.json().get("models", [])]
return jsonify({"success": True, "message": f"连接成功,共 {len(models)} 个模型", "models": models})
else:
payload = {"model": model or "test", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 5}
r = requests.post(api_url, json=payload, timeout=10)
if r.status_code == 200:
result = r.json()
name = result.get("model", model)
return jsonify({"success": True, "message": f"连接成功,模型: {name}"})
else:
return jsonify({"success": False, "error": f"API 返回 {r.status_code}: {r.text[:200]}"})
except requests.exceptions.ConnectionError:
return jsonify({"success": False, "error": f"无法连接到: {api_url}"})
except Exception as e:
return jsonify({"success": False, "error": str(e)})
@bp.route('/generate', methods=["POST"])
def generate_tags():
data = request.get_json()
api_url = (data.get("api_url") or "").strip()
model = (data.get("model") or "").strip()
software = data.get("software", "llamacpp")
content = (data.get("content") or "").strip()
max_tags = data.get("max_tags", 10)
min_length = data.get("min_length", 2)
max_length = data.get("max_length", 6)
if not api_url:
return jsonify({"success": False, "error": "请填写 API 地址"})
if software == "ollama" and not model:
return jsonify({"success": False, "error": "Ollama 需要填写模型名称"})
if not content:
return jsonify({"success": False, "error": "请输入文章内容"})
custom_system = (data.get("system_msg") or "").strip()
custom_user = (data.get("user_msg") or "").strip()
system_msg = custom_system if custom_system else "你是一个专门生成文章标签的助手,请你根据我给你的文章的内容总结并生成一系列的标签,格式可以参考[关键词1, 关键词2, 关键词3].你只需要给我生成这种形式的标签即可,其他分析内容无需输出."
user_template = custom_user if custom_user else "请严格按照以下要求,从提供的文章内容中提取关键词。\n\n文章内容:\n{content}\n\n要求:\n- 提取最多 {max_tags} 个最能概括文章主旨和核心概念的关键词。\n- 关键词必须来源于文章内容,准确反映文章主题。\n- 每个关键词的长度必须在 {min_length}{max_length} 个字符之间。\n- 输出格式为关键词1, 关键词2, 关键词3, ...\n- 只输出关键词列表,不要有任何其他解释或前缀。"
user_msg = user_template.format(content=content, max_tags=max_tags, min_length=min_length, max_length=max_length)
if software == "ollama":
payload = {
"model": model,
"prompt": user_msg,
"system": system_msg,
"stream": False,
"think": False,
"options": {"top_p": 0.9, "temperature": 0.1, "num_predict": 2048}
}
else:
payload = {
"messages": [
{"role": "system", "content": system_msg},
{"role": "user", "content": user_msg}
],
"stream": False,
"temperature": 0.1,
"top_p": 0.9,
"max_tokens": 2048,
}
if model:
payload["model"] = model
start_time = time.time()
try:
response = requests.post(api_url, json=payload, timeout=120)
if response.status_code != 200:
return jsonify({"success": False, "error": f"API 返回错误: {response.status_code} - {response.text[:200]}"})
result = response.json()
if software == "ollama":
llm_output = result.get("response", "").strip()
actual_model = result.get("model", model)
else:
choices = result.get("choices", [])
if not choices:
return jsonify({"success": False, "error": f"API 响应无 choices: {json.dumps(result, ensure_ascii=False)[:200]}"})
llm_output = choices[0].get("message", {}).get("content", "").strip()
actual_model = result.get("model", model)
think_match = re.search(r'<think>(.*?)</think>', llm_output, re.DOTALL)
ai_think = think_match.group(1).strip() if think_match else ""
clean_output = re.sub(r'<think>.*?</think>', '', llm_output, count=1, flags=re.DOTALL).strip()
raw_tags = [tag.strip() for tag in re.split(r'[,;\n]+', clean_output) if tag.strip()]
seen = set()
tags = []
for tag in raw_tags:
if tag not in seen:
seen.add(tag)
tags.append(tag)
elapsed = round(time.time() - start_time, 2)
return jsonify({
"success": True,
"tags": tags,
"think": ai_think,
"model": actual_model,
"consume": elapsed
})
except requests.exceptions.ConnectionError:
return jsonify({"success": False, "error": f"无法连接到 API 地址: {api_url}"})
except requests.exceptions.Timeout:
return jsonify({"success": False, "error": "API 请求超时120秒"})
except Exception as e:
return jsonify({"success": False, "error": f"请求失败: {str(e)}"})

View File

@@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
"""
down-video 视频下载蓝图
"""
import os
import json
import queue
from flask import Blueprint, render_template, request, jsonify, send_file, Response
from config import DEFAULT_OUTPUT_DIR, PROXY_URL
from utils.down_video_utils import (
check_ffmpeg,
check_deno,
is_twitter_url,
is_bilibili_url,
is_instagram_url,
is_youtube_url,
download_video,
)
from utils.stats_db import add_recent_path, get_recent_paths, delete_recent_path, clear_recent_paths
bp = Blueprint('dv_cookies', __name__, url_prefix='/down-video')
# 用于存储下载进度的队列
progress_queues = {}
@bp.route('/')
def page():
ffmpeg_ok = check_ffmpeg()
deno_ok = check_deno()
return render_template('down_video.html', ffmpeg_ok=ffmpeg_ok, deno_ok=deno_ok)
@bp.route('/file')
def serve_file():
filepath = request.args.get("path", "")
as_download = request.args.get("download", "0") == "1"
if not filepath:
return "Missing path", 400
filepath = os.path.abspath(filepath)
if not os.path.exists(filepath) or not os.path.isfile(filepath):
return "File not found", 404
ext = os.path.splitext(filepath)[1].lower()
if ext not in ('.mp4', '.m4a', '.webm', '.mkv', '.mov', '.avi'):
return "File type not allowed", 403
mime_types = {
'.mp4': 'video/mp4',
'.m4a': 'audio/mp4',
'.webm': 'video/webm',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
}
mimetype = mime_types.get(ext, 'application/octet-stream')
response = send_file(
filepath,
mimetype=mimetype,
conditional=True,
)
response.headers['Accept-Ranges'] = 'bytes'
if not as_download:
response.headers['Content-Disposition'] = 'inline'
return response
@bp.route('/download', methods=["POST"])
def download():
data = request.get_json()
url = data.get("url", "").strip() if data else ""
output_dir = data.get("output_dir", DEFAULT_OUTPUT_DIR).strip() if data else DEFAULT_OUTPUT_DIR
download_id = data.get("download_id", "") if data else ""
use_proxy = data.get("use_proxy", False) if data else False
proxy_url_input = (data.get("proxy_url") or "").strip() if data else ""
if not url:
return jsonify({"success": False, "message": "请输入视频链接"}), 400
if is_twitter_url(url):
platform = 'twitter'
elif is_bilibili_url(url):
platform = 'bilibili'
elif is_instagram_url(url):
platform = 'instagram'
elif is_youtube_url(url):
platform = 'youtube'
else:
return jsonify({"success": False, "message": "仅支持 Twitter/X、Bilibili 和 Instagram 视频链接"}), 400
if not output_dir:
output_dir = DEFAULT_OUTPUT_DIR
# 确定使用的代理地址
proxy_url = proxy_url_input if use_proxy and proxy_url_input else (PROXY_URL if use_proxy else None)
# 创建进度队列
progress_queue = queue.Queue()
if download_id:
progress_queues[download_id] = progress_queue
def progress_callback(data):
progress_queue.put(data)
try:
# 发送开始合并的消息
def send_merge_status():
progress_queue.put({
'type': 'merging',
'message': '正在合并视频和音频...'
})
success, message, title, filepath = download_video(url, output_dir, platform, progress_callback, proxy_url=proxy_url)
# 如果是bilibili或youtube可能有合并过程发送合并状态
if platform in ('bilibili', 'youtube') and filepath and '_merged' in filepath:
send_merge_status()
return jsonify({"success": success, "message": message, "title": title, "filepath": filepath})
finally:
# 清理队列
if download_id and download_id in progress_queues:
del progress_queues[download_id]
@bp.route('/progress/<download_id>')
def progress_stream(download_id):
"""SSE端点 - 实时推送下载进度"""
def generate():
progress_queue = progress_queues.get(download_id)
if not progress_queue:
yield f"data: {json.dumps({'type': 'error', 'message': '下载任务不存在'})}\n\n"
return
while True:
try:
data = progress_queue.get(timeout=30)
yield f"data: {json.dumps(data)}\n\n"
if data.get('type') == 'progress' and data.get('percent', 0) >= 100:
break
except queue.Empty:
# 发送心跳保持连接
yield f":\n\n"
continue
return Response(
generate(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
)
# ===== 保存路径历史 =====
@bp.route('/save-paths', methods=['GET'])
def get_save_paths():
paths = get_recent_paths(limit=10)
return jsonify({'paths': paths})
@bp.route('/save-paths', methods=['POST'])
def add_save_path():
data = request.get_json()
path = data.get('path', '').strip() if data else ''
if not path:
return jsonify({'success': False, 'message': '路径不能为空'}), 400
add_recent_path(path)
return jsonify({'success': True})
@bp.route('/save-paths/delete', methods=['POST'])
def delete_save_path():
data = request.get_json()
path = data.get('path', '').strip() if data else ''
if not path:
return jsonify({'success': False, 'message': '路径不能为空'}), 400
delete_recent_path(path)
return jsonify({'success': True})

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""
fen-ci 分词蓝图
"""
from flask import Blueprint, render_template, request, jsonify
from utils.fen_ci_utils import tokenizer, token_auth
bp = Blueprint('fen_ci', __name__, url_prefix='/fen-ci')
def _authenticate():
"""检查请求头中的 Authorization token"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return None, "缺少或格式错误的Authorization头。请使用 'Authorization: Bearer <your_token>'"
token = auth_header.split(" ", 1)[1]
user = token_auth.get_user(token)
if not user:
return None, "无效的Token"
return user, None
@bp.route('/')
def page():
return render_template('fen_ci.html')
@bp.route('/tokenize', methods=['POST'])
def tokenize():
user, error_msg = _authenticate()
if not user:
return jsonify({'success': False, 'error': error_msg}), 401
data = request.get_json()
input_text = data.get('input_text', '')
try:
tokens = tokenizer.tokenize(input_text)
stats = tokenizer.get_stats(tokens)
return jsonify({
'success': True,
'tokens': tokens,
'stats': stats,
'original_text': input_text,
'requested_by': user
})
except Exception as e:
return jsonify({'success': False, 'error': f'分词处理失败: {str(e)}'}), 500

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from flask import Blueprint, render_template
bp = Blueprint('http_status', __name__, url_prefix='/http-status')
@bp.route('/')
def page():
return render_template('http_status.html')

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from flask import Blueprint, render_template
bp = Blueprint('json_format', __name__, url_prefix='/json-format')
@bp.route('/')
def page():
return render_template('json_format.html')

View File

@@ -0,0 +1,447 @@
# -*- coding: utf-8 -*-
"""
pin-tu 图片浏览蓝图
用户自行指定浏览路径root 用 URL-safe base64 编码放在 URL 中)
"""
import os
import shutil
import base64
from flask import Blueprint, render_template, send_from_directory, request, abort, jsonify
from utils.pin_tu_utils import (
find_media_in_folder,
get_subfolders,
build_breadcrumbs,
is_safe_path,
)
from utils.stats_db import (
add_search, get_search_history, delete_search_history, clear_search_history,
add_recent_path, get_recent_paths, delete_recent_path, clear_recent_paths,
)
bp = Blueprint('pin_tu', __name__, url_prefix='/pin-tu')
def _decode_root(b64_str):
"""URL-safe base64 解码为原始路径"""
try:
# URL-safe → standard base64
padded = b64_str.replace('-', '+').replace('_', '/')
# 补齐 =
pad = len(padded) % 4
if pad:
padded += '=' * (4 - pad)
return base64.b64decode(padded).decode('utf-8')
except Exception:
return None
def _encode_root(path_str):
"""路径编码为 URL-safe base64 字符串"""
b = base64.b64encode(path_str.encode('utf-8')).decode('ascii')
return b.replace('+', '-').replace('/', '_').rstrip('=')
@bp.route('/')
def index():
return render_template('pin_tu.html', mode='index', root='', root_b64='')
@bp.route('/browse/<string:root_b64>')
def browse(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
# 记录最近访问路径
add_recent_path(root_path)
subpath = request.args.get('p', '')
mode = request.args.get('mode', 'list')
if mode not in ['list', 'grid', 'manga']:
mode = 'list'
root_abs = os.path.normpath(root_path)
if subpath:
current_path = os.path.normpath(os.path.join(root_abs, subpath))
else:
current_path = root_abs
if not os.path.isdir(root_abs):
abort(404)
if not is_safe_path(root_abs, current_path):
abort(403)
image_files, video_files, other_files = find_media_in_folder(current_path)
subfolders = get_subfolders(current_path)
breadcrumbs = build_breadcrumbs(subpath, base_url=f'/pin-tu/browse/{root_b64}')
current_path_name = os.path.basename(current_path) if current_path != root_abs else os.path.basename(root_abs)
if mode == 'manga':
return render_template(
'pin_tu_manga.html',
images=image_files,
current_path_name=current_path_name,
current_subpath=subpath + '/' if subpath else '',
root_b64=root_b64,
)
return render_template(
'pin_tu.html',
mode='browse',
images=image_files,
videos=video_files,
files=other_files,
subfolders=subfolders,
current_path_name=current_path_name,
current_subpath=subpath + '/' if subpath else '',
breadcrumbs=breadcrumbs,
view_mode=mode,
is_manga_path='漫画' in subpath,
root_b64=root_b64,
root_display=root_path,
)
@bp.route('/file-info/<string:root_b64>')
def file_info(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
file_path = request.args.get('p', '')
if not file_path:
abort(400)
root_abs = os.path.normpath(root_path)
full_file = os.path.normpath(os.path.join(root_abs, file_path))
if not is_safe_path(root_abs, full_file) or not os.path.isfile(full_file):
abort(404)
stat = os.stat(full_file)
size = stat.st_size
if size < 1024:
size_str = f'{size} B'
elif size < 1024 * 1024:
size_str = f'{size / 1024:.1f} KB'
elif size < 1024 * 1024 * 1024:
size_str = f'{size / (1024 * 1024):.1f} MB'
else:
size_str = f'{size / (1024 * 1024 * 1024):.2f} GB'
from datetime import datetime
modified = datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
ext = os.path.splitext(file_path)[1].lstrip('.').upper()
return {
'name': os.path.basename(file_path),
'size': size_str,
'size_bytes': size,
'type': ext or '未知',
'modified': modified,
'path': file_path,
}
@bp.route('/media/<string:root_b64>')
def serve_media(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
file_path = request.args.get('p', '')
if not file_path:
abort(400)
root_abs = os.path.normpath(root_path)
folder = os.path.dirname(file_path)
filename = os.path.basename(file_path)
base_dir = os.path.join(root_abs, folder)
full_file = os.path.normpath(os.path.join(base_dir, filename))
if not is_safe_path(root_abs, full_file):
abort(403)
return send_from_directory(base_dir, filename)
@bp.route('/rename/<string:root_b64>', methods=['POST'])
def rename(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
data = request.get_json() or {}
old_path = data.get('path', '')
new_name = data.get('new_name', '').strip()
if not old_path or not new_name:
return {'success': False, 'error': '参数不完整'}, 400
# 校验新文件名
invalid = set(r'\/:*?"<>|')
if any(c in invalid for c in new_name):
return {'success': False, 'error': '文件名包含非法字符'}, 400
root_abs = os.path.normpath(root_path)
old_full = os.path.normpath(os.path.join(root_abs, old_path))
if not is_safe_path(root_abs, old_full) or not os.path.exists(old_full):
return {'success': False, 'error': '文件不存在'}, 404
new_full = os.path.join(os.path.dirname(old_full), new_name)
if os.path.exists(new_full) and os.path.normcase(new_full) != os.path.normcase(old_full):
return {'success': False, 'error': '同名文件已存在'}, 400
try:
os.rename(old_full, new_full)
return {'success': True, 'new_name': new_name}
except Exception as e:
return {'success': False, 'error': str(e)}, 500
@bp.route('/search-page/<string:root_b64>')
def search_page(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
return render_template('pin_tu_search.html', root_b64=root_b64, root_display=root_path, query=request.args.get('q', ''))
@bp.route('/search/<string:root_b64>')
def search(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
query = request.args.get('q', '').strip().lower()
if not query or len(query) < 1:
return {'results': []}
root_abs = os.path.normpath(root_path)
if not os.path.isdir(root_abs):
abort(404)
results = []
max_results = 50
for dirpath, dirnames, filenames in os.walk(root_abs):
# 搜索文件夹名
for d in list(dirnames):
if query in d.lower():
rel = os.path.relpath(os.path.join(dirpath, d), root_abs).replace('\\', '/')
results.append({'name': d, 'path': rel, 'type': 'folder'})
if len(results) >= max_results:
return {'results': results}
# 搜索文件名
for f in filenames:
if query in f.lower():
rel = os.path.relpath(os.path.join(dirpath, f), root_abs).replace('\\', '/')
ext = os.path.splitext(f)[1].lstrip('.').upper()
results.append({'name': f, 'path': rel, 'type': ext or 'file'})
if len(results) >= max_results:
return {'results': results}
return {'results': results}
# ===== 搜索历史 API =====
@bp.route('/api/search-history/<string:root_b64>')
def api_get_search_history(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
records = get_search_history(root_path, limit=20)
return jsonify({'history': records})
@bp.route('/api/search-history/<string:root_b64>', methods=['POST'])
def api_add_search(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
data = request.get_json() or {}
keyword = data.get('keyword', '').strip()
result_count = data.get('result_count', 0)
if keyword:
add_search(keyword, root_path, result_count)
return jsonify({'success': True})
@bp.route('/api/search-history/<string:root_b64>', methods=['DELETE'])
def api_delete_search_history(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
data = request.get_json() or {}
if data.get('clear'):
clear_search_history(root_path)
else:
ids = data.get('ids', [])
if ids:
delete_search_history(ids)
return jsonify({'success': True})
# ===== 最近访问路径 API =====
@bp.route('/api/recent-paths')
def api_get_recent_paths():
records = get_recent_paths(limit=8)
return jsonify({'paths': records})
@bp.route('/api/recent-paths', methods=['POST'])
def api_add_recent_path():
data = request.get_json() or {}
path = data.get('path', '').strip()
if path:
add_recent_path(path)
return jsonify({'success': True})
@bp.route('/api/recent-paths', methods=['DELETE'])
def api_delete_recent_path():
data = request.get_json() or {}
if data.get('clear'):
clear_recent_paths()
else:
path = data.get('path', '')
if path:
delete_recent_path(path)
return jsonify({'success': True})
# ===== 新建文件夹 =====
@bp.route('/create-folder/<string:root_b64>', methods=['POST'])
def create_folder(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
data = request.get_json() or {}
subpath = data.get('path', '')
name = data.get('name', '').strip()
if not name:
return {'success': False, 'error': '名称不能为空'}, 400
invalid = set(r'\/:*?"<>|')
if any(c in invalid for c in name):
return {'success': False, 'error': '名称包含非法字符'}, 400
root_abs = os.path.normpath(root_path)
if subpath:
parent = os.path.normpath(os.path.join(root_abs, subpath))
else:
parent = root_abs
if not is_safe_path(root_abs, parent) or not os.path.isdir(parent):
return {'success': False, 'error': '父目录不存在'}, 400
target = os.path.join(parent, name)
if os.path.exists(target):
return {'success': False, 'error': '同名文件夹已存在'}, 400
try:
os.makedirs(target)
return {'success': True, 'name': name}
except Exception as e:
return {'success': False, 'error': str(e)}, 500
# ===== 移动文件/文件夹 =====
@bp.route('/move/<string:root_b64>', methods=['POST'])
def move_item(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
data = request.get_json() or {}
source = data.get('source', '')
dest = data.get('dest', '') # 目标文件夹的相对路径,空字符串表示根目录
if not source:
return {'success': False, 'error': '缺少源路径'}, 400
root_abs = os.path.normpath(root_path)
src_full = os.path.normpath(os.path.join(root_abs, source))
if not is_safe_path(root_abs, src_full) or not os.path.exists(src_full):
return {'success': False, 'error': '源文件不存在'}, 404
if dest:
dest_dir = os.path.normpath(os.path.join(root_abs, dest))
else:
dest_dir = root_abs
if not is_safe_path(root_abs, dest_dir) or not os.path.isdir(dest_dir):
return {'success': False, 'error': '目标目录不存在'}, 400
# 不能移动到自身或自身子目录
norm_src = os.path.normcase(src_full)
norm_dest = os.path.normcase(dest_dir)
if norm_dest.startswith(norm_src + os.sep) or norm_dest == norm_src:
return {'success': False, 'error': '不能移动到自身或子目录'}, 400
item_name = os.path.basename(src_full)
new_full = os.path.join(dest_dir, item_name)
if os.path.exists(new_full) and os.path.normcase(new_full) != norm_src:
return {'success': False, 'error': '目标位置已存在同名项目'}, 400
try:
shutil.move(src_full, new_full)
return {'success': True, 'name': item_name}
except Exception as e:
return {'success': False, 'error': str(e)}, 500
# ===== 删除文件/文件夹 =====
@bp.route('/delete/<string:root_b64>', methods=['POST'])
def delete_item(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
data = request.get_json() or {}
source = data.get('source', '')
if not source:
return {'success': False, 'error': '缺少路径'}, 400
root_abs = os.path.normpath(root_path)
full = os.path.normpath(os.path.join(root_abs, source))
if not is_safe_path(root_abs, full) or not os.path.exists(full):
return {'success': False, 'error': '文件不存在'}, 404
name = os.path.basename(full)
try:
if os.path.isdir(full):
shutil.rmtree(full)
else:
os.remove(full)
return {'success': True, 'name': name}
except Exception as e:
return {'success': False, 'error': str(e)}, 500
# ===== 列出子文件夹(供移动弹窗使用)=====
@bp.route('/list-folders/<string:root_b64>')
def list_folders(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
subpath = request.args.get('p', '')
root_abs = os.path.normpath(root_path)
if subpath:
current = os.path.normpath(os.path.join(root_abs, subpath))
else:
current = root_abs
if not is_safe_path(root_abs, current) or not os.path.isdir(current):
abort(404)
subfolders = get_subfolders(current)
# 父目录
parent = ''
if subpath:
parts = subpath.rstrip('/').split('/')
parent = '/'.join(parts[:-1]) if len(parts) > 1 else ''
return jsonify({
'current': subpath,
'parent': parent,
'folders': [{'name': f, 'path': (subpath + '/' + f).strip('/')} for f in subfolders]
})

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from flask import Blueprint, render_template
bp = Blueprint('qr_code', __name__, url_prefix='/qr-code')
@bp.route('/')
def page():
return render_template('qr_code.html')

View File

@@ -0,0 +1,415 @@
# -*- coding: utf-8 -*-
"""
rvc RVC 变声蓝图
通过 subprocess 调用 RVC 自带的 runtime\python.exe 实现语音变声
"""
import os
import json
import uuid
import glob
import struct
import subprocess
import threading
import tempfile
from flask import Blueprint, render_template, request, jsonify, send_file, after_this_request
bp = Blueprint('rvc', __name__, url_prefix='/rvc')
try:
from config import BASE_DIR
except ImportError:
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'rvc_config.json')
WORKER_SCRIPT = os.path.join(BASE_DIR, 'utils', 'rvc_worker.py')
# 子进程管理
_proc = None
_proc_lock = threading.Lock()
_proc_stderr = []
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'device': None, 'model_loaded': False}
# 变声请求锁
_synth_lock = threading.Lock()
# 任务结果缓存
_task_results_local = {}
F0_METHODS = ['pm', 'harvest', 'crepe', 'rmvpe']
F0_METHOD_NAMES = {
'pm': 'PM (最快)', 'harvest': 'Harvest (高质量)',
'crepe': 'Crepe (神经网络)', 'rmvpe': 'RMVPE (推荐)',
}
def _default_config():
return {
'rvc_project_path': r'E:\AI\RVC\RVC1006Nvidia',
'model_dir': 'assets/weights',
'model_dir_mode': 'relative',
'index_dir': 'logs',
'index_dir_mode': 'relative',
'last_model': '',
'last_index': '',
'f0_up_key': 0,
'f0_method': 'rmvpe',
'index_rate': 0.75,
'filter_radius': 3,
'resample_sr': 0,
'rms_mix_rate': 0.25,
'protect': 0.33,
}
def _load_config():
cfg = _default_config()
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
saved = json.load(f)
cfg.update(saved)
# 空值回退到默认值
if not cfg.get('model_dir'):
cfg['model_dir'] = 'assets/weights'
if not cfg.get('index_dir'):
cfg['index_dir'] = 'logs'
if not cfg.get('model_dir_mode'):
cfg['model_dir_mode'] = 'relative'
if not cfg.get('index_dir_mode'):
cfg['index_dir_mode'] = 'relative'
return cfg
def _save_config(cfg):
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def _scan_pth_models(directory):
"""扫描 .pth 模型文件"""
if not directory or not os.path.isdir(directory):
return []
files = glob.glob(os.path.join(directory, '*.pth'))
files.sort(key=lambda x: os.path.basename(x).lower())
return [os.path.basename(f) for f in files]
def _scan_index_files(directory):
"""递归扫描 .index 文件,排除 trained"""
if not directory or not os.path.isdir(directory):
return []
results = []
for root, dirs, files in os.walk(directory):
for f in files:
if f.endswith('.index') and 'trained' not in f.lower():
results.append(os.path.join(root, f))
results.sort(key=lambda x: os.path.basename(x).lower())
return results
def _send_msg(proc, obj):
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
proc.stdin.write(struct.pack('<I', len(data)))
proc.stdin.write(data)
proc.stdin.flush()
def _recv_msg(proc):
raw_len = proc.stdout.read(4)
if not raw_len:
return None
msg_len = struct.unpack('<I', raw_len)[0]
data = proc.stdout.read(msg_len)
return json.loads(data.decode('utf-8'))
def _init_engine():
global _proc, _engine_status
if _engine_status['initialized'] and _proc and _proc.poll() is None:
return True, None
with _proc_lock:
if _engine_status['initialized'] and _proc and _proc.poll() is None:
return True, None
if _engine_status['loading']:
return False, '引擎正在加载中,请稍候...'
_engine_status['loading'] = True
_engine_status['error'] = None
cfg = _load_config()
rvc_path = cfg.get('rvc_project_path', '').strip()
if not rvc_path or not os.path.isdir(rvc_path):
_engine_status['loading'] = False
_engine_status['error'] = f'RVC 项目路径不存在: {rvc_path}'
return False, _engine_status['error']
runtime_python = os.path.join(rvc_path, 'runtime', 'python.exe')
if not os.path.exists(runtime_python):
_engine_status['loading'] = False
_engine_status['error'] = f'找不到 RVC 运行时: {runtime_python}'
return False, _engine_status['error']
try:
creationflags = 0x08000000 if os.name == 'nt' else 0
_proc = subprocess.Popen(
[runtime_python, WORKER_SCRIPT, rvc_path],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
creationflags=creationflags,
)
_proc_stderr.clear()
def _read_stderr():
try:
for line in _proc.stderr:
_proc_stderr.append(line.decode('utf-8', errors='replace').rstrip())
except Exception:
pass
threading.Thread(target=_read_stderr, daemon=True).start()
msg = _recv_msg(_proc)
if msg and msg.get('type') == 'ready':
_engine_status['initialized'] = True
_engine_status['device'] = msg.get('device', 'unknown')
_engine_status['error'] = None
return True, None
else:
err = msg.get('error', '未知错误') if msg else '子进程无响应'
if _proc_stderr:
err += '\n--- 日志 ---\n' + '\n'.join(_proc_stderr[-10:])
_engine_status['error'] = err
_kill_proc()
return False, err
except Exception as e:
_engine_status['error'] = str(e)
_kill_proc()
return False, str(e)
finally:
_engine_status['loading'] = False
def _kill_proc():
global _proc
if _proc:
try:
_proc.kill()
except Exception:
pass
_proc = None
def _send_command(obj):
with _synth_lock:
if not _proc or _proc.poll() is not None:
return False, '子进程未运行'
try:
_send_msg(_proc, obj)
msg = _recv_msg(_proc)
if msg and msg.get('type') == 'done':
return True, msg
else:
return False, msg.get('error', '未知错误') if msg else '子进程无响应'
except Exception as e:
return False, str(e)
# ==================== 路由 ====================
@bp.route('/')
def page():
cfg = _load_config()
return render_template('rvc.html',
config=cfg, f0_methods=F0_METHODS,
f0_method_names=F0_METHOD_NAMES)
@bp.route('/config', methods=['GET'])
def get_config():
cfg = _load_config()
cfg['engine_status'] = _engine_status
# 扫描模型
rvc_path = cfg.get('rvc_project_path', '')
model_dir_raw = cfg.get('model_dir', 'assets/weights')
model_dir = model_dir_raw if cfg.get('model_dir_mode') == 'absolute' else os.path.join(rvc_path, model_dir_raw)
cfg['models'] = _scan_pth_models(model_dir)
# 扫描 index
index_dir_raw = cfg.get('index_dir', 'logs')
index_dir = index_dir_raw if cfg.get('index_dir_mode') == 'absolute' else os.path.join(rvc_path, index_dir_raw)
cfg['index_files'] = _scan_index_files(index_dir)
return jsonify(cfg)
@bp.route('/config', methods=['POST'])
def save_config():
data = request.get_json()
cfg = _load_config()
for key in ('rvc_project_path', 'model_dir', 'model_dir_mode', 'index_dir', 'index_dir_mode',
'last_model', 'last_index',
'f0_up_key', 'f0_method', 'index_rate',
'filter_radius', 'resample_sr', 'rms_mix_rate', 'protect'):
if key in data:
cfg[key] = data[key]
_save_config(cfg)
return jsonify({'success': True})
@bp.route('/upload-audio', methods=['POST'])
def upload_audio():
if 'audio' not in request.files:
return jsonify({'success': False, 'error': '请上传音频文件'}), 400
audio_file = request.files['audio']
if not audio_file.filename:
return jsonify({'success': False, 'error': '未选择文件'}), 400
ext = os.path.splitext(audio_file.filename)[1].lower()
if ext not in ('.wav', '.mp3', '.flac', '.ogg', '.m4a', '.aac'):
return jsonify({'success': False, 'error': f'不支持的音频格式: {ext}'}), 400
orig_filename = audio_file.filename
filename = uuid.uuid4().hex + ext
save_path = os.path.join(tempfile.gettempdir(), filename)
audio_file.save(save_path)
return jsonify({'success': True, 'path': save_path, 'filename': orig_filename})
@bp.route('/engine-status', methods=['GET'])
def engine_status():
status = dict(_engine_status)
status['logs'] = _proc_stderr[-50:]
return jsonify(status)
@bp.route('/clear-logs', methods=['POST'])
def clear_logs():
_proc_stderr.clear()
return jsonify({'success': True})
@bp.route('/init-engine', methods=['POST'])
def init_engine():
ok, err = _init_engine()
if ok:
return jsonify({'success': True, 'device': _engine_status.get('device')})
return jsonify({'success': False, 'error': err})
@bp.route('/reload-engine', methods=['POST'])
def reload_engine():
global _engine_status
_kill_proc()
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'device': None, 'model_loaded': False}
ok, err = _init_engine()
if ok:
return jsonify({'success': True, 'device': _engine_status.get('device')})
return jsonify({'success': False, 'error': err})
@bp.route('/stop-engine', methods=['POST'])
def stop_engine():
global _engine_status
_kill_proc()
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'device': None, 'model_loaded': False}
return jsonify({'success': True})
@bp.route('/switch-model', methods=['POST'])
def switch_model():
if not _engine_status['initialized']:
return jsonify({'success': False, 'error': '引擎未初始化'}), 400
data = request.get_json()
model_name = data.get('model_name')
if not model_name:
return jsonify({'success': False, 'error': '缺少模型名称'}), 400
ok, result = _send_command({'type': 'load_model', 'model_name': model_name})
if ok:
_engine_status['model_loaded'] = True
cfg = _load_config()
cfg['last_model'] = model_name
_save_config(cfg)
return jsonify({'success': True, 'message': f'模型已加载: {model_name}'})
return jsonify({'success': False, 'error': result})
@bp.route('/convert', methods=['POST'])
def convert():
if not _engine_status['initialized']:
return jsonify({'success': False, 'error': '引擎未初始化,请先初始化'}), 400
if not _engine_status.get('model_loaded'):
return jsonify({'success': False, 'error': '请先加载语音模型'}), 400
data = request.get_json()
input_path = (data.get('input_path') or '').strip()
if not input_path or not os.path.exists(input_path):
return jsonify({'success': False, 'error': '请上传有效的音频文件'}), 400
cfg = _load_config()
for key in ('last_model', 'last_index', 'f0_up_key', 'f0_method',
'index_rate', 'filter_radius', 'resample_sr', 'rms_mix_rate', 'protect'):
if key in data:
cfg[key] = data[key]
_save_config(cfg)
task_id = uuid.uuid4().hex
output_path = os.path.join(tempfile.gettempdir(), f'rvc_{task_id}.wav')
convert_params = {
'type': 'convert',
'input_path': input_path,
'output_path': output_path,
'f0_up_key': data.get('f0_up_key', cfg['f0_up_key']),
'f0_method': data.get('f0_method', cfg['f0_method']),
'file_index': data.get('file_index', ''),
'index_rate': data.get('index_rate', cfg['index_rate']),
'filter_radius': data.get('filter_radius', cfg['filter_radius']),
'resample_sr': data.get('resample_sr', cfg['resample_sr']),
'rms_mix_rate': data.get('rms_mix_rate', cfg['rms_mix_rate']),
'protect': data.get('protect', cfg['protect']),
}
def _do_convert():
ok, result = _send_command(convert_params)
if ok:
_task_results_local[task_id] = {'status': 'done', 'file': output_path}
else:
_task_results_local[task_id] = {'status': 'error', 'error': result}
_task_results_local[task_id] = {'status': 'converting'}
t = threading.Thread(target=_do_convert, daemon=True)
t.start()
return jsonify({'success': True, 'task_id': task_id})
@bp.route('/status/<task_id>')
def task_status(task_id):
info = _task_results_local.get(task_id)
if not info:
return jsonify({'success': False, 'error': '任务不存在'}), 404
resp = {'success': True, 'progress': info}
if info['status'] == 'done':
resp['audio_url'] = f'/rvc/audio/{task_id}'
elif info['status'] == 'error':
resp['progress'] = {'status': 'error', 'error': info.get('error', '未知错误')}
_task_results_local.pop(task_id, None)
return jsonify(resp)
@bp.route('/audio/<task_id>')
def get_audio(task_id):
info = _task_results_local.pop(task_id, None)
audio_path = info['file'] if info else os.path.join(tempfile.gettempdir(), f'rvc_{task_id}.wav')
if not os.path.exists(audio_path):
return jsonify({'error': '音频文件不存在'}), 404
@after_this_request
def cleanup(resp):
try:
os.remove(audio_path)
except OSError:
pass
return resp
return send_file(audio_path, mimetype='audio/wav')

View File

@@ -0,0 +1,342 @@
# -*- coding: utf-8 -*-
"""
sovits-tts GPT-SoVITS v2 配音蓝图
代理转发请求到 GPT-SoVITS v2 API 服务
"""
import os
import json
import glob
import platform
import subprocess
import requests
from flask import Blueprint, render_template, request, jsonify, Response
bp = Blueprint('sovits_tts', __name__, url_prefix='/sovits-tts')
CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'config', 'sovits_config.json')
try:
from config import BASE_DIR
CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'sovits_config.json')
except ImportError:
pass
_sovits_proc = None
@bp.route('/')
def page():
return render_template('sovits_tts.html')
def _load_config():
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
return json.load(f)
return {
'api_url': 'http://127.0.0.1:9880',
'gpt_model_dir': '', 'sovits_model_dir': '',
'last_gpt_model': '', 'last_sovits_model': '',
'sovits_path': '', 'start_cmd': 'start api_v2.bat',
'refer_audio_history': [],
'refer_audio_folder': '',
'top_k': 5, 'top_p': 1.0, 'temperature': 1.0,
'batch_size': 32, 'speed': 1.0, 'text_split_method': 'cut5'
}
def _save_config(cfg):
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def _scan_models(model_dir, ext):
if not model_dir or not os.path.isdir(model_dir):
return []
files = glob.glob(os.path.join(model_dir, '**', f'*{ext}'), recursive=True)
files.sort(key=lambda f: os.path.getmtime(f), reverse=True)
return files
@bp.route('/config', methods=['GET'])
def get_config():
cfg = _load_config()
cfg['gpt_models'] = _scan_models(cfg.get('gpt_model_dir', ''), '.ckpt')
cfg['sovits_models'] = _scan_models(cfg.get('sovits_model_dir', ''), '.pth')
return jsonify(cfg)
@bp.route('/config', methods=['POST'])
def save_config():
data = request.get_json()
cfg = _load_config()
for key in ('api_url', 'gpt_model_dir', 'sovits_model_dir', 'last_gpt_model', 'last_sovits_model', 'sovits_path', 'start_cmd',
'top_k', 'top_p', 'temperature', 'batch_size', 'speed', 'text_split_method', 'refer_audio_folder'):
if key in data:
cfg[key] = data[key]
_save_config(cfg)
return jsonify({'success': True})
@bp.route('/refer-audio-history', methods=['POST'])
def save_refer_audio():
data = request.get_json()
path = (data.get('path') or '').strip()
if not path:
return jsonify({'success': False, 'error': '路径不能为空'})
cfg = _load_config()
history = cfg.get('refer_audio_history', [])
if path in history:
history.remove(path)
history.insert(0, path)
cfg['refer_audio_history'] = history
_save_config(cfg)
return jsonify({'success': True, 'history': history})
@bp.route('/refer-audio-history', methods=['DELETE'])
def delete_refer_audio():
data = request.get_json()
path = (data.get('path') or '').strip()
cfg = _load_config()
history = cfg.get('refer_audio_history', [])
if path in history:
history.remove(path)
cfg['refer_audio_history'] = history
_save_config(cfg)
return jsonify({'success': True, 'history': history})
@bp.route('/refer-audio-history', methods=['PUT'])
def update_refer_audio():
data = request.get_json()
old_path = (data.get('old_path') or '').strip()
new_path = (data.get('new_path') or '').strip()
if not old_path or not new_path:
return jsonify({'success': False, 'error': '路径不能为空'})
cfg = _load_config()
history = cfg.get('refer_audio_history', [])
if old_path not in history:
return jsonify({'success': False, 'error': '原路径不存在'})
if new_path in history and new_path != old_path:
return jsonify({'success': False, 'error': '新路径已存在'})
idx = history.index(old_path)
history[idx] = new_path
cfg['refer_audio_history'] = history
_save_config(cfg)
return jsonify({'success': True, 'history': history})
@bp.route('/scan-audio-folder', methods=['POST'])
def scan_audio_folder():
"""扫描文件夹下的音频文件"""
data = request.get_json()
folder = (data.get('folder') or '').strip()
if not folder:
return jsonify({'success': False, 'error': '请输入文件夹路径'})
if not os.path.isdir(folder):
return jsonify({'success': False, 'error': '文件夹不存在'})
AUDIO_EXTS = ('.wav', '.mp3', '.flac', '.ogg', '.aac', '.m4a', '.wma')
files = []
for f in os.listdir(folder):
full = os.path.join(folder, f)
if os.path.isfile(full) and f.lower().endswith(AUDIO_EXTS):
files.append(full)
files.sort(key=lambda x: os.path.basename(x).lower())
# 保存最后使用的文件夹
cfg = _load_config()
cfg['refer_audio_folder'] = folder
_save_config(cfg)
return jsonify({'success': True, 'files': files, 'count': len(files)})
@bp.route('/test-connection', methods=['POST'])
def test_connection():
data = request.get_json()
api_url = (data.get('api_url') or '').strip().rstrip('/')
if not api_url:
return jsonify({'success': False, 'error': '请填写 GPT-SoVITS 服务地址'})
try:
r = requests.get(api_url + '/test', timeout=10)
if r.status_code == 200:
data = r.json()
return jsonify({'success': True, 'message': data.get('message', '连接成功')})
return jsonify({'success': False, 'error': f'服务返回状态码 {r.status_code}'})
except requests.exceptions.ConnectionError:
return jsonify({'success': False, 'error': f'无法连接到: {api_url}'})
except requests.exceptions.Timeout:
return jsonify({'success': False, 'error': '连接超时'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@bp.route('/run', methods=['POST'])
def run_api():
global _sovits_proc
if platform.system() != 'Windows':
return jsonify({'success': False, 'error': '仅支持 Windows 系统'})
cfg = _load_config()
sovits_path = (cfg.get('sovits_path') or '').strip()
start_cmd = (cfg.get('start_cmd') or '').strip()
if not sovits_path:
return jsonify({'success': False, 'error': '请先配置 GPT-SoVITS 路径'})
if not os.path.isdir(sovits_path):
return jsonify({'success': False, 'error': f'路径不存在: {sovits_path}'})
if not start_cmd:
return jsonify({'success': False, 'error': '请先配置启动命令'})
try:
_sovits_proc = subprocess.Popen(start_cmd, shell=True, cwd=sovits_path)
return jsonify({'success': True, 'message': f'已启动: {start_cmd}'})
except Exception as e:
return jsonify({'success': False, 'error': f'启动失败: {str(e)}'})
@bp.route('/status', methods=['GET'])
def check_status():
cfg = _load_config()
api_url = (cfg.get('api_url') or '').strip().rstrip('/')
if not api_url:
return jsonify({'running': False})
try:
r = requests.get(api_url + '/test', timeout=3)
if r.status_code == 200:
data = r.json()
return jsonify({'running': True, 'message': data.get('message', '')})
except Exception:
pass
return jsonify({'running': False})
@bp.route('/kill', methods=['POST'])
def kill_api():
global _sovits_proc
if _sovits_proc and _sovits_proc.poll() is None:
try:
subprocess.call(['taskkill', '/F', '/T', '/PID', str(_sovits_proc.pid)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
pass
_sovits_proc = None
else:
cfg = _load_config()
api_url = (cfg.get('api_url') or '').strip().rstrip('/')
if api_url:
try:
r = requests.get(api_url + '/control?command=exit', timeout=5)
except Exception:
pass
return jsonify({'success': True, 'message': '已发送停止信号'})
@bp.route('/synthesize', methods=['POST'])
def synthesize():
data = request.get_json()
api_url = (data.get('api_url') or '').strip().rstrip('/')
text = (data.get('text') or '').strip()
text_lang = (data.get('text_lang') or 'zh').strip()
if not api_url:
return jsonify({'success': False, 'error': '请填写 GPT-SoVITS 服务地址'})
if not text:
return jsonify({'success': False, 'error': '请输入要合成的文本'})
ref_audio_path = (data.get('ref_audio_path') or '').strip()
prompt_text = (data.get('prompt_text') or '').strip()
prompt_lang = (data.get('prompt_lang') or 'zh').strip()
if not ref_audio_path:
return jsonify({'success': False, 'error': '请填写参考音频路径'})
payload = {
'text': text,
'text_lang': text_lang,
'ref_audio_path': ref_audio_path,
'prompt_text': prompt_text,
'prompt_lang': prompt_lang,
'text_split_method': (data.get('text_split_method') or 'cut5').strip(),
'batch_size': data.get('batch_size', 1),
'top_k': data.get('top_k', 5),
'top_p': data.get('top_p', 1.0),
'temperature': data.get('temperature', 1.0),
'speed_factor': data.get('speed_factor', 1.0),
'seed': data.get('seed', -1),
'media_type': 'wav',
'streaming_mode': False,
}
try:
r = requests.post(api_url + '/tts', json=payload, timeout=300)
content_type = r.headers.get('Content-Type', '')
if r.status_code == 200 and 'audio' in content_type:
return Response(r.content, mimetype='audio/wav',
headers={'Content-Disposition': 'inline'})
else:
try:
err = r.json()
msg = err.get('message', r.text[:200])
except Exception:
msg = r.text[:200]
return jsonify({'success': False, 'error': f'合成失败: {msg}'})
except requests.exceptions.ConnectionError:
return jsonify({'success': False, 'error': f'无法连接到: {api_url}'})
except requests.exceptions.Timeout:
return jsonify({'success': False, 'error': '合成超时300秒文本可能过长'})
except Exception as e:
return jsonify({'success': False, 'error': f'请求失败: {str(e)}'})
@bp.route('/set-gpt-model', methods=['POST'])
def set_gpt_model():
data = request.get_json()
api_url = (data.get('api_url') or '').strip().rstrip('/')
weights_path = (data.get('weights_path') or '').strip()
if not api_url:
return jsonify({'success': False, 'error': '请填写 GPT-SoVITS 服务地址'})
if not weights_path:
return jsonify({'success': False, 'error': '请选择 GPT 模型'})
try:
r = requests.get(api_url + '/set_gpt_weights', params={'weights_path': weights_path}, timeout=60)
if r.status_code == 200:
return jsonify({'success': True, 'message': 'GPT 模型切换成功'})
try:
err = r.json()
msg = err.get('message', r.text[:200])
except Exception:
msg = r.text[:200]
return jsonify({'success': False, 'error': f'切换失败: {msg}'})
except requests.exceptions.ConnectionError:
return jsonify({'success': False, 'error': f'无法连接到: {api_url}'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@bp.route('/set-sovits-model', methods=['POST'])
def set_sovits_model():
data = request.get_json()
api_url = (data.get('api_url') or '').strip().rstrip('/')
weights_path = (data.get('weights_path') or '').strip()
if not api_url:
return jsonify({'success': False, 'error': '请填写 GPT-SoVITS 服务地址'})
if not weights_path:
return jsonify({'success': False, 'error': '请选择 SoVITS 模型'})
try:
r = requests.get(api_url + '/set_sovits_weights', params={'weights_path': weights_path}, timeout=60)
if r.status_code == 200:
return jsonify({'success': True, 'message': 'SoVITS 模型切换成功'})
try:
err = r.json()
msg = err.get('message', r.text[:200])
except Exception:
msg = r.text[:200]
return jsonify({'success': False, 'error': f'切换失败: {msg}'})
except requests.exceptions.ConnectionError:
return jsonify({'success': False, 'error': f'无法连接到: {api_url}'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})

View File

@@ -0,0 +1,338 @@
# -*- coding: utf-8 -*-
"""
STT 语音识别蓝图
使用 faster-whisper 实现音频转文字
"""
import os
import json
import uuid
import subprocess
import tempfile
import threading
import time
from datetime import timedelta
from flask import Blueprint, render_template, request, jsonify
bp = Blueprint('stt', __name__, url_prefix='/stt')
# HuggingFace 镜像
os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'
# OpenCC 繁体转简体
try:
from opencc import OpenCC
_cc_t2s = OpenCC('t2s')
except Exception:
_cc_t2s = None
try:
from config import BASE_DIR
except ImportError:
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'stt_config.json')
# 模型缓存
_model_cache = {}
_task_queue = []
_task_progress = {}
_task_results = {}
_worker_started = False
_worker_lock = threading.Lock()
LANGUAGES = {
'auto': '自动检测',
'zh': '中文', 'en': '英语', 'ja': '日语', 'ko': '韩语',
'fr': '法语', 'de': '德语', 'es': '西班牙语', 'ru': '俄语',
'th': '泰语', 'it': '意大利语', 'pt': '葡萄牙语', 'vi': '越南语',
'ar': '阿拉伯语', 'tr': '土耳其语',
}
def _load_config():
cfg = {'model_dir': ''}
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
cfg.update(json.load(f))
return cfg
def _save_config(cfg):
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def _scan_models(directory):
"""扫描模型目录,提取已下载的模型名称"""
models = []
if not directory or not os.path.isdir(directory):
return models
prefix = 'models--Systran--faster-whisper-'
for name in os.listdir(directory):
full = os.path.join(directory, name)
if os.path.isdir(full) and name.startswith(prefix):
model_name = name[len(prefix):]
if model_name:
models.append(model_name)
return sorted(models)
@bp.route('/')
def page():
cfg = _load_config()
return render_template('stt.html', models=_scan_models(cfg['model_dir']), languages=LANGUAGES)
@bp.route('/config', methods=['GET'])
def get_config():
cfg = _load_config()
cfg['models'] = _scan_models(cfg['model_dir'])
return jsonify(cfg)
@bp.route('/config', methods=['POST'])
def save_config():
data = request.get_json()
cfg = _load_config()
if 'model_dir' in data:
cfg['model_dir'] = data['model_dir'].strip()
_save_config(cfg)
return jsonify({'success': True})
@bp.route('/models')
def list_models():
cfg = _load_config()
return jsonify({'models': _scan_models(cfg['model_dir'])})
@bp.route('/cuda-check')
def cuda_check():
"""检查 CUDA 是否可用ctranslate2 优先PyTorch 兜底)"""
# 方法1: ctranslate2faster-whisper 的实际后端)
try:
import ctranslate2
count = ctranslate2.get_cuda_device_count()
if count > 0:
name = 'CUDA Device'
try:
name = ctranslate2.get_cuda_device_name(0) or name
except Exception:
pass
return jsonify({'cuda': True, 'device_count': count, 'name': name})
except Exception:
pass
# 方法2: PyTorch
try:
import torch
if torch.cuda.is_available():
return jsonify({'cuda': True, 'device_count': torch.cuda.device_count(),
'name': torch.cuda.get_device_name(0)})
except Exception:
pass
return jsonify({'cuda': False})
def _ms_to_srt_time(ms):
td = timedelta(milliseconds=ms)
h, rem = divmod(td.seconds, 3600)
m, s = divmod(rem, 60)
ms_part = td.microseconds // 1000
return f'{h:02d}:{m:02d}:{s:02d},{ms_part:03d}'
def _convert_to_wav(input_path):
"""用 FFmpeg 转为 16kHz 单声道 WAV返回 wav 路径或 None"""
wav_path = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex + '.wav')
cmd = ['ffmpeg', '-y', '-i', input_path, '-ar', '16000', '-ac', '1', wav_path]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300,
creationflags=0x08000000 if os.name == 'nt' else 0)
if result.returncode == 0 and os.path.exists(wav_path):
return wav_path
except Exception:
pass
return None
def _get_model(model_name, device='cpu'):
"""获取或加载模型CUDA 失败自动回退 CPU"""
cfg = _load_config()
model_dir = cfg['model_dir']
cache_key = f'{model_name}_{device}_{model_dir}'
if cache_key not in _model_cache:
from faster_whisper import WhisperModel
try:
_model_cache[cache_key] = WhisperModel(
model_name, device=device, download_root=model_dir
)
except Exception as e:
if device == 'cuda':
# CUDA 加载失败,回退 CPU
cpu_key = f'{model_name}_cpu_{model_dir}'
if cpu_key not in _model_cache:
_model_cache[cpu_key] = WhisperModel(
model_name, device='cpu', download_root=model_dir
)
return _model_cache[cpu_key], f'CUDA 加载失败({e}),已回退到 CPU'
raise
return _model_cache[cache_key], None
def _process_task(task):
"""处理单个转录任务"""
task_id = task['task_id']
fmt = task.get('format', 'text')
device = task.get('device', 'cpu')
try:
_task_progress[task_id] = {'percent': 0, 'status': 'loading', 'format': fmt}
model, warn = _get_model(task['model'], device)
if warn:
_task_progress[task_id] = {'percent': 0, 'status': 'transcribing', 'format': fmt, 'warning': warn}
else:
_task_progress[task_id] = {'percent': 0, 'status': 'transcribing', 'format': fmt}
lang = task['language'] if task['language'] != 'auto' else None
segments, info = model.transcribe(
task['wav_path'],
beam_size=5, best_of=5,
vad_filter=True,
language=lang,
)
total_duration = max(info.duration, 0.01)
results = []
for seg in segments:
_task_progress[task_id] = {
'percent': round(seg.end / total_duration, 2),
'status': 'transcribing',
'format': fmt,
}
text = seg.text.strip()
if not text or len(text) <= 1:
continue
if _cc_t2s:
text = _cc_t2s.convert(text)
start_ms = int(seg.start * 1000)
end_ms = int(seg.end * 1000)
results.append({
'start': start_ms,
'end': end_ms,
'start_time': _ms_to_srt_time(start_ms),
'end_time': _ms_to_srt_time(end_ms),
'text': text,
})
_task_results[task_id] = results
_task_progress[task_id] = {'percent': 1, 'status': 'done', 'format': fmt}
except Exception as e:
_task_progress[task_id] = {'percent': 0, 'status': 'error', 'error': str(e), 'format': fmt}
finally:
# 清理临时文件
try:
if os.path.exists(task['wav_path']):
os.remove(task['wav_path'])
except Exception:
pass
def _worker():
"""后台 worker 线程"""
while True:
if not _task_queue:
time.sleep(1)
continue
task = _task_queue.pop(0)
_process_task(task)
def _ensure_worker():
global _worker_started
if not _worker_started:
with _worker_lock:
if not _worker_started:
t = threading.Thread(target=_worker, daemon=True)
t.start()
_worker_started = True
@bp.route('/transcribe', methods=['POST'])
def transcribe():
if 'audio' not in request.files:
return jsonify({'success': False, 'error': '请上传音频文件'}), 400
audio_file = request.files['audio']
if not audio_file.filename:
return jsonify({'success': False, 'error': '未选择文件'}), 400
model_name = request.form.get('model', 'base')
language = request.form.get('language', 'auto')
device = request.form.get('device', 'cpu')
output_format = request.form.get('format', 'text')
if model_name not in _scan_models(_load_config()['model_dir']):
return jsonify({'success': False, 'error': f'模型 {model_name} 不存在,请先下载到 stt_models 目录'}), 400
# 保存原始文件
ext = os.path.splitext(audio_file.filename)[1].lower()
original_path = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex + ext)
audio_file.save(original_path)
# 转 WAV
wav_path = _convert_to_wav(original_path)
try:
os.remove(original_path)
except Exception:
pass
if not wav_path:
return jsonify({'success': False, 'error': '音频转换失败,请确保 FFmpeg 已安装'}), 500
# 创建任务
task_id = uuid.uuid4().hex
_task_progress[task_id] = {'percent': 0, 'status': 'queued', 'format': output_format}
_task_queue.append({
'task_id': task_id,
'wav_path': wav_path,
'model': model_name,
'language': language,
'device': device,
'format': output_format,
})
_ensure_worker()
return jsonify({'success': True, 'task_id': task_id})
@bp.route('/status/<task_id>')
def task_status(task_id):
progress = _task_progress.get(task_id)
if not progress:
return jsonify({'success': False, 'error': '任务不存在'}), 404
resp = {'success': True, 'progress': progress}
if progress['status'] == 'done':
results = _task_results.get(task_id, [])
fmt = progress.get('format', 'text')
if fmt == 'srt':
lines = []
for i, r in enumerate(results):
lines.append(f"{i+1}\n{r['start_time']} --> {r['end_time']}\n{r['text']}\n")
resp['result'] = '\n'.join(lines)
elif fmt == 'json':
resp['result'] = results
else:
resp['result'] = '\n'.join(r['text'] for r in results)
# 清理
_task_progress.pop(task_id, None)
_task_results.pop(task_id, None)
return jsonify(resp)

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from flask import Blueprint, render_template
bp = Blueprint('token_gen', __name__, url_prefix='/token-gen')
@bp.route('/')
def page():
return render_template('token_gen.html')

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from flask import Blueprint, render_template
bp = Blueprint('url_parser', __name__, url_prefix='/url-parser')
@bp.route('/')
def page():
return render_template('url_parser.html')