From 05e733e8720d7819174abb98e94a8173f6a7da48 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Wed, 17 Jun 2026 10:43:33 +0800 Subject: [PATCH] 22 --- flask-dev-api/.claude/settings.local.json | 15 +- flask-dev-api/app.py | 25 +- flask-dev-api/blueprints/__init__.py | 4 +- flask-dev-api/blueprints/ai_translate.py | 621 +++++++++++++++ flask-dev-api/blueprints/audio_slicer.py | 98 ++- flask-dev-api/blueprints/mp4_to_audio.py | 231 ++++++ flask-dev-api/blueprints/uvr_sep.py | 80 +- flask-dev-api/config/ai_translate_config.json | 9 + flask-dev-api/config/audio_slicer_config.json | 4 +- flask-dev-api/config/sidebar_config.json | 11 + flask-dev-api/config/uvr_sep_config.json | 7 +- flask-dev-api/static/font-awesome.css | 11 + flask-dev-api/stats.db | Bin 163840 -> 204800 bytes flask-dev-api/templates/ai_translate.html | 664 ++++++++++++++++ flask-dev-api/templates/audio_slicer.html | 731 +++++++++++++++++- flask-dev-api/templates/index.html | 245 +++--- flask-dev-api/templates/mp4_to_audio.html | 527 +++++++++++++ flask-dev-api/templates/uvr_sep.html | 370 +++++++-- 18 files changed, 3399 insertions(+), 254 deletions(-) create mode 100644 flask-dev-api/blueprints/ai_translate.py create mode 100644 flask-dev-api/blueprints/mp4_to_audio.py create mode 100644 flask-dev-api/config/ai_translate_config.json create mode 100644 flask-dev-api/config/sidebar_config.json create mode 100644 flask-dev-api/templates/ai_translate.html create mode 100644 flask-dev-api/templates/mp4_to_audio.html diff --git a/flask-dev-api/.claude/settings.local.json b/flask-dev-api/.claude/settings.local.json index 85df226..10b4bb3 100644 --- a/flask-dev-api/.claude/settings.local.json +++ b/flask-dev-api/.claude/settings.local.json @@ -72,7 +72,20 @@ "Bash(cat)", "Bash(python /tmp/check_style.py)", "Read(//tmp/**)", - "Bash(curl -s -o /dev/null -w \"HTTP Status: %{http_code}\\\\nSize: %{size_download} bytes\\\\n\" http://localhost:5000/uvr-sep/)" + "Bash(curl -s -o /dev/null -w \"HTTP Status: %{http_code}\\\\nSize: %{size_download} bytes\\\\n\" http://localhost:5000/uvr-sep/)", + "Bash(python -c \"import torch.utils._pytree as pt; print\\(hasattr\\(pt, 'register_constant'\\)\\); print\\(dir\\(pt\\)\\)\")", + "Bash(python -c \"import unsloth; print\\(unsloth.__version__\\)\")", + "Bash(head -80 \"D:\\\\Environment\\\\python\\\\python3-12\\\\Lib\\\\site-packages\\\\unsloth\\\\__init__.py\" 2>&1)", + "Bash(python -c \"import bitsandbytes as bnb; print\\('bnb OK'\\); print\\(dir\\(bnb.functional.lib\\)\\)\")", + "Bash(ls \"D:\\\\Environment\\\\python\\\\python3-12\\\\Lib\\\\site-packages\\\\unsloth\\\\\" 2>&1 | head -30)", + "Bash(python -c \"from llama_cpp import Llama; print\\('OK'\\)\")", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5000/ai-translate/)", + "Bash(curl -s http://localhost:5000/ai-translate/models)", + "Bash(python -m json.tool)", + "Bash(python -c \"import llama_cpp; print\\('file:', llama_cpp.__file__\\); print\\('supports_gpu:', llama_cpp.llama_supports_gpu_offload\\(\\)\\)\")", + "Bash(cat \"E:/AI/ai-translation/argos/translate-en_zh-1_9/metadata.json\")", + "Bash(ls \"E:\\\\AI\\\\ai-translation\\\\argos\")", + "Bash(cat \"E:\\\\AI\\\\ai-translation\\\\nllb\\\\nllb-200-distilled-600M\\\\config.json\" 2>&1 | head -20)" ] } } diff --git a/flask-dev-api/app.py b/flask-dev-api/app.py index 671cb64..79f3f2e 100644 --- a/flask-dev-api/app.py +++ b/flask-dev-api/app.py @@ -5,16 +5,17 @@ """ import sys import os +import json import time from datetime import datetime from flask import Flask, render_template, jsonify, g, request, session, redirect from config import PORT, LOGIN_ENABLED, LOGIN_USERNAME, LOGIN_PASSWORD, SECRET_KEY -from blueprints import 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, audio_slicer_bp, uvr_sep_bp +from blueprints import 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, audio_slicer_bp, uvr_sep_bp, mp4_to_audio_bp, ai_translate_bp from utils.stats_db import init_db, record_call, get_total_count, get_avg_duration, get_daily_counts, get_monthly_counts, get_available_years, get_available_months, get_daily_counts_by_month, get_hourly_counts, get_endpoint_stats, get_today_count, get_yesterday_count, get_month_count, get_success_rate, get_duration_distribution, get_slowest_endpoints, get_week_compare, get_recent_calls, get_active_days from utils.stats_db import ALLOWED_TABLES, get_table_info, get_table_rows, get_table_count, insert_row, update_row, delete_rows, clear_table -TOOL_COUNT = 17 +TOOL_COUNT = 19 def create_app(): @@ -57,6 +58,24 @@ def create_app(): def data_manage(): return render_template('data_manage.html') + # 侧边栏功能显示配置 + SIDEBAR_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config', 'sidebar_config.json') + + @app.route('/api/sidebar-config', methods=['GET']) + def get_sidebar_config(): + if os.path.exists(SIDEBAR_CONFIG_PATH): + with open(SIDEBAR_CONFIG_PATH, 'r', encoding='utf-8') as f: + return jsonify(json.load(f)) + return jsonify({'hidden_features': []}) + + @app.route('/api/sidebar-config', methods=['POST']) + def save_sidebar_config(): + data = request.get_json(force=True) + os.makedirs(os.path.dirname(SIDEBAR_CONFIG_PATH), exist_ok=True) + with open(SIDEBAR_CONFIG_PATH, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + return jsonify({'success': True}) + # 仪表盘统计接口 @app.route('/api/stats') def api_stats(): @@ -228,6 +247,8 @@ def create_app(): app.register_blueprint(rvc_bp) app.register_blueprint(audio_slicer_bp) app.register_blueprint(uvr_sep_bp) + app.register_blueprint(mp4_to_audio_bp) + app.register_blueprint(ai_translate_bp) return app diff --git a/flask-dev-api/blueprints/__init__.py b/flask-dev-api/blueprints/__init__.py index c498a18..5670c23 100644 --- a/flask-dev-api/blueprints/__init__.py +++ b/flask-dev-api/blueprints/__init__.py @@ -16,9 +16,11 @@ from .ai_dubbing import bp as ai_dubbing_bp from .rvc import bp as rvc_bp from .audio_slicer import bp as audio_slicer_bp from .uvr_sep import bp as uvr_sep_bp +from .mp4_to_audio import bp as mp4_to_audio_bp +from .ai_translate import bp as ai_translate_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', 'audio_slicer_bp', 'uvr_sep_bp', + 'sovits_tts_bp', 'stt_bp', 'ai_dubbing_bp', 'rvc_bp', 'audio_slicer_bp', 'uvr_sep_bp', 'mp4_to_audio_bp', 'ai_translate_bp', ] diff --git a/flask-dev-api/blueprints/ai_translate.py b/flask-dev-api/blueprints/ai_translate.py new file mode 100644 index 0000000..66663ee --- /dev/null +++ b/flask-dev-api/blueprints/ai_translate.py @@ -0,0 +1,621 @@ +# -*- coding: utf-8 -*- +""" +ai-translate AI 翻译蓝图 +支持 NLLB-200 本地翻译引擎 +""" +import os +import re +import json +import uuid +import threading +import tempfile +from flask import Blueprint, render_template, request, jsonify, send_file + +bp = Blueprint('ai_translate', __name__, url_prefix='/ai-translate') + +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_translate_config.json') + +# 引擎状态 +_model = None +_model_lock = threading.Lock() +_engine_status = {'loaded': False, 'loading': False, 'engine_type': None, 'model_name': None, 'error': None} + +_tasks = {} + +SUBTITLE_EXTS = ('.srt', '.ass', '.ssa', '.vtt') +LANGUAGES = { + 'zh': '中文', 'en': '英文', 'ja': '日文', 'ko': '韩文', + 'fr': '法文', 'de': '德文', 'es': '西班牙文', 'ru': '俄文', + 'th': '泰文', 'vi': '越南文', 'it': '意大利文', + 'pt': '葡萄牙文', 'auto': '自动检测', +} + +# NLLB-200 语言代码映射 +_NLLB_LANG_MAP = { + 'zh': 'zho_Hans', 'en': 'eng_Latn', 'ja': 'jpn_Jpan', 'ko': 'kor_Hang', + 'fr': 'fra_Latn', 'de': 'deu_Latn', 'es': 'spa_Latn', 'ru': 'rus_Cyrl', + 'th': 'tha_Thai', 'vi': 'vie_Latn', 'it': 'ita_Latn', + 'pt': 'por_Latn', +} + +# langdetect 返回值 → 我们的语言代码 +_DETECT_LANG_MAP = { + 'zh-cn': 'zh', 'zh-tw': 'zh', 'zh': 'zh', + 'en': 'en', 'ja': 'ja', 'ko': 'ko', + 'fr': 'fr', 'de': 'de', 'es': 'es', 'ru': 'ru', + 'th': 'th', 'vi': 'vi', 'it': 'it', 'pt': 'pt', +} + +ENGINE_TYPES = { + 'nllb': {'name': 'NLLB-200', 'desc': 'HF 目录或模型 ID'}, +} + + +def _default_config(): + return { + 'engine_type': 'nllb', + 'nllb_dir': '', + 'max_new_tokens': 512, + 'batch_size': 8, + 'last_nllb_model': '', + 'last_src_lang': 'en', + 'last_tgt_lang': 'zh', + } + + +def _load_config(): + cfg = _default_config() + if os.path.exists(CONFIG_PATH): + try: + with open(CONFIG_PATH, 'r', encoding='utf-8') as f: + cfg.update(json.load(f)) + except Exception: + pass + return cfg + + +def _save_config(cfg): + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + with open(CONFIG_PATH, 'w', encoding='utf-8') as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) + + + +# ==================== 字幕解析 ==================== + +def _parse_srt(text): + blocks = re.split(r'\n\s*\n', text.strip()) + entries = [] + for block in blocks: + lines = block.strip().split('\n') + if len(lines) < 3: + continue + try: + idx = int(lines[0].strip()) + except ValueError: + continue + timecode = lines[1].strip() + content = '\n'.join(lines[2:]).strip() + if content: + entries.append((idx, timecode, content)) + return entries + + +def _build_srt(entries): + parts = [] + for idx, tc, content in entries: + parts.append(f'{idx}\n{tc}\n{content}') + return '\n\n'.join(parts) + '\n' + + +def _parse_vtt(text): + text = re.sub(r'^WEBVTT\s*\n', '', text.strip(), count=1) + blocks = re.split(r'\n\s*\n', text.strip()) + entries = [] + for i, block in enumerate(blocks): + lines = block.strip().split('\n') + if len(lines) < 2: + continue + timecode = None + content_lines = [] + for line in lines: + if '-->' in line: + timecode = line.strip() + elif timecode is not None: + content_lines.append(line) + else: + if '-->' not in line: + continue + if timecode and content_lines: + entries.append((i + 1, timecode, '\n'.join(content_lines).strip())) + return entries + + +def _build_vtt(entries): + parts = ['WEBVTT\n'] + for idx, tc, content in entries: + parts.append(f'{idx}\n{tc}\n{content}') + return '\n\n'.join(parts) + '\n' + + +def _parse_ass(text): + entries = [] + for line in text.split('\n'): + line = line.strip() + if line.lower().startswith('dialogue:'): + entries.append(line) + return entries + + +def _ass_extract_text(dialogue_line): + parts = dialogue_line.split(',', 9) + if len(parts) < 10: + return dialogue_line, '' + text = parts[9] + text = re.sub(r'\{[^}]*\}', '', text) + text = text.replace('\\N', '\n').replace('\\n', '\n') + return dialogue_line, text.strip() + + +def _ass_replace_text(dialogue_line, new_text): + parts = dialogue_line.split(',', 9) + if len(parts) < 10: + return dialogue_line + translated = new_text.replace('\n', '\\N') + parts[9] = translated + return ','.join(parts) + + +def _build_ass(original_lines, translated_texts): + result = [] + trans_idx = 0 + for line in original_lines: + if line.lower().startswith('dialogue:'): + if trans_idx < len(translated_texts): + result.append(_ass_replace_text(line, translated_texts[trans_idx])) + trans_idx += 1 + else: + result.append(line) + else: + result.append(line) + return '\n'.join(result) + + +def _detect_subtitle_format(filename, content): + ext = os.path.splitext(filename)[1].lower() + if ext == '.srt': + return 'srt' + if ext == '.vtt': + return 'vtt' + if ext in ('.ass', '.ssa'): + return 'ass' + if 'WEBVTT' in content[:20]: + return 'vtt' + if 'Dialogue:' in content: + return 'ass' + return 'srt' + + +# ==================== 模型加载与翻译 ==================== + +def _load_model(model_path, model_name): + global _model + from transformers import AutoTokenizer, AutoModelForSeq2SeqLM + import torch + + _engine_status['loading'] = True + _engine_status['error'] = None + try: + device = 'cuda' if torch.cuda.is_available() else 'cpu' + tokenizer = AutoTokenizer.from_pretrained(model_path, clean_up_tokenization_spaces=True) + model = AutoModelForSeq2SeqLM.from_pretrained(model_path) + model = model.to(device) + model.eval() + _model = {'model': model, 'tokenizer': tokenizer, 'device': device} + + _engine_status['loaded'] = True + _engine_status['model_name'] = model_name + _engine_status['engine_type'] = 'nllb' + _engine_status['error'] = None + return True, None + + except Exception as e: + _engine_status['error'] = str(e) + return False, str(e) + finally: + _engine_status['loading'] = False + + +def _unload_model(): + global _model + _model = None + _engine_status['loaded'] = False + _engine_status['model_name'] = None + _engine_status['engine_type'] = None + import gc + gc.collect() + try: + import torch + if torch.cuda.is_available(): + torch.cuda.empty_cache() + except Exception: + pass + + +def _detect_lang(text): + """用 langdetect 检测语言,返回我们的语言代码(如 'zh'、'en')""" + try: + from langdetect import detect + code = detect(text) + return _DETECT_LANG_MAP.get(code, _DETECT_LANG_MAP.get(code.split('-')[0], 'en')) + except Exception: + return 'en' + + +def _do_translate(texts, src_lang, tgt_lang, cfg): + if not _model: + return None, '模型未加载' + try: + import torch + model = _model['model'] + tokenizer = _model['tokenizer'] + device = _model['device'] + + nllb_tgt = _NLLB_LANG_MAP.get(tgt_lang, _NLLB_LANG_MAP.get('zh', 'zho_Hans')) + tgt_token_id = tokenizer.convert_tokens_to_ids(nllb_tgt) + + # 自动检测时,用第一条文本检测语言 + if src_lang == 'auto': + sample = ' '.join(texts[:3]) + detected = _detect_lang(sample) + nllb_src = _NLLB_LANG_MAP.get(detected, 'eng_Latn') + else: + nllb_src = _NLLB_LANG_MAP.get(src_lang, 'eng_Latn') + + results = [] + for text in texts: + tokenizer.src_lang = nllb_src + inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=512).to(device) + with torch.no_grad(): + translated = model.generate( + **inputs, + forced_bos_token_id=tgt_token_id, + max_new_tokens=cfg.get('max_new_tokens', 512) + ) + result = tokenizer.decode(translated[0], skip_special_tokens=True) + results.append(result) + return results, None + except Exception as e: + return None, str(e) + + +def _ensure_model(src_lang=None, tgt_lang=None): + if _engine_status['loaded'] and _model: + return True, None + cfg = _load_config() + models_dir = cfg.get('nllb_dir', '') + if not models_dir or not os.path.isdir(models_dir): + return False, '请在设置中配置 NLLB 模型目录' + + # 用 last 模型或第一个可用 + model_name = cfg.get('last_nllb_model', '') + if model_name: + full = os.path.join(models_dir, model_name) + if not (os.path.isdir(full) and os.path.exists(os.path.join(full, 'config.json'))): + model_name = '' + if not model_name: + for name in sorted(os.listdir(models_dir)): + full = os.path.join(models_dir, name) + if os.path.isdir(full) and os.path.exists(os.path.join(full, 'config.json')): + model_name = name + break + if not model_name: + return False, '未找到 NLLB 模型,请在设置中配置目录' + + model_path = os.path.join(models_dir, model_name) + with _model_lock: + if _engine_status['loaded'] and _model: + return True, None + ok, load_err = _load_model(model_path, model_name) + if ok: + cfg['last_nllb_model'] = model_name + _save_config(cfg) + return ok, load_err + + +# ==================== 启动时自动加载模型 ==================== + +_auto_load_done = False + + +def _trigger_auto_load(): + global _auto_load_done + if _auto_load_done: + return + _auto_load_done = True + + threading.Thread(target=_ensure_model, daemon=True).start() + + +# ==================== 路由 ==================== + +@bp.route('/') +def page(): + _trigger_auto_load() + cfg = _load_config() + return render_template('ai_translate.html', config=cfg, languages=LANGUAGES, engine_types=ENGINE_TYPES) + + +@bp.route('/model-status') +def model_status(): + return jsonify({ + 'loaded': _engine_status['loaded'], + 'loading': _engine_status['loading'], + 'model_name': _engine_status.get('model_name'), + }) + + +@bp.route('/config', methods=['GET']) +def get_config(): + return jsonify(_load_config()) + + +@bp.route('/config', methods=['POST']) +def save_config(): + data = request.get_json() + cfg = _load_config() + for key in ('nllb_dir', 'last_src_lang', 'last_tgt_lang', 'max_new_tokens', 'batch_size'): + if key in data: + cfg[key] = data[key] + _save_config(cfg) + return jsonify({'success': True}) + + +@bp.route('/models') +def list_models(): + cfg = _load_config() + models_dir = cfg.get('nllb_dir', '') + models = [] + if models_dir and os.path.isdir(models_dir): + for name in sorted(os.listdir(models_dir)): + full = os.path.join(models_dir, name) + if os.path.isdir(full) and os.path.exists(os.path.join(full, 'config.json')): + models.append({'name': name, 'type': 'huggingface', 'path': full}) + return jsonify({'models': models, 'models_dir': models_dir, 'last_model': cfg.get('last_nllb_model', '')}) + + +@bp.route('/languages') +def list_languages(): + langs = [{'code': c, 'name': n} for c, n in LANGUAGES.items() if c != 'auto'] + return jsonify({'src_langs': [{'code': 'auto', 'name': '自动检测'}] + langs, 'tgt_langs': langs}) + + +@bp.route('/unload-model', methods=['POST']) +def unload_model(): + with _model_lock: + _unload_model() + cfg = _load_config() + cfg['last_nllb_model'] = '' + _save_config(cfg) + return jsonify({'success': True, 'message': '模型已卸载'}) + + +@bp.route('/load-model', methods=['POST']) +def load_model(): + data = request.get_json() or {} + model_name = data.get('model_name', '').strip() + if not model_name: + return jsonify({'success': False, 'error': '请指定模型名称'}), 400 + + cfg = _load_config() + models_dir = cfg.get('nllb_dir', '') + if not models_dir or not os.path.isdir(models_dir): + return jsonify({'success': False, 'error': '请先配置 NLLB 模型目录'}), 400 + + model_path = os.path.join(models_dir, model_name) + if not os.path.isdir(model_path): + return jsonify({'success': False, 'error': f'模型目录不存在: {model_name}'}), 400 + + with _model_lock: + _unload_model() + ok, err = _load_model(model_path, model_name) + if ok: + cfg['last_nllb_model'] = model_name + _save_config(cfg) + return jsonify({'success': True, 'model_name': model_name}) + return jsonify({'success': False, 'error': err or '加载失败'}), 400 + + +@bp.route('/translate-text', methods=['POST']) +def translate_text(): + data = request.get_json() + text = (data.get('text') or '').strip() + if not text: + return jsonify({'success': False, 'error': '请输入要翻译的文本'}), 400 + + src_lang = data.get('src_lang', 'en') + tgt_lang = data.get('tgt_lang', 'zh') + cfg = _load_config() + batch_size = cfg.get('batch_size', 8) + + cfg['last_src_lang'] = src_lang + cfg['last_tgt_lang'] = tgt_lang + _save_config(cfg) + + ok, err = _ensure_model(src_lang, tgt_lang) + if not ok: + return jsonify({'success': False, 'error': err}), 400 + + task_id = uuid.uuid4().hex + _tasks[task_id] = {'status': 'translating', 'progress': 0, 'result': None, 'error': None} + + def _do_task(): + try: + paragraphs = [p.strip() for p in text.split('\n') if p.strip()] + if not paragraphs: + paragraphs = [text] + + all_results = [] + total = len(paragraphs) + for i in range(0, total, batch_size): + batch = paragraphs[i:i + batch_size] + results, err = _do_translate(batch, src_lang, tgt_lang, cfg) + if err: + _tasks[task_id] = {'status': 'error', 'error': err} + return + all_results.extend(results) + _tasks[task_id]['progress'] = round((i + len(batch)) / total * 100) + + _tasks[task_id] = {'status': 'done', 'result': '\n'.join(all_results), 'progress': 100} + + except Exception as e: + _tasks[task_id] = {'status': 'error', 'error': str(e)} + + threading.Thread(target=_do_task, daemon=True).start() + return jsonify({'success': True, 'task_id': task_id}) + + +@bp.route('/translate-file', methods=['POST']) +def translate_file(): + if 'subtitle' not in request.files: + return jsonify({'success': False, 'error': '请上传字幕文件'}), 400 + f = request.files['subtitle'] + if not f.filename: + return jsonify({'success': False, 'error': '请上传字幕文件'}), 400 + + ext = os.path.splitext(f.filename)[1].lower() + if ext not in SUBTITLE_EXTS: + return jsonify({'success': False, 'error': f'不支持的格式: {ext},支持 SRT/ASS/VTT'}), 400 + + src_lang = request.form.get('src_lang', 'en') + tgt_lang = request.form.get('tgt_lang', 'zh') + cfg = _load_config() + batch_size = cfg.get('batch_size', 8) + + cfg['last_src_lang'] = src_lang + cfg['last_tgt_lang'] = tgt_lang + _save_config(cfg) + + ok, err = _ensure_model(src_lang, tgt_lang) + if not ok: + return jsonify({'success': False, 'error': err}), 400 + + content = f.read().decode('utf-8', errors='replace') + fmt = _detect_subtitle_format(f.filename, content) + + task_id = uuid.uuid4().hex + _tasks[task_id] = {'status': 'translating', 'progress': 0, 'result': None, 'error': None, + 'filename': f.filename, 'format': fmt} + + def _do_task(): + try: + if fmt == 'srt': + entries = _parse_srt(content) + texts = [e[2] for e in entries] + elif fmt == 'vtt': + entries = _parse_vtt(content) + texts = [e[2] for e in entries] + elif fmt == 'ass': + full_lines = content.split('\n') + dialogue_lines = [l for l in full_lines if l.strip().lower().startswith('dialogue:')] + texts = [] + for dl in dialogue_lines: + _, txt = _ass_extract_text(dl) + texts.append(txt if txt else '') + entries = dialogue_lines + else: + _tasks[task_id] = {'status': 'error', 'error': '未知字幕格式'} + return + + if not texts: + _tasks[task_id] = {'status': 'error', 'error': '字幕文件中未找到可翻译的文本'} + return + + all_results = [] + total = len(texts) + + for i in range(0, total, batch_size): + batch = texts[i:i + batch_size] + batch = [t for t in batch if t.strip()] + if not batch: + all_results.extend(texts[i:i + batch_size]) + _tasks[task_id]['progress'] = round((i + batch_size) / total * 100) + continue + + results, err = _do_translate(batch, src_lang, tgt_lang, cfg) + if err: + _tasks[task_id] = {'status': 'error', 'error': err} + return + + ri = 0 + for j in range(i, min(i + batch_size, total)): + if texts[j].strip(): + all_results.append(results[ri] if ri < len(results) else texts[j]) + ri += 1 + else: + all_results.append('') + _tasks[task_id]['progress'] = round(min(i + batch_size, total) / total * 100) + + if fmt == 'srt': + translated_entries = [(entries[i][0], entries[i][1], all_results[i]) for i in range(len(entries))] + output = _build_srt(translated_entries) + elif fmt == 'vtt': + translated_entries = [(entries[i][0], entries[i][1], all_results[i]) for i in range(len(entries))] + output = _build_vtt(translated_entries) + elif fmt == 'ass': + output = _build_ass(content.split('\n'), all_results) + else: + output = '\n'.join(all_results) + + out_path = os.path.join(tempfile.gettempdir(), f'ai_trans_{task_id}{ext}') + with open(out_path, 'w', encoding='utf-8') as fout: + fout.write(output) + + _tasks[task_id] = { + 'status': 'done', 'progress': 100, + 'result': output, 'file_path': out_path, + 'filename': f.filename, 'format': fmt, + } + + except Exception as e: + _tasks[task_id] = {'status': 'error', 'error': str(e)} + + threading.Thread(target=_do_task, daemon=True).start() + return jsonify({'success': True, 'task_id': task_id}) + + +@bp.route('/task-status/') +def task_status(task_id): + task = _tasks.get(task_id) + if not task: + return jsonify({'success': False, 'error': '任务不存在'}), 404 + return jsonify({'success': True, **task}) + + +@bp.route('/download/') +def download(task_id): + task = _tasks.get(task_id) + if not task or task.get('status') != 'done': + return jsonify({'error': '文件不存在'}), 404 + file_path = task.get('file_path') + if not file_path or not os.path.exists(file_path): + return jsonify({'error': '文件不存在'}), 404 + orig = task.get('filename', 'translated') + base, ext = os.path.splitext(orig) + return send_file(file_path, as_attachment=True, download_name=f'{base}_translated{ext}') + + +@bp.route('/cleanup/', methods=['POST']) +def cleanup(task_id): + task = _tasks.pop(task_id, None) + if task and task.get('file_path'): + try: + os.remove(task['file_path']) + except OSError: + pass + return jsonify({'success': True}) diff --git a/flask-dev-api/blueprints/audio_slicer.py b/flask-dev-api/blueprints/audio_slicer.py index 06a143a..6c8d90c 100644 --- a/flask-dev-api/blueprints/audio_slicer.py +++ b/flask-dev-api/blueprints/audio_slicer.py @@ -156,6 +156,93 @@ def analyze(): }) +@bp.route('/manual-slice', methods=['POST']) +def manual_slice(): + data = request.get_json() + task_id = data.get('task_id') + task = _tasks.get(task_id) + if not task: + return jsonify({'success': False, 'error': '任务不存在'}), 404 + + cut_points = sorted(data.get('cut_points', [])) + sr = task['sr'] + total = task['total'] + duration = task['duration'] + + valid = [p for p in cut_points if 0 < p < duration] + if not valid: + return jsonify({'success': False, 'error': '没有有效的切割点'}), 400 + + samples = [int(round(p * sr)) for p in valid] + boundaries = [0] + samples + [total] + ranges = [(boundaries[i], boundaries[i + 1]) + for i in range(len(boundaries) - 1) + if boundaries[i + 1] > boundaries[i]] + + task['ranges'] = ranges + task['status'] = 'analyzed' + + preview = [{'index': i, 'duration': round((e - b) / sr, 2), 'samples': e - b} + for i, (b, e) in enumerate(ranges)] + return jsonify({'success': True, 'count': len(ranges), 'preview': preview, + 'sample_rate': int(sr), 'channels': int(task['ch'])}) + + +@bp.route('/preview-range/') +def preview_range(task_id): + task = _tasks.get(task_id) + if not task: + return jsonify({'error': '任务不存在'}), 404 + try: + start = float(request.args.get('start', 0)) + end = float(request.args.get('end', task['duration'])) + except (ValueError, TypeError): + return jsonify({'error': '参数错误'}), 400 + + sr = task['sr'] + ch = task['ch'] + begin = max(0, int(start * sr)) + end_sample = min(task['total'], int(end * sr)) + if end_sample <= begin: + return jsonify({'error': '无效范围'}), 400 + + buf = io.BytesIO() + with soundfile.SoundFile(task['src']) as src: + src.seek(begin) + frames = end_sample - begin + data = src.read(frames) + with soundfile.SoundFile(buf, mode='w', samplerate=sr, channels=ch, + format='WAV') as dst: + dst.write(data) + buf.seek(0) + return send_file(buf, mimetype='audio/wav') + + +@bp.route('/waveform-peaks/') +def waveform_peaks(task_id): + """返回波形峰值数据,供前端绘制波形图""" + task = _tasks.get(task_id) + if not task: + return jsonify({'error': '任务不存在'}), 404 + num_samples = int(request.args.get('samples', 400)) + with soundfile.SoundFile(task['src']) as f: + sr = f.samplerate + ch = f.channels + total = len(f) + samples_per_bucket = max(1, total // num_samples) + peaks = [] + for i in range(num_samples): + start = i * samples_per_bucket + length = min(samples_per_bucket, total - start) + if length <= 0: + break + data = f.read(length) + if ch > 1: + data = data.mean(axis=1) + peaks.append(float(abs(data).max())) + return jsonify({'peaks': peaks, 'duration': task['duration'], 'sr': sr}) + + @bp.route('/slice', methods=['POST']) def start_slice(): data = request.get_json() @@ -166,6 +253,16 @@ def start_slice(): if not task.get('ranges'): return jsonify({'success': False, 'error': '请先分析'}), 400 + # 支持选择性切割 + selected = data.get('selected_indices') + all_ranges = task['ranges'] + if selected is not None and isinstance(selected, list) and len(selected) > 0: + ranges = [all_ranges[i] for i in selected if 0 <= i < len(all_ranges)] + else: + ranges = all_ranges + if not ranges: + return jsonify({'success': False, 'error': '未选择任何片段'}), 400 + task['status'] = 'slicing' task['progress'] = 0 task['slices'] = [] @@ -176,7 +273,6 @@ def start_slice(): def _do_slice(): base = os.path.splitext(task['orig_name'])[0] - ranges = task['ranges'] total = len(ranges) for i, (begin, end) in enumerate(ranges): out_path = os.path.join(out_dir, f'{base}_{i:03d}.wav') diff --git a/flask-dev-api/blueprints/mp4_to_audio.py b/flask-dev-api/blueprints/mp4_to_audio.py new file mode 100644 index 0000000..6c8a8c9 --- /dev/null +++ b/flask-dev-api/blueprints/mp4_to_audio.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +""" +mp4-to-audio MP4转音频蓝图 +基于 ffmpeg 提取视频中的音频流 +""" +import os +import re +import json +import uuid +import subprocess +import threading +import tempfile +from flask import Blueprint, render_template, request, jsonify, send_file, after_this_request + +try: + from config import BASE_DIR +except ImportError: + BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +bp = Blueprint('mp4_to_audio', __name__, url_prefix='/mp4-to-audio') + +VIDEO_EXTS = ('.mp4', '.mkv', '.avi', '.webm', '.mov', '.flv', '.wmv') +CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'mp4_to_audio_config.json') + +DEFAULT_PARAMS = { + 'output_format': 'mp3', + 'mp3_bitrate': '320k', + 'wav_sample_rate': 'original', +} + +_tasks = {} + + +def _load_config(): + cfg = dict(DEFAULT_PARAMS) + if os.path.exists(CONFIG_PATH): + try: + with open(CONFIG_PATH, 'r', encoding='utf-8') as f: + cfg.update(json.load(f)) + except Exception: + pass + return cfg + + +def _save_config(cfg): + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + with open(CONFIG_PATH, 'w', encoding='utf-8') as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) + + +def _check_ffmpeg(): + try: + r = subprocess.run(['ffmpeg', '-version'], capture_output=True, text=True, timeout=5) + return r.returncode == 0 + except Exception: + return False + + +def _get_duration(path): + try: + cmd = ['ffprobe', '-v', 'error', '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', path] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + return float(r.stdout.strip()) + except Exception: + return 0.0 + + +def _do_convert(task_id): + task = _tasks[task_id] + src = task['src'] + output_format = task['output_format'] + out_ext = '.mp3' if output_format == 'mp3' else '.wav' + base = os.path.splitext(task['orig_name'])[0] + out_filename = f'{base}{out_ext}' + out_path = os.path.join(tempfile.gettempdir(), f'mp4audio_{task_id}{out_ext}') + + cmd = ['ffmpeg', '-y', '-i', src] + if output_format == 'mp3': + cmd += ['-vn', '-acodec', 'libmp3lame', '-b:a', task['mp3_bitrate']] + else: + cmd += ['-vn', '-acodec', 'pcm_s16le'] + if task['wav_sample_rate'] != 'original': + cmd += ['-ar', task['wav_sample_rate']] + cmd.append(out_path) + + duration = task['duration'] + try: + proc = subprocess.Popen(cmd, stderr=subprocess.PIPE, universal_newlines=True) + for line in proc.stderr: + m = re.search(r'time=(\d+):(\d+):(\d+\.?\d*)', line) + if m and duration > 0: + cur = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + float(m.group(3)) + task['progress'] = min(99, int(cur / duration * 100)) + proc.wait() + if proc.returncode != 0: + task['status'] = 'error' + task['error'] = 'ffmpeg 转换失败' + return + except Exception as e: + task['status'] = 'error' + task['error'] = str(e) + return + + task['output_path'] = out_path + task['filename'] = out_filename + task['status'] = 'done' + task['progress'] = 100 + + +@bp.route('/') +def page(): + cfg = _load_config() + ffmpeg_ok = _check_ffmpeg() + return render_template('mp4_to_audio.html', config=cfg, ffmpeg_ok=ffmpeg_ok) + + +@bp.route('/config', methods=['GET']) +def get_config(): + return jsonify(_load_config()) + + +@bp.route('/config', methods=['POST']) +def save_config(): + data = request.get_json() + cfg = _load_config() + for key in DEFAULT_PARAMS: + if key in data: + cfg[key] = data[key] + _save_config(cfg) + return jsonify({'success': True}) + + +@bp.route('/upload', methods=['POST']) +def upload(): + if 'video' not in request.files: + return jsonify({'success': False, 'error': '请上传视频文件'}), 400 + f = request.files['video'] + if not f.filename: + return jsonify({'success': False, 'error': '请上传视频文件'}), 400 + ext = os.path.splitext(f.filename)[1].lower() + if ext not in VIDEO_EXTS: + return jsonify({'success': False, 'error': f'不支持的格式: {ext}'}), 400 + + task_id = uuid.uuid4().hex + save_path = os.path.join(tempfile.gettempdir(), f'mp4audio_{task_id}{ext}') + f.save(save_path) + + duration = _get_duration(save_path) + size = os.path.getsize(save_path) + + _tasks[task_id] = { + 'src': save_path, 'orig_name': f.filename, + 'output_path': None, 'output_format': 'mp3', + 'duration': duration, 'size': size, + 'status': 'uploaded', 'progress': 0, 'error': None, + 'filename': None, 'mp3_bitrate': '320k', 'wav_sample_rate': 'original', + } + return jsonify({ + 'success': True, 'task_id': task_id, + 'filename': f.filename, 'duration': round(duration, 2), 'size': size, + }) + + +@bp.route('/convert', methods=['POST']) +def start_convert(): + data = request.get_json() + task_id = data.get('task_id') + task = _tasks.get(task_id) + if not task: + return jsonify({'success': False, 'error': '任务不存在'}), 404 + + task['output_format'] = data.get('output_format', 'mp3') + task['mp3_bitrate'] = data.get('mp3_bitrate', '320k') + task['wav_sample_rate'] = data.get('wav_sample_rate', 'original') + task['status'] = 'converting' + task['progress'] = 0 + task['error'] = None + + threading.Thread(target=_do_convert, args=(task_id,), daemon=True).start() + return jsonify({'success': True}) + + +@bp.route('/status/') +def status(task_id): + task = _tasks.get(task_id) + if not task: + return jsonify({'success': False, 'error': '任务不存在'}), 404 + resp = { + 'success': True, 'status': task['status'], 'progress': task['progress'], + } + if task['status'] == 'done': + resp['filename'] = task['filename'] + resp['output_format'] = task['output_format'] + elif task['status'] == 'error': + resp['error'] = task.get('error', '未知错误') + return jsonify(resp) + + +@bp.route('/download/') +def download(task_id): + task = _tasks.get(task_id) + if not task or not task.get('output_path') or not os.path.exists(task['output_path']): + return jsonify({'error': '文件不存在'}), 404 + mime = 'audio/mpeg' if task['output_format'] == 'mp3' else 'audio/wav' + return send_file(task['output_path'], mimetype=mime, as_attachment=True, + download_name=task['filename']) + + +@bp.route('/play/') +def play(task_id): + task = _tasks.get(task_id) + if not task or not task.get('output_path') or not os.path.exists(task['output_path']): + return jsonify({'error': '文件不存在'}), 404 + mime = 'audio/mpeg' if task['output_format'] == 'mp3' else 'audio/wav' + return send_file(task['output_path'], mimetype=mime) + + +@bp.route('/cleanup/', methods=['POST']) +def cleanup(task_id): + task = _tasks.pop(task_id, None) + if not task: + return jsonify({'success': True}) + for key in ('src', 'output_path'): + try: + p = task.get(key) + if p and os.path.exists(p): + os.remove(p) + except OSError: + pass + return jsonify({'success': True}) diff --git a/flask-dev-api/blueprints/uvr_sep.py b/flask-dev-api/blueprints/uvr_sep.py index 7a9f2e6..13a8774 100644 --- a/flask-dev-api/blueprints/uvr_sep.py +++ b/flask-dev-api/blueprints/uvr_sep.py @@ -40,6 +40,7 @@ _tasks = {} DEFAULT_PARAMS = { 'uvr_project_path': '', 'model_dir_mode': 'absolute', 'demucs_model_dir': '', 'vr_model_dir': '', 'mdx_model_dir': '', + 'demucs_model_dir_mode': 'absolute', 'vr_model_dir_mode': 'absolute', 'mdx_model_dir_mode': 'absolute', 'arch_type': 'Demucs', 'save_format': 'wav', 'wav_type': 'PCM_16', 'mp3_bitrate': '320k', 'is_gpu': True, 'device_set': 'Default', @@ -70,7 +71,7 @@ def _save_config(cfg): json.dump(cfg, f, ensure_ascii=False, indent=2) -def _resolve_model_dir(raw_dir, cfg=None): +def _resolve_model_dir(raw_dir, cfg=None, mode=None): """将模型目录路径解析为绝对路径""" if not raw_dir: return '' @@ -79,7 +80,8 @@ def _resolve_model_dir(raw_dir, cfg=None): if cfg is None: cfg = _load_config() uvr_path = cfg.get('uvr_project_path', '') - mode = cfg.get('model_dir_mode', 'absolute') + if mode is None: + mode = cfg.get('model_dir_mode', 'absolute') if mode == 'relative' and uvr_path: return os.path.normpath(os.path.join(uvr_path, raw_dir)) # absolute 模式下如果输入的是相对路径,也尝试拼接 uvr_project_path @@ -92,13 +94,15 @@ def _resolve_model_dir(raw_dir, cfg=None): def _get_model_dir_for_arch(arch_type, explicit_dir=None, cfg=None): """获取指定架构的模型目录:优先显式路径,否则从配置自动推断""" - if explicit_dir: - return _resolve_model_dir(explicit_dir, cfg) if cfg is None: cfg = _load_config() + mode_map = {'Demucs': 'demucs_model_dir_mode', 'VR Arc': 'vr_model_dir_mode', 'MDX-Net': 'mdx_model_dir_mode'} + mode = cfg.get(mode_map.get(arch_type, ''), cfg.get('model_dir_mode', 'absolute')) + if explicit_dir: + return _resolve_model_dir(explicit_dir, cfg, mode=mode) key_map = {'Demucs': 'demucs_model_dir', 'VR Arc': 'vr_model_dir', 'MDX-Net': 'mdx_model_dir'} raw = cfg.get(key_map.get(arch_type, ''), '') - return _resolve_model_dir(raw, cfg) + return _resolve_model_dir(raw, cfg, mode=mode) def _get_uvr_paths(): @@ -639,18 +643,47 @@ def _update_progress(task_id, step, inference_iterations=0): return progress = min(99, max(1, int((step + inference_iterations) * 100))) task['progress'] = progress + import time as _t + # 记录子进度(inference_iterations > 0 表示推理中的迭代进度) + if inference_iterations > 0: + last = task.get('_last_iter_log', 0) + if inference_iterations - last >= 0.1: + task['_last_iter_log'] = inference_iterations + task['logs'].append('[%s] Inference iteration: %.0f%%' % (_t.strftime('%H:%M:%S'), inference_iterations * 100)) + if len(task['logs']) > 500: + task['logs'] = task['logs'][-500:] + else: + # 主进度每 20% 记录一条 + last = task.get('_last_progress_log', 0) + if progress - last >= 20: + task['_last_progress_log'] = progress + task['logs'].append('[%s] Progress: %d%%' % (_t.strftime('%H:%M:%S'), progress)) + if len(task['logs']) > 500: + task['logs'] = task['logs'][-500:] def _make_process_data(task_id, model_data, audio_path, export_path): """构造 process_data 字典""" base = os.path.splitext(os.path.basename(audio_path))[0] + task = _tasks.get(task_id) + def _write_console(*args, **kwargs): + msg = ' '.join(str(a) for a in args) + msg = msg.replace('\r\n', '\n').replace('\r', '\n').strip() + if msg and task: + import time as _t + for line in msg.split('\n'): + line = line.strip() + if line: + task['logs'].append('[%s] %s' % (_t.strftime('%H:%M:%S'), line)) + if len(task['logs']) > 500: + task['logs'] = task['logs'][-500:] return { 'model_data': model_data, 'export_path': export_path, 'audio_file_base': base, 'audio_file': audio_path, 'set_progress_bar': lambda step, it=0: _update_progress(task_id, step, it), - 'write_to_console': lambda *_, **__: None, + 'write_to_console': _write_console, 'process_iteration': lambda: None, 'cached_source_callback': lambda *_, **__: (None, None), 'cached_model_source_holder': lambda *_, **__: None, @@ -663,6 +696,29 @@ def _make_process_data(task_id, model_data, audio_path, export_path): def _do_separate(task_id, model_path, process_method, audio_path, params, model_meta): """后台线程执行分离""" task = _tasks[task_id] + import time as _time + + # 日志捕获 + class _LogCapture: + def __init__(self, orig): + self._orig = orig + def write(self, msg): + if msg and msg.strip(): + text = msg.replace('\r\n', '\n').replace('\r', '\n').strip() + for line in text.split('\n'): + line = line.strip() + if line: + task['logs'].append('[%s] %s' % (_time.strftime('%H:%M:%S'), line)) + if len(task['logs']) > 500: + task['logs'] = task['logs'][-500:] + self._orig.write(msg) + def flush(self): + self._orig.flush() + + old_stdout, old_stderr = sys.stdout, sys.stderr + sys.stdout = _LogCapture(old_stdout) + sys.stderr = _LogCapture(old_stderr) + try: _ensure_uvr_imports() @@ -727,6 +783,8 @@ def _do_separate(task_id, model_path, process_method, audio_path, params, model_ task['error'] = str(e) traceback.print_exc() finally: + sys.stdout = old_stdout + sys.stderr = old_stderr try: os.remove(audio_path) except OSError: @@ -945,6 +1003,7 @@ def start_separate(): 'stems': [], 'error': None, 'device': '', + 'logs': [], } threading.Thread( @@ -968,6 +1027,7 @@ def status(task_id): } if task.get('device'): resp['device'] = task['device'] + resp['logs'] = task.get('logs', [])[-200:] if task['status'] == 'done': resp['stems'] = task['stems'] resp['count'] = len(task['stems']) @@ -976,6 +1036,14 @@ def status(task_id): return jsonify(resp) +@bp.route('/clear-logs/', methods=['POST']) +def clear_logs(task_id): + task = _tasks.get(task_id) + if task: + task['logs'] = [] + return jsonify({'success': True}) + + @bp.route('/download//') def download_stem(task_id, stem): task = _tasks.get(task_id) diff --git a/flask-dev-api/config/ai_translate_config.json b/flask-dev-api/config/ai_translate_config.json new file mode 100644 index 0000000..5edef36 --- /dev/null +++ b/flask-dev-api/config/ai_translate_config.json @@ -0,0 +1,9 @@ +{ + "engine_type": "nllb", + "nllb_dir": "E:\\AI\\ai-translation\\nllb", + "max_new_tokens": 512, + "batch_size": 8, + "last_nllb_model": "nllb-200-distilled-1.3B", + "last_src_lang": "auto", + "last_tgt_lang": "zh" +} \ No newline at end of file diff --git a/flask-dev-api/config/audio_slicer_config.json b/flask-dev-api/config/audio_slicer_config.json index 6538501..07633b0 100644 --- a/flask-dev-api/config/audio_slicer_config.json +++ b/flask-dev-api/config/audio_slicer_config.json @@ -1,7 +1,7 @@ { - "threshold": -40, + "threshold": -41, "min_length": 5000, - "min_interval": 100, + "min_interval": 300, "hop_size": 10, "max_sil_kept": 1000 } \ No newline at end of file diff --git a/flask-dev-api/config/sidebar_config.json b/flask-dev-api/config/sidebar_config.json new file mode 100644 index 0000000..75f76e5 --- /dev/null +++ b/flask-dev-api/config/sidebar_config.json @@ -0,0 +1,11 @@ +{ + "hidden_features": [ + "sovits-tts", + "json-format", + "http-status", + "url-parser", + "chmod-calc", + "token-gen", + "qr-code" + ] +} \ No newline at end of file diff --git a/flask-dev-api/config/uvr_sep_config.json b/flask-dev-api/config/uvr_sep_config.json index c6bd410..370337d 100644 --- a/flask-dev-api/config/uvr_sep_config.json +++ b/flask-dev-api/config/uvr_sep_config.json @@ -1,9 +1,12 @@ { "uvr_project_path": "E:\\AI\\ultimatevocalremovergui", "model_dir_mode": "absolute", - "demucs_model_dir": "uvr5-model\\demucs_model", + "demucs_model_dir": "E:\\AI\\uvr5-model\\demucs_model", "vr_model_dir": "E:\\AI\\uvr5-model\\vr-arc", "mdx_model_dir": "E:\\AI\\uvr5-model\\mdx-net", + "demucs_model_dir_mode": "absolute", + "vr_model_dir_mode": "absolute", + "mdx_model_dir_mode": "absolute", "arch_type": "Demucs", "save_format": "wav", "wav_type": "PCM_16", @@ -21,7 +24,7 @@ "vr_window_size": 1024, "vr_aggression": 5, "vr_batch_size": 4, - "demucs_selected_model": "", + "demucs_selected_model": "htdemucs_ft_v4.yaml", "mdx_selected_model": "", "vr_selected_model": "" } \ No newline at end of file diff --git a/flask-dev-api/static/font-awesome.css b/flask-dev-api/static/font-awesome.css index 0557bd5..7c95b0b 100644 --- a/flask-dev-api/static/font-awesome.css +++ b/flask-dev-api/static/font-awesome.css @@ -53,6 +53,7 @@ .fa-database:before { content: "\f1c0"; } .fa-file:before { content: "\f15b"; } .fa-play:before { content: "\f04b"; } +.fa-pause:before { content: "\f04c"; } .fa-pen:before { content: "\f303"; } .fa-fire:before { content: "\f06d"; } .fa-tachometer-alt:before { content: "\f3fd"; } @@ -66,3 +67,13 @@ .fa-code:before { content: "\f121"; } .fa-key:before { content: "\f084"; } .fa-microphone:before { content: "\f130"; } +.fa-gear:before, +.fa-cog:before { content: "\f013"; } +.fa-times:before { content: "\f00d"; } +.fa-eye:before { content: "\f06e"; } +.fa-floppy-disk:before { content: "\f0c7"; } +.fa-sliders:before { content: "\f1de"; } +.fa-language:before { content: "\f1ab"; } +.fa-globe:before { content: "\f0ac"; } +.fa-stop:before { content: "\f04d"; } +.fa-download:before { content: "\f019"; } diff --git a/flask-dev-api/stats.db b/flask-dev-api/stats.db index cc90c8b9e200f25f80b6cbaa41642c43e3b54eb1..b83bf5a6f068e0efb5a0c487aeca53f3858087d8 100644 GIT binary patch delta 29690 zcmbtd37A|}m9BcV?^)@Pr8^xUd#B%8TdI;u(IhN^B$%)hx|8mZK%hfNfP|%phW=M zj*oT6ro|@3!ZGi|gA=|Q&45HO_TVpXkpJJW7w~t9z7&7^w5#!Vv6jH!Md~&9yHJhe z?}Fkk{GDGEAI|sOGJy-;bkp;nPJo0=$m&&6h6~2_u>HUfKGv0OH9?yH8?|82FoaUMAe#3pg`x^I3chvP8*IlkHu6f+Y z+@stL+-h!$^LNhsojaX5r{D2wb>9k?SQJa@B(A&5C-P#vkB|2W@!3K$Ey!s}P>Mon z-DTX8Abl3){vu!6Hge;w2a>umU<_#PVp>diVI4;CvkIT?Y`y({QH?nPBAT|`it}HSV)OX zabBtSI_|icr)GB?d^ySH3JMw?WC_qX z`)f4hozh>NQ|XyJLqolF&wNrY&MvLDk(TlM ztm^N0#_uzkTA!axr2~SiGC|X;E%7Y$7muaSWC(Iv0jJXJNnAe>1oIou z7Q0G^Z*oczI^}pJ%1ZH=%IuJ5w3yo1jYQc@sS;rdlS*-Nqkwp+$i+#ezmIT>L$D^E zPm9pS#D*7=T%1r^9Ia9a3Aq?6zeuE&IF%+!I^z9!5+H7bid%YpGw1Ou7!bq_mkA&& zpDl*!2_&dnDF&G}fg^eDF9u3OH*gD&yt;>*>n%jE^zx4HI${sRc41WqqrZyk(etAV zB1a?NkK7VDIT8r}GOUHq3(pAsHgqVoJ=7O`Klo_y>R>+T3Op0IIk49MXSyWU`ltEc z^d0hz_~v@w@qWX*-OGFaW~<07-1 zzjuDixx<-s{Ey?i>b^c#wz#W0B`FwKnonhlSC;P2VgXF=er(;lH}}+=Ge|HhnCi~b zvZKzvkdQ)yG+6;x&^Dz>L)_#bUJ^T3iaTmlQd*Lv;%Mo`51ornCe1{`s(882v0^Ic zm->fx4{saoyK?)s%_BSeFB`gi_t19sw06xW);peS6oNKZikH>c^RNJ=xV^HHWvpa8 zUt0exXNs4QmEBtkt>mV_CS^V?W0h^Aw$)9a1+3JqK1X6I7m)fd+adSu8ttQ!m0oD(7n?>)*X&`{>Xx8(QwdS3ds1trsk)A7TzQ@pid7Fz+53j6MItSNVsjgcI2KQ)xQRKd9Tx6Q0tM!6 zsM>2BW6cx?sYjgYCvwjJPgA#XtZcDZdZU}`>GC)I#$y^bR@o_ODJd2&ERijsG;1Dp zFOm#PNEI)ja%VqhC6x@D%M{ONa=-g>FuUWBsUx_Fc&2z>Y2qrl;R#keV1P=hcrNS3 z*!u=F*u+CbFXxoK|CgZPRS!0i2x6vqHtXgQEM{!c4f~jci1ZgqzSZ6WZ*hWmH71;5a4Q4|RX+&Y&rH9r~I}_o+F~6rWD_V<+4W0X&m!;-tpef-Az12H22( zX{8sQ>gB}p+O?7%6z~{%Gh-s)3MSxg=o>EGG-{p-l&i!9aL!~cEiaGhUaOcUfvqA_ zTvq1#84IogJPC|4ThS$+1*yN7sl*H98M{jZ`CepMOnmFZh%y zQpeisF`EKwf~i%7S`+NnaYg0H+%l#xe??LI8(zp+bx9>QAzbi3(DadsqK#muh|$!= zRNBdO0OXY%R~ON(*;(3=H-!^uoqG?qt0gm&F(ea zBr;3^GE0&=uk^uD=OXd~agWSr)w!ixKXe{9Yi*2r*v7s{Xq3vRC$Mm88n&&*?0|8R z5|Vm6i;Kt$#352zJ#OrAQKhk@&Z!BBKq8Sx zm3Ue`c6{7OV?~`-H3S|nFvDGJJZs2sKK`L;hOylxWc3)DMj-LSK8-CftCqg-q4W4z zYft$i{(Y&r2`{TtSmse~f^H_a??!;pmJ=M-M!@=*$l9#E${c1e5(Mh%1Tvi7LVdbT zeuOtoq}AB?6eVKNX*D{A#)WdGPr62HC9Our(7LX%Ko*D^9&2Of-;fr{Sn8qL3}ed_ z6%{EPstD^Ff>#n#!C9QG27Hb^G?b~2^Q$qEEGMK9d1QD<*(AyQh`31{5o9?&JT$UN zREET%k~)=}+)Z{=gzt*#Z<{nwG9JFiXCrCY-W1WX=2#G3wjjtxjxCQ*s~)N^q%>p7 zvS{Os>Mpx4nsS_6XUv2@BBVq{bup5eO&*Ss2(Uwr+F@ha2klY#fKzu`{wO0$#B@2> zbb*zO?HLhHflim1m7KuhlZrP~#|}>*BE!6XthR#e4PBAO+2VC&U)F1OoQy<3Aa|{` z>_}>mOYW`8j(w8^AA4Aol_LA7glO~{7SBv>&a5a2sT^Hx*#Y7f8L9eJMDsnfoyFX$ z{bqj)83`3w?5>481@=;4;mEg1I8uzA6Pq1D`3#;BP&5dPY18+;6!Lq82gB^*h&nu3^`LDclF#1Kc)luJcXjH=Ns@3mius-&Xg{ zJu9PL<8z!u6Oo~9eY4AC8l}qTbpW3mMkFdyH}(Pm7$Sy_A;W zZgzDLlu+f=EA0vT;mn{Zl_eb^#f<0Fo%UEfw!B>DDO4-6;DK#f0FNu|@i?;aM~M9X zQ0E=MLQJCGkW+WqWAW%WW;AV=;DL&yl2AuW&a<4e3R|Bd3#GnPLb^c(LP@G9C1GYs zDXHFk%rk3}P(i?$Q!le8;gQ0TyG;2L=^hbfJgIIUPxsh=Qc3kvyG;v~iaF^P@i6A+ z5__UQen-w!tN;r*2tJilx7lOyy*dA0Alh%#AgZmBl2cJMv$L!o?~&7EbYB6BBL3>%A7%Bh<=ut=6Y(!GnHHP!qeDXHHllIjrUVKV*F_kZD> zzL@$x67IpJ40hI~n3EmBr5phV`unTXEs#7T^2)bp%_D(zd$%`SH*BFH|sJtyP@ zlB}N7l8EF=2=;&@sb{mjoogxrf&}peDr)LkB~qbkD4UWWu>)X3+fYq)$qVUBb$w~@ zByL)E^X5&dBrhPGkhfrWAKEmO6cW;=xGcptC8059FkJmQr963bNNb3V=o{&f04R!j zX01c`w3Mi>W8#^T2U0gjT(-7GjTeHbuCW{p3fE-QJEK}bzzZprQctG^gYKq~5#pc< zjgpzVx*D~FiB`#51d^wj-lBdZit4GBlA;<*QaTj$$|xaYL^fDaJ=wYmYOPSks+zcX zf%I%;T`2-vBPwm`NmT_P##Rz3b$|@1)N?vZq45$GEEN^I`p!vRRua`b?Y#8%YL1G8 zU5>1p6|AuQ6?1hTqXz{EhI6$N30^?y%Pp0TEfD~sx~wW8c>(IIH8tD=Y$L;d^oq5L zHt84(Iw`A5OTAM7ot0l;Tho$N0&RE za>SmDeLA)>7LC3b{ap0)s5kOr^$z8k!$`J9uw!b8u$h zwZLtGje&0en+Qa=`xW1R`o8bG#ka~A22QPcKjDpfp7-dU^`4mf1-Iee;GX1~@lUQV zy9QkgxOcewxM8l(`JVGp=T**Sj{kBzrS402$y%c2>fVBk0#OYw3}|s01p?Tq_i&cc zYVN>`X1IZ{!IUQzO`(06b}SOL4#80}DkZDQv@;WxWoHe`p5Wh@8(Bj+6DX9GqrHf9 zLWr`O__uT-%9_x!lR~)@F{kkz_Mq}^ci{f7nPUwmC!f>$ZL;_^35>5L#x_!^Ab87d)4YKy6y=8cQ&%H_EQnUvK~twh>0p1H+Y1Xgcoe<@;1@?4yZ+G5=@R8wwg^xfV0wG(K@Z(fKaxmP3~)m`e$2yVqa{JLplB%IV#T!_ zEv3q7b8L<_)Rgu>PGnc{yw+=Xv{(O@dLi>@u{~pQXODNZD9I?Y*3;Go$@v~7r*+#K z?7=;T33=!j^>lq~>ZJA2e zrnl5Y4>6QkOd)`inPyZsJ`10^z#IN zW8lmPJ5-3lf`@<&7De~*Y_MpL5E~<9)p!fL9HurysdAdvrlkE%-VGfQq)FCLrpMG- zM`@Hu!isl~do)OU;Hq8iJ0V0_jT?6-h!qeDI$L(Kh0F^f8qgfHleXqvuJ?IX z@a4u;Mvx?wV+*t|6B|-$C>tW7%BeTlo1TMzO`6WUF?r!bR3Hgss!x~ezI40-a<}WK zpcZz-yeNi4FuiO42G#R|tnO{8`T`pYFXYrc_Ox;LmZnS;qan3)lCR|)ha9n|W1o$k ziqj0gjuxZmMyE$!j@%s?jGPdDKm1_$(y$!*IP_%bhS17TEcnX$pc-5qoEZ3dpcFVe z(Bpr_f55-Zzrc6Y_if*`z7u_J@6WtnK*}})*g?s2j;GiCI~*(?btm0U*N?ca`@IP_lQcoMS#2}(FO7td)nfq{encU;54 zA+RVg9ztFNGJw;JCR6OUh&<04Y{btif(VJ626({ESPuUChlB@~M7b)=cmVJd^BTYd zdxETg-AWJ?QxS^WgbxlCOm+K;AVMmy0Y0!P$dJ|4(DJ<20YE5JK%1wwKGG05GC{}* z;+!^WPpeOdHZ>$bj0LLg2!mPy4BQYZ#g90r7AP?aJUoDNTYsCm0@)Z+` zhVni1YOyhpzz$dhp-O73dT+{o=u{3?$&k0AXg>yF8e}1v94p+LW{>K2BNK(>#;Dtm zuMd*4DjKWkw;nH{lC{<)eJTlvAPGwPY+td>?ys*Z#eVx|pL6q8k3ijq5UOH=R;HaM0kDcbs05|zgdDg^JJ z827kw#uYPoX3G(6A|oP1Ic=Ri$9%wVK3iL6At$x9l!evGAR!=n!ZJmrWR2amzDH|1 zsmGY0?0d!-Cg7gr5_2#KJ^&Bl!%lCh8&W|eL@?v?5H1lG=$>P?O5*TsYx{naPh=XYh&GPFA#< z7`7V7sSJdfIeSWb=(1jHe6)&Q+bakQIN#-Cl(U==L1!pB!apo)sWV!+CPX>yF#yrz8=~cS{iZ( ze-^y$tl&k#`GKQ>#{)M7RtKi~|Ji?^f7GA!JA6O!X} z%-d^o*0iMRhiEWJjt7W&9cs51!5McoE`P>k2I@>nS+`VvEdWMeSmZjiZyf-$Fqu** zI)e{Q;~|c*MkUKS@WW2LK*iuelgFr&i+&}c10HMf9d1EOawS6PMNB`?*1+u1UE-emS@E#-9pdiEw@Row(! zLQ11dgCqmW>%jEJ&ZU8gAnOd+Yi*;*lO$OOum`1>f;6JwvQh|DUbl_i-TW5v=DP3m zDx;F(86D6aM#Y(+`DTaWxzKD^2#(^i&ak_71A@QSf!$fpa@qR8OICEib8RgKp{XM4 zfZy8kVpNtWTb+%9N|bfLY@K+43P@TfDy%SIW*w;%eFmj!?eI`AgjT0_;682Z zV5=Q30dfJ*>cERoF}$iZFLDM;)`3d3<;AEN4AqX8h#3M7)qxkGVo*~%UIOF-)C67@ z4yL>w@eLQK04R0nQNZc|WMqSi0YmNNLKdt87_#Oy$b1(0Xegrt`?0kb)_n?3o6Gd0 zM;)jTI%&BCDV3~SVn4*xhbz3KWKIY8W2XRp==8=fP--j`o#8(g;#4~lrJN1`$WBL3 z4qe_{BgKgD9kRy2AB&FIq=SkS3<3UFPeD~29vKnv9XsMYy6ck`C1Ub~6X^+okw7sn+yV{F{ly=YG(hJbYYg#FdZP*x#M33zaICMwsd$CW& zR>!7BUyI%qJsaPU{8Qv$WOJlD{ATzY;a%ZO*c1AB=(f2ezQ{e#^?~a# zm$J`wifbbGkK6%#N3ziQN9W_tPdf9?pyQ{I#Nm*2W)v3dlIVw2R!~~V>M#mhRXu01 z22EGnV`qoCSTDI+FLG9gMU1-_+)kmxR;+tDw;~Invb2bX8j!m_iljQgkqaF*V$GfS zCQ2pB86B2j%Qvw&y;816bHsE|6u>#_PI_41{&3&N1R>I0YkOr zg+K}B1Ek5CY73ho5}M_8U`h6pg_SSSlV&E1WAPnBnp!w)m<*^OHba`)QbFlJnygh& z6I;mZ7uywEzq=`=U;<(0wzSd^vjTkSEh22N1g0vXB))neREo}!sg{ESl>ik0nQAvD zpu$;49nh1#Il1k$CZLfDgu5v8*7y^he$o{d0`MnmZI@L5uL?z7)Jp6Is*Sd&E^I}` z=9hfc!sqo1?5be(?Wo3gKh|iLl!>gL-)eBo5rPV*B=z&g=?KYXSTK)sTXj;_4Wl}z zBNa)f8K%_IEulw3Wr-=(86>D67eFQJnH^&D4=N$612Y=mP6k=<*{%*W$=aZ+qZg2I zT4F{G663lK9iUNLUhpCq7jPl#ag}={REiE*s1q+p58S5%m5eoFiR#ozkBk#kmavYA zO3^KGok|%IYZttbvjN5FFh*3c^_EyoRTDCw)PdC?05(Oh6rLal zMF+0aS}(K{9_5VX?aZc3rRd8#^MW~NDV51=DmrGo5_vsiSI29gvN)0i-S9HOFS3q1 z9kK7m_QvwjzeIl&{d{yoG#dF$`z(3?d;YKaNBpyWula8GZSYO?{>H0%&+rC3Klc2cXSK)Ye#TvJXK>u?30#|( z=Kh3REU)Aw=ll3l{Zi-gj@KM_sw-v`vIfRt%Xl5nnNkOGHiaKBmeTg-DQ-N&Ts|QD z-{fph-JNTR*-Nkzac_c>7%|r|!P&*EowQQ2Mrz#B6Ni~IlJ@fCht^(Sz3-+_ zUMy5{LCzb%NbIe+jVl^I^q?*P0}=SF0SLwBG!)G90B0uW44^A^GX8^U!(3~|Kw#PN zyaDLN#y|=G63n= zk@umMcd*0twS#eF117Mc0lcS;ymjJkoJWry7aGGxa1oO^19*@fLGGQi#9YCmf{1+H z09a&4kiv7hq4gGN6$BBI83SNadkbh`0Qe3WHxL*=lbZETFS*&-w|(^DE7z_mz3>ZX zgt-xNH;R%ofF0Qx`$Iqc8atlYFbVjiCY3jU9N8H=hWZdzuoIe_^1=ouP^eshVsdAs%qipy zAX0V|@tr?36>edTNIu}s09Un{kwV?N*nel^H4JPSgA1QC0B3a+1XtF4<+>ZOqt|ZG zWGu*$n!M3tB}l>QEcNY&t^5hNz_E4P6Xg3@7)B$Q2#sK`#!fY>PLM)%i?$Nvw*RnN zpV%oN$V_{JJmI$3?POAg$Qv_!jzXKcshgVH9TssnxbRZWm~Kyy1Ba}Xij(d#=H}Qs zm9*8!;6ql61x(@t#x!c}ons)_$-PUDV`uIw7t~Ng!B@*TA7lXJvtw)bJZ>??_!wA$ zW`O|OQ*2?7w-^xlq&Sr`fDqad8V*#kv490^4e()GLDJRxiHv_Ke>Ya|RUwF! zNE(0_A&4m+ub%m)H^-nXYcPD#>D$F+(2PQQ_JFgCUP?sp5*kkjJ)Eke(rr7F1j5v_X0yIbZ;JgpN9!GJ?P- zyB{VCt8o(|Y(&Li(4<9YY}*ADD!K-PCQVcgrT*HL!c0gU=1mP404r??iS_o?XByAa zmHQ{xEGY(qGu!oFJ8oL(A4jbX0G!QvX@lI&?Qr3z0c3Mhb3x=kmELlrC@*^I*~JgdfAcT=*};jzpSGy@ZXIv0ExAgfqYtCU=p|O835HkjF z!}0cyOLPnXiuU{8S__&ne)1oe$Qr;J+cKyv1CxUxha9j%fkHN~(Ar`H6)DsKW*pxr z$z6e2gW-?n*sGd?)!mR<6Oy?Ea_xCSHrgVP&;_V?*kySgde3W-;Kl!EnHX>@EN=ZSm-OfWQik-kq>OP7Gwz4h#1e`!6yF z?{p}nf(CRGJWM*PVrL+OBS*Cl@TR>OXqZ*JO!A^yjPeT5PaE>)ezuYp;2Vl&5QLo1 z%p1T)Z3-eZ)p;lr5%UM=w6h>6njP5Rm{3p$3PE@jjsdLN6Xg6mn!ewlf{>~PaMzZC z)EBMpecz%62yBUjr$A-x39_#F<9!r)zyxXqXlrItz$M1bXU}s_ym2fP`=$B`W?ZDa z0Z`Y>#hS^}r%gja0=HMzJX)Da&H(01by8@&p|0us@ptyV)^Ir~CWrArl^lq$Jwb+C z79}Tq4}<%E3kCq=ma|h=w))oo#!Eh!AmoG9yaBk_o*;w&O){zanqm(b4kA%3Qeegq zq}3t?8E#}#G6q27ad>GoN9cvU5NLASt;8ZCq9FlefW43*$>wO!%FUZ59v&PK4FKdZ zi(jCVj`kQn5acnpuQZ4BfR@MJyg()i%Ng+?KXe{5aOVNb>DqWH&j` zdR0t{euPWWcSYsMN0CP&yC0r7``m~tv){$;b-igj#U$#d0NW>S%;K~AIeJ?ti}T3k zszP=@ss@nD&faaxAkZHFNs0ZeoM5MY^#adwoDBvfL@U@9py8}2#)S4r6mGEHz7l*C z=*LEs0gkvE*N+V w;A}pv-+o%3ar + + + + + + AI 翻译 + + + +
+
+
+
正在加载模型,请稍候...
+
+ + +
+
+ + + +
NLLB-200 本地翻译 · 点击齿轮配置模型目录
+ + + + + +
+ + +
+ + +
+
+ 语言设置 + +
+
+
+ + +
+ +
+ + +
+
+
+ + +
+
+
输入文本
+
+ +
+
+ +
+ + + + +
+
+ + +
+
+

翻译结果

+ +
+
+
+
+
翻译中0%
+
+
+
+ +
翻译结果将显示在这里
+
+
+
+
+ + + + diff --git a/flask-dev-api/templates/audio_slicer.html b/flask-dev-api/templates/audio_slicer.html index f8823bd..f0a247a 100644 --- a/flask-dev-api/templates/audio_slicer.html +++ b/flask-dev-api/templates/audio_slicer.html @@ -146,6 +146,13 @@ } .slice-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; } .slice-btn.play-btn:hover { color: #0078d4; border-color: #0078d4; } + .param-save-btn { + margin-left: auto; padding: 3px 10px; border: 1px solid #e0e0e0; + border-radius: 5px; background: #fff; color: #666; font-size: 11px; + font-weight: 600; cursor: pointer; transition: all 0.15s; + display: inline-flex; align-items: center; gap: 4px; + } + .param-save-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; } /* 进度条 */ .progress-bar { @@ -181,6 +188,27 @@ .status-text { font-size: 12px; color: #999; margin: 8px 0; } + /* 片段选择 */ + .seg-row { + display: flex; align-items: center; gap: 8px; padding: 6px 8px; + border-bottom: 1px solid #f0f0f0; cursor: pointer; border-radius: 4px; + transition: background 0.15s; + } + .seg-row:hover { background: #f5f5f5; } + .seg-row.selected { background: #e8f0fe; } + .seg-row.dimmed { opacity: 0.4; } + .seg-check { + width: 16px; height: 16px; accent-color: #0078d4; cursor: pointer; + flex-shrink: 0; + } + .seg-ops { display: flex; gap: 4px; margin-bottom: 8px; } + .seg-op-btn { + padding: 3px 10px; border: 1px solid #e0e0e0; border-radius: 4px; + background: #fff; color: #666; font-size: 11px; cursor: pointer; + transition: all 0.15s; + } + .seg-op-btn:hover { background: #f0f0f0; border-color: #ccc; } + /* Toast */ .toast-container { position: fixed; top: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 8px; } .toast { @@ -193,6 +221,90 @@ .toast.info { background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; } @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + /* 模式切换 */ + .mode-toggle { + display: flex; gap: 0; margin-bottom: 12px; + border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden; + } + .mode-btn { + flex: 1; padding: 7px 0; text-align: center; + font-size: 12px; font-weight: 600; cursor: pointer; + background: #fff; color: #999; border: none; + transition: all 0.15s; + } + .mode-btn.active { background: #0078d4; color: #fff; } + .mode-btn:first-child { border-right: 1px solid #e0e0e0; } + .mode-btn.active:first-child { border-right-color: #0078d4; } + + /* 切割点列表 */ + .cut-list { margin-top: 8px; } + .cut-row { + display: flex; align-items: center; gap: 6px; + padding: 5px 0; border-bottom: 1px solid #f0f0f0; + } + .cut-row:last-child { border-bottom: none; } + .cut-idx { width: 24px; font-size: 12px; font-weight: 700; color: #999; text-align: center; } + .cut-input { + flex: 1; padding: 4px 8px; border: 1px solid #e0e0e0; border-radius: 4px; + font-size: 12px; text-align: center; outline: none; + transition: border-color 0.15s; + } + .cut-input:focus { border-color: #0078d4; } + .cut-del { + width: 24px; height: 24px; border: none; background: none; + color: #ccc; cursor: pointer; font-size: 12px; border-radius: 4px; + display: flex; align-items: center; justify-content: center; + transition: all 0.15s; + } + .cut-del:hover { background: #fce4ec; color: #c62828; } + .cut-add { + display: flex; align-items: center; justify-content: center; gap: 4px; + width: 100%; padding: 6px; border: 1px dashed #e0e0e0; border-radius: 6px; + background: none; color: #999; font-size: 12px; cursor: pointer; + margin-top: 6px; transition: all 0.15s; + } + .cut-add:hover { border-color: #0078d4; color: #0078d4; } + .cut-hint { font-size: 11px; color: #aaa; margin-top: 8px; line-height: 1.5; } + + /* 时间轴 (HTML div 实现) */ + .timeline-wrap { position: relative; margin-bottom: 28px; margin-top: 22px; padding-bottom: 22px; } + #timelineBar { + position: relative; height: 100px; + border-radius: 8px; border: 1px solid #e0e0e0; + cursor: crosshair; user-select: none; overflow: visible; + background: #f0f4f8; + } + #timelineBar.readonly { cursor: default; } + #timelineBar.readonly .cut-marker { cursor: default; } + #waveformCanvas { + position: absolute; top: 0; left: 0; width: 100%; height: 100%; + border-radius: 8px; pointer-events: none; + } + .cut-marker { + position: absolute; top: 0; width: 24px; height: 100%; + transform: translateX(-50%); z-index: 2; cursor: grab; + } + .cut-marker:active { cursor: grabbing; } + .cut-marker-line { + position: absolute; left: 50%; top: 0; bottom: 0; + width: 2px; background: #e53935; transform: translateX(-50%); + } + .cut-marker-tri { + position: absolute; left: 50%; top: -1px; + transform: translateX(-50%); + width: 0; height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 8px solid #e53935; + pointer-events: none; + } + .cut-marker-label { + position: absolute; left: 50%; bottom: -20px; + transform: translateX(-50%); + font-size: 10px; color: #e53935; font-weight: 600; + white-space: nowrap; pointer-events: none; + } + @media (max-width: 768px) { body { flex-direction: column; height: auto; overflow: auto; } .left-panel { width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; } @@ -239,11 +351,17 @@ - -
+ +
+
自动切割
+
手动切割
+
+ + +
分割参数 - +
静音阈值 @@ -277,6 +395,16 @@
+ + +
+
@@ -298,7 +429,15 @@

上传音频文件后开始分析

-
- - - + +
较小的尺寸占用资源较少。较大的尺寸效果可能更好。
@@ -419,24 +472,25 @@
MDX-Net 参数
- - - + +
默认大小为 256。音质可能会因你的选择而有所不同。
- - - + +
较高的值可能会带来更好的效果,但也会导致处理时间延长。
@@ -446,22 +500,28 @@
VR Arc 参数
- - - + +
选择窗口大小,以平衡音质与处理速度。
- - - + +
数值越大,提取越深入。对于人声和伴奏,通常设置为 5。
@@ -540,6 +600,13 @@
+
+
+ 控制台日志 + +
+
+
@@ -605,6 +672,37 @@ var currentArch = 'Demucs'; var scannedModels = []; + // ── 自定义输入切换 ────────────────────────────────────────────────────── + function toggleCustom(sel, customId) { + var customEl = document.getElementById(customId); + if (!customEl) return; + customEl.style.display = sel.value === '__custom__' ? 'block' : 'none'; + if (sel.value === '__custom__') customEl.focus(); + } + + function getSelectValue(selectId, customId) { + var sel = document.getElementById(selectId); + if (!sel) return ''; + if (sel.value === '__custom__') { + var custom = document.getElementById(customId); + return custom ? custom.value.trim() : ''; + } + return sel.value; + } + + function restoreCustomSelect(selectId, customId, value) { + var sel = document.getElementById(selectId); + if (!sel) return; + // Try matching a preset option + for (var i = 0; i < sel.options.length; i++) { + if (sel.options[i].value === value) { sel.selectedIndex = i; return; } + } + // No match — use custom + sel.value = '__custom__'; + var custom = document.getElementById(customId); + if (custom) { custom.style.display = 'block'; custom.value = value; } + } + // ── 设置弹窗 ────────────────────────────────────────────────────────── function openSettings() { var dirs = window._savedModelDirs || {}; @@ -893,19 +991,14 @@ // 架构特异参数 if (currentArch === 'Demucs') { var demucsStemSelect = document.getElementById('demucsStemSelect'); - var demucsSegment = document.getElementById('demucsSegment'); formData.append('demucs_stems', demucsStemSelect ? demucsStemSelect.value : 'All Stems'); - formData.append('segment', demucsSegment ? demucsSegment.value : 'Default'); + formData.append('segment', getSelectValue('demucsSegment', 'demucsSegmentCustom') || 'Default'); } else if (currentArch === 'MDX-Net') { - var mdxSegmentSize = document.getElementById('mdxSegmentSize'); - var mdxOverlap = document.getElementById('mdxOverlap'); - formData.append('segment', mdxSegmentSize ? mdxSegmentSize.value : 'Default'); - formData.append('mdx_overlap', mdxOverlap ? mdxOverlap.value : '0.250'); + formData.append('segment', getSelectValue('mdxSegmentSize', 'mdxSegmentSizeCustom') || 'Default'); + formData.append('mdx_overlap', getSelectValue('mdxOverlap', 'mdxOverlapCustom') || '0.250'); } else if (currentArch === 'VR Arc') { - var vrWindowSize = document.getElementById('vrWindowSize'); - var vrAggression = document.getElementById('vrAggression'); - formData.append('vr_window_size', vrWindowSize ? vrWindowSize.value : '1024'); - formData.append('vr_aggression', vrAggression ? vrAggression.value : '5'); + formData.append('vr_window_size', getSelectValue('vrWindowSize', 'vrWindowSizeCustom') || '1024'); + formData.append('vr_aggression', getSelectValue('vrAggression', 'vrAggressionCustom') || '5'); } var startBtn = document.getElementById('startBtn'); @@ -921,6 +1014,8 @@ if (progressSection) progressSection.style.display = 'block'; if (progressFill) progressFill.style.width = '0%'; if (statusText) statusText.textContent = '正在上传并初始化...'; + var logContent = document.getElementById('logContent'); + if (logContent) logContent.innerHTML = ''; fetch('/uvr-sep/separate', { method: 'POST', body: formData }) .then(function(r) { return r.json(); }) @@ -944,6 +1039,22 @@ }); } + // ── 日志 ────────────────────────────────────────────────────────────── + function renderLogs(logs) { + var el = document.getElementById('logContent'); + if (!el || !logs || !logs.length) return; + el.innerHTML = logs.map(function(l) { return l.replace(/'); + el.scrollTop = el.scrollHeight; + } + + function clearLogs() { + var el = document.getElementById('logContent'); + if (el) el.innerHTML = ''; + if (currentTaskId) { + fetch('/uvr-sep/clear-logs/' + currentTaskId, { method: 'POST' }); + } + } + // ── 轮询状态 ────────────────────────────────────────────────────────── function pollStatus() { if (!currentTaskId) return; @@ -960,6 +1071,7 @@ var emptyState = document.getElementById('emptyState'); if (progressFill) progressFill.style.width = data.progress + '%'; + if (data.logs) renderLogs(data.logs); var devInfo = data.device ? ' [' + data.device + ']' : ''; if (data.status === 'done') { clearInterval(pollTimer); pollTimer = null; @@ -990,14 +1102,25 @@ var name = s.stem.toLowerCase(); if (name === 'vocals' || name === 'vocal') { iconClass = 'vocals'; icon = 'fa-microphone'; } else if (name === 'instrumental' || name === 'no vocals') { iconClass = 'instrumental'; icon = 'fa-folder'; } + var safeStem = s.stem.replace(/'/g, "\\'"); var row = document.createElement('div'); row.className = 'stem-row'; row.innerHTML = '
' + '
' + s.stem + '
' + s.filename + '
' + - '
' + - '' + - '' + + '
' + + '
' + + '' + + '' + + '0:00 / 0:00' + + '
' + + '
' + + '' + + '
' + '
'; list.appendChild(row); }); @@ -1007,12 +1130,115 @@ if (resultSection) resultSection.style.display = 'block'; } + // ── 音频播放控制 ───────────────────────────────────────────────────── + var currentStem = ''; + var playTimer = null; + var seeking = false; + function playStem(taskId, stem) { var audio = document.getElementById('previewAudio'); - if (audio) { + if (!audio) return; + + // 切换音轨 + if (currentStem !== stem) { + stopCurrentStem(); + currentStem = stem; audio.src = '/uvr-sep/download/' + encodeURIComponent(taskId) + '/' + encodeURIComponent(stem); - audio.play(); } + + // 播放/暂停切换 + if (audio.paused) { + audio.play(); + } else { + audio.pause(); + } + } + + function stopCurrentStem() { + var audio = document.getElementById('previewAudio'); + if (!audio) return; + audio.pause(); + audio.removeAttribute('src'); + if (playTimer) { clearInterval(playTimer); playTimer = null; } + if (currentStem) { + var btn = document.getElementById('playerBtn_' + currentStem); + var prog = document.getElementById('progress_' + currentStem); + var time = document.getElementById('time_' + currentStem); + if (btn) { btn.className = 'player-btn'; btn.innerHTML = ''; } + if (prog) prog.value = 0; + if (time) time.textContent = '0:00 / 0:00'; + } + currentStem = ''; + } + + // audio 事件 + (function() { + var audio = document.getElementById('previewAudio'); + if (!audio) return; + + audio.addEventListener('play', function() { + if (!currentStem) return; + var btn = document.getElementById('playerBtn_' + currentStem); + if (btn) { btn.className = 'player-btn playing'; btn.innerHTML = ''; } + if (playTimer) clearInterval(playTimer); + playTimer = setInterval(updatePlayProgress, 200); + }); + + audio.addEventListener('pause', function() { + if (!currentStem) return; + var btn = document.getElementById('playerBtn_' + currentStem); + if (btn) { btn.className = 'player-btn'; btn.innerHTML = ''; } + if (playTimer) { clearInterval(playTimer); playTimer = null; } + }); + + audio.addEventListener('ended', function() { + if (!currentStem) return; + var btn = document.getElementById('playerBtn_' + currentStem); + var prog = document.getElementById('progress_' + currentStem); + var time = document.getElementById('time_' + currentStem); + if (btn) { btn.className = 'player-btn'; btn.innerHTML = ''; } + if (prog) prog.value = 100; + if (time) time.textContent = formatPlayerTime(audio.duration) + ' / ' + formatPlayerTime(audio.duration); + if (playTimer) { clearInterval(playTimer); playTimer = null; } + }); + + audio.addEventListener('loadedmetadata', function() { + updatePlayProgress(); + }); + })(); + + function updatePlayProgress() { + var audio = document.getElementById('previewAudio'); + if (!audio || !currentStem || isNaN(audio.duration)) return; + var prog = document.getElementById('progress_' + currentStem); + var time = document.getElementById('time_' + currentStem); + if (prog && !seeking) { + prog.value = audio.currentTime / audio.duration * 100; + } + if (time) { + time.textContent = formatPlayerTime(audio.currentTime) + ' / ' + formatPlayerTime(audio.duration); + } + } + + function startSeeking(stem) { + if (stem === currentStem) seeking = true; + } + + function doSeek(stem) { + seeking = false; + var audio = document.getElementById('previewAudio'); + if (!audio || stem !== currentStem || isNaN(audio.duration)) return; + var prog = document.getElementById('progress_' + stem); + if (prog) { + audio.currentTime = parseFloat(prog.value) / 100 * audio.duration; + } + } + + function formatPlayerTime(sec) { + if (isNaN(sec)) return '0:00'; + var m = Math.floor(sec / 60); + var s = Math.floor(sec % 60); + return m + ':' + (s < 10 ? '0' : '') + s; } function downloadStem(taskId, stem) { @@ -1048,11 +1274,6 @@ var isSecondaryOnly = document.getElementById('isSecondaryOnly'); var saveFormat = document.getElementById('saveFormat'); var demucsStemSelect = document.getElementById('demucsStemSelect'); - var demucsSegment = document.getElementById('demucsSegment'); - var mdxSegmentSize = document.getElementById('mdxSegmentSize'); - var mdxOverlap = document.getElementById('mdxOverlap'); - var vrWindowSize = document.getElementById('vrWindowSize'); - var vrAggression = document.getElementById('vrAggression'); var cfg = { uvr_project_path: uvrPath ? uvrPath.value.trim() : '', @@ -1069,11 +1290,11 @@ is_secondary_stem_only: isSecondaryOnly ? isSecondaryOnly.checked : false, save_format: saveFormat ? saveFormat.value : 'wav', demucs_stems: demucsStemSelect ? demucsStemSelect.value : 'All Stems', - demucs_segment: demucsSegment ? demucsSegment.value : 'Default', - mdx_segment_size: mdxSegmentSize ? mdxSegmentSize.value : 'Default', - mdx_overlap: mdxOverlap ? parseFloat(mdxOverlap.value) : 0.25, - vr_window_size: vrWindowSize ? parseInt(vrWindowSize.value) : 1024, - vr_aggression: vrAggression ? parseInt(vrAggression.value) : 5, + demucs_segment: getSelectValue('demucsSegment', 'demucsSegmentCustom') || 'Default', + mdx_segment_size: getSelectValue('mdxSegmentSize', 'mdxSegmentSizeCustom') || 'Default', + mdx_overlap: parseFloat(getSelectValue('mdxOverlap', 'mdxOverlapCustom')) || 0.25, + vr_window_size: parseInt(getSelectValue('vrWindowSize', 'vrWindowSizeCustom')) || 1024, + vr_aggression: parseInt(getSelectValue('vrAggression', 'vrAggressionCustom')) || 5, }; // 保存选中模型(按架构区分) @@ -1190,20 +1411,15 @@ if (demucsStemSel.options[i].value === cfg.demucs_stems) { demucsStemSel.selectedIndex = i; break; } } } - var demucsSegSel = document.getElementById('demucsSegment'); - if (demucsSegSel) demucsSegSel.value = cfg.demucs_segment; + restoreCustomSelect('demucsSegment', 'demucsSegmentCustom', cfg.demucs_segment); // 恢复 MDX 参数 - var mdxSegSel = document.getElementById('mdxSegmentSize'); - if (mdxSegSel) mdxSegSel.value = cfg.mdx_segment_size; - var mdxOverlap = document.getElementById('mdxOverlap'); - if (mdxOverlap) mdxOverlap.value = cfg.mdx_overlap.toFixed(3); + restoreCustomSelect('mdxSegmentSize', 'mdxSegmentSizeCustom', cfg.mdx_segment_size); + restoreCustomSelect('mdxOverlap', 'mdxOverlapCustom', cfg.mdx_overlap.toFixed(3)); // 恢复 VR 参数 - var vrWinSel = document.getElementById('vrWindowSize'); - if (vrWinSel) vrWinSel.value = cfg.vr_window_size; - var vrAggression = document.getElementById('vrAggression'); - if (vrAggression) vrAggression.value = cfg.vr_aggression; + restoreCustomSelect('vrWindowSize', 'vrWindowSizeCustom', String(cfg.vr_window_size)); + restoreCustomSelect('vrAggression', 'vrAggressionCustom', String(cfg.vr_aggression)); // 恢复通用参数 var isGpu = document.getElementById('isGpu');