From db033c661f7dee7722af8a162e09ed730785634b Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Wed, 17 Jun 2026 01:51:11 +0800 Subject: [PATCH] 11111 --- flask-dev-api/.claude/settings.local.json | 33 +- flask-dev-api/app.py | 6 +- flask-dev-api/blueprints/__init__.py | 4 +- flask-dev-api/blueprints/audio_slicer.py | 273 ++++ flask-dev-api/blueprints/uvr_sep.py | 1015 +++++++++++++ flask-dev-api/config/audio_slicer_config.json | 7 + .../config/dv_cookies/x.com_cookies.txt | 2 +- flask-dev-api/config/uvr_sep_config.json | 27 + flask-dev-api/stats.db | Bin 131072 -> 163840 bytes flask-dev-api/templates/ai_dubbing.html | 8 + flask-dev-api/templates/audio_slicer.html | 566 ++++++++ flask-dev-api/templates/base64.html | 7 + flask-dev-api/templates/chmod_calc.html | 4 + flask-dev-api/templates/content_tag.html | 8 + flask-dev-api/templates/down_video.html | 7 + flask-dev-api/templates/fen_ci.html | 7 + flask-dev-api/templates/http_status.html | 7 + flask-dev-api/templates/index.html | 293 +++- flask-dev-api/templates/json_format.html | 7 + flask-dev-api/templates/login.html | 4 + flask-dev-api/templates/qr_code.html | 4 + flask-dev-api/templates/rvc.html | 8 + flask-dev-api/templates/sovits_tts.html | 8 + flask-dev-api/templates/stt.html | 10 +- flask-dev-api/templates/token_gen.html | 4 + flask-dev-api/templates/url_parser.html | 4 + flask-dev-api/templates/uvr_sep.html | 1261 +++++++++++++++++ flask-dev-api/utils/audio_slicer_core.py | 191 +++ 28 files changed, 3744 insertions(+), 31 deletions(-) create mode 100644 flask-dev-api/blueprints/audio_slicer.py create mode 100644 flask-dev-api/blueprints/uvr_sep.py create mode 100644 flask-dev-api/config/audio_slicer_config.json create mode 100644 flask-dev-api/config/uvr_sep_config.json create mode 100644 flask-dev-api/templates/audio_slicer.html create mode 100644 flask-dev-api/templates/uvr_sep.html create mode 100644 flask-dev-api/utils/audio_slicer_core.py diff --git a/flask-dev-api/.claude/settings.local.json b/flask-dev-api/.claude/settings.local.json index 970e1c9..85df226 100644 --- a/flask-dev-api/.claude/settings.local.json +++ b/flask-dev-api/.claude/settings.local.json @@ -41,7 +41,38 @@ "Bash(where python *)", "Bash(conda env *)", "Bash(\"E:/AI/GPT-SoVITS-v4/runtime/python.exe\" \"D:/UserData/Desktop/my_proj/py_demo/python_script/flask-dev-api/utils/sovits_worker.py\" \"E:/AI/GPT-SoVITS-v4\" \"GPT_SoVITS/configs/tts_infer.yaml\")", - "Bash(echo \"EXIT CODE: $?\")" + "Bash(echo \"EXIT CODE: $?\")", + "Bash(ls -la \"D:/UserData/Desktop/my_proj/ultimatevocalremovergui/\")", + "Bash(ls \"E:\\\\AI\\\\ultimatevocalremovergui/demucs\" 2>/dev/null || echo \"No demucs dir\")", + "Bash(curl -s http://127.0.0.1:5000/uvr-sep/)", + "Bash(node -e ' *)", + "Bash(grep -rn \"bag_num\" \"E:\\\\AI\\\\ultimatevocalremovergui/demucs/apply.py\" \"E:\\\\AI\\\\ultimatevocalremovergui/separate.py\" 2>/dev/null)", + "Bash(tasklist)", + "Bash(python -c \"import torch; print\\('torch:', torch.__version__\\); print\\('cuda:', torch.cuda.is_available\\(\\)\\); print\\('cuda_home:', torch.utils.cpp_extension.CUDA_HOME if hasattr\\(torch.utils.cpp_extension, 'CUDA_HOME'\\) else 'N/A'\\)\")", + "Bash(pip list *)", + "Bash(nvidia-smi)", + "Bash(curl -s http://localhost:5000/uvr-sep/)", + "Bash(findstr /n \"模型.*参数\\\\|save-btn\\\\|section-title\")", + "Bash(python -c \"import sys; data=sys.stdin.read\\(\\); lines=data.split\\('\\\\n'\\); [print\\(f'{i+1}: {l.rstrip\\(\\)}'\\) for i,l in enumerate\\(lines\\) if 'section-title' in l or 'save-btn' in l or '模型' in l]\")", + "Bash(python -c \"import sys; data=sys.stdin.read\\(\\); lines=data.split\\('\\\\n'\\); [print\\(f'{i+1}: {l.rstrip\\(\\)}'\\) for i,l in enumerate\\(lines\\) if 320 <= i+1 <= 340]\")", + "Bash(python -c \"import sys; data=sys.stdin.read\\(\\); lines=data.split\\('\\\\n'\\); [print\\(f'{i+1}: {l.rstrip\\(\\)}'\\) for i,l in enumerate\\(lines\\) if 350 <= i+1 <= 370]\")", + "Bash(python -c \"import sys; lines=sys.stdin.read\\(\\).split\\('\\\\n'\\); [print\\(f'{i+1}: {l.rstrip\\(\\)}'\\) for i,l in enumerate\\(lines\\) if 990 <= i+1 <= 1000]\")", + "Bash(python -c \"import sys; lines=sys.stdin.read\\(\\).split\\('\\\\n'\\); [print\\(f'{i+1}: {l.rstrip\\(\\)}'\\) for i,l in enumerate\\(lines\\) if 'demucsParams' in l or 'mdxParams' in l or 'vrParams' in l]\")", + "Bash(python -c \"import sys; lines=sys.stdin.read\\(\\).split\\('\\\\n'\\); [print\\(f'{i+1}: {l.rstrip\\(\\)}'\\) for i,l in enumerate\\(lines\\) if 'id=\\\\\"demucs' in l or 'id=\\\\\"mdx' in l or 'id=\\\\\"vrP' in l]\")", + "Bash(python -c \"import sys; data=sys.stdin.read\\(\\); print\\('demucsParams' in data, 'mdxParams' in data, 'vrParams' in data\\)\")", + "Bash(python -c \"import sys; lines=sys.stdin.read\\(\\).split\\('\\\\n'\\); [print\\(f'{i+1}: {l.rstrip\\(\\)}'\\) for i,l in enumerate\\(lines\\) if 570 <= i+1 <= 582]\")", + "Bash(python -c \"import sys; lines=sys.stdin.read\\(\\).split\\('\\\\n'\\); [print\\(f'{i+1}: {l.rstrip\\(\\)}'\\) for i,l in enumerate\\(lines\\) if '' in l]\")", + "Bash(python -c \"import sys; lines=sys.stdin.read\\(\\).split\\('\\\\n'\\); [print\\(f'{i+1}: {l.rstrip\\(\\)}'\\) for i,l in enumerate\\(lines\\) if 'id=\\\\\"modelDirDisplay\\\\\"' in l or 'demucsParams' in l and 'getElement' not in l]\")", + "Bash(python -c \"import sys; lines=sys.stdin.read\\(\\).split\\('\\\\n'\\); [print\\(f'{i+1}: {l.rstrip\\(\\)}'\\) for i,l in enumerate\\(lines\\) if 'modelDirDisplay' in l]\")", + "Bash(python -c \"import sys; data=sys.stdin.read\\(\\); print\\('模型目录' in data, 'modelDirDisplay' in data.split\\(' {src, sr, ch, total, duration, ranges, settings, slices, status, progress, error, orig_name} +_tasks = {} + + +def _audio_info(path): + with soundfile.SoundFile(path) as f: + sr = f.samplerate + ch = f.channels + total = len(f) + duration = total / sr + return {'sample_rate': sr, 'channels': ch, 'total_samples': total, 'duration': round(duration, 2)} + + +@bp.route('/') +def page(): + cfg = _load_config() + return render_template('audio_slicer.html', config=cfg) + + +@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 'audio' not in request.files: + return jsonify({'success': False, 'error': '请上传音频文件'}), 400 + f = request.files['audio'] + if not f.filename: + return jsonify({'success': False, 'error': '请上传音频文件'}), 400 + ext = os.path.splitext(f.filename)[1].lower() + if ext not in AUDIO_EXTS: + return jsonify({'success': False, 'error': f'不支持的格式: {ext}'}), 400 + + task_id = uuid.uuid4().hex + save_path = os.path.join(tempfile.gettempdir(), f'aslicer_{task_id}{ext}') + f.save(save_path) + try: + info = _audio_info(save_path) + except Exception as e: + try: + os.remove(save_path) + except OSError: + pass + return jsonify({'success': False, 'error': f'无法读取音频: {e}'}), 400 + + _tasks[task_id] = { + 'src': save_path, 'orig_name': f.filename, + 'sr': info['sample_rate'], 'ch': info['channels'], + 'total': info['total_samples'], 'duration': info['duration'], + 'ranges': None, 'slices': None, + 'status': 'uploaded', 'progress': 0, 'error': None, + } + return jsonify({'success': True, 'task_id': task_id, 'info': info, 'filename': f.filename}) + + +@bp.route('/analyze', methods=['POST']) +def analyze(): + data = request.get_json() + task_id = data.get('task_id') + task = _tasks.get(task_id) + if not task: + return jsonify({'success': False, 'error': '任务不存在'}), 404 + + settings = { + 'threshold': float(data.get('threshold', -40)), + 'min_length': int(data.get('min_length', 5000)), + 'min_interval': int(data.get('min_interval', 300)), + 'hop_size': int(data.get('hop_size', 10)), + 'max_sil_kept': int(data.get('max_sil_kept', 1000)), + } + task['settings'] = settings + + try: + ranges, sr, ch, total = analyze_audio(task['src'], settings) + except ValueError as e: + return jsonify({'success': False, 'error': str(e)}), 400 + except Exception as e: + return jsonify({'success': False, 'error': f'分析失败: {e}'}), 500 + + task['ranges'] = ranges + task['sr'] = sr + task['ch'] = ch + task['status'] = 'analyzed' + + preview = [] + for i, (begin, end) in enumerate(ranges): + b, e = int(begin), int(end) + dur = (e - b) / sr + preview.append({'index': i, 'duration': round(dur, 2), 'samples': e - b}) + + return jsonify({ + 'success': True, 'count': len(ranges), 'preview': preview, + 'sample_rate': int(sr), 'channels': int(ch), + }) + + +@bp.route('/slice', methods=['POST']) +def start_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 + if not task.get('ranges'): + return jsonify({'success': False, 'error': '请先分析'}), 400 + + task['status'] = 'slicing' + task['progress'] = 0 + task['slices'] = [] + task['error'] = None + + out_dir = os.path.join(tempfile.gettempdir(), f'aslicer_out_{task_id}') + os.makedirs(out_dir, exist_ok=True) + + 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') + try: + write_slice_range(task['src'], out_path, task['sr'], task['ch'], begin, end) + task['slices'].append({ + 'index': i, 'path': out_path, + 'filename': f'{base}_{i:03d}.wav', + 'duration': round((end - begin) / task['sr'], 2), + }) + except Exception as e: + task['error'] = f'切片 {i} 写入失败: {e}' + task['status'] = 'error' + return + task['progress'] = round((i + 1) / total * 100) + task['status'] = 'done' + task['progress'] = 100 + + threading.Thread(target=_do_slice, 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['slices'] = task['slices'] + resp['count'] = len(task['slices']) + elif task['status'] == 'error': + resp['error'] = task.get('error', '未知错误') + return jsonify(resp) + + +@bp.route('/download//') +def download_one(task_id, index): + task = _tasks.get(task_id) + if not task or not task.get('slices'): + return jsonify({'error': '文件不存在'}), 404 + slices = task['slices'] + if index < 0 or index >= len(slices): + return jsonify({'error': '索引越界'}), 404 + path = slices[index]['path'] + if not os.path.exists(path): + return jsonify({'error': '文件不存在'}), 404 + return send_file(path, mimetype='audio/wav', as_attachment=True, + download_name=slices[index]['filename']) + + +@bp.route('/download-all/') +def download_all(task_id): + task = _tasks.get(task_id) + if not task or not task.get('slices'): + return jsonify({'error': '无切片可下载'}), 404 + + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + for s in task['slices']: + if os.path.exists(s['path']): + zf.write(s['path'], s['filename']) + buf.seek(0) + + base = os.path.splitext(task['orig_name'])[0] + return send_file(buf, mimetype='application/zip', as_attachment=True, + download_name=f'{base}_slices.zip') + + +@bp.route('/cleanup/', methods=['POST']) +def cleanup(task_id): + """清理临时文件""" + task = _tasks.pop(task_id, None) + if not task: + return jsonify({'success': True}) + try: + if task.get('src') and os.path.exists(task['src']): + os.remove(task['src']) + except OSError: + pass + out_dir = os.path.join(tempfile.gettempdir(), f'aslicer_out_{task_id}') + if os.path.isdir(out_dir): + for f in os.listdir(out_dir): + try: + os.remove(os.path.join(out_dir, f)) + except OSError: + pass + try: + os.rmdir(out_dir) + 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 new file mode 100644 index 0000000..7a9f2e6 --- /dev/null +++ b/flask-dev-api/blueprints/uvr_sep.py @@ -0,0 +1,1015 @@ +# -*- coding: utf-8 -*- +""" +UVR 人声分离蓝图 +基于 Ultimate Vocal Remover GUI 的音频源分离能力 +支持 VR / MDX-Net / Demucs 三种架构 +""" +import os +import sys +import io +import json +import uuid +import hashlib +import zipfile +import shutil +import threading +import tempfile +import traceback +from flask import Blueprint, render_template, request, jsonify, send_file +import numpy as np + +try: + from config import BASE_DIR +except ImportError: + BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +bp = Blueprint('uvr_sep', __name__, url_prefix='/uvr-sep') + +AUDIO_EXTS = ('.wav', '.flac', '.ogg', '.mp3', '.aac', '.m4a', '.wma', '.aiff', '.opus') +CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'uvr_sep_config.json') + +# 架构类型常量(与 UVR 的 gui_data.constants 一致) +VR_ARCH = 'VR Arc' +MDX_ARCH = 'MDX-Net' +DEMUCS_ARCH = 'Demucs' + +_tasks = {} + +# ── 配置读写 ────────────────────────────────────────────────────────────────── + +DEFAULT_PARAMS = { + 'uvr_project_path': '', 'model_dir_mode': 'absolute', + 'demucs_model_dir': '', 'vr_model_dir': '', 'mdx_model_dir': '', + 'arch_type': 'Demucs', + 'save_format': 'wav', 'wav_type': 'PCM_16', 'mp3_bitrate': '320k', + 'is_gpu': True, 'device_set': 'Default', + 'is_normalization': False, + 'is_primary_stem_only': False, 'is_secondary_stem_only': False, + 'demucs_stems': 'All Stems', + 'demucs_segment': 'Default', + 'mdx_segment_size': 'Default', 'mdx_overlap': 0.25, 'mdx_batch_size': 1, + 'vr_window_size': 1024, 'vr_aggression': 5, 'vr_batch_size': 4, + 'demucs_selected_model': '', 'mdx_selected_model': '', 'vr_selected_model': '', +} + + +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 _resolve_model_dir(raw_dir, cfg=None): + """将模型目录路径解析为绝对路径""" + if not raw_dir: + return '' + if os.path.isabs(raw_dir): + return raw_dir + if cfg is None: + cfg = _load_config() + uvr_path = cfg.get('uvr_project_path', '') + 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 + if uvr_path: + resolved = os.path.normpath(os.path.join(uvr_path, raw_dir)) + if os.path.isdir(resolved): + return resolved + return raw_dir + + +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() + 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) + + +def _get_uvr_paths(): + """计算 UVR 项目内的关键路径""" + cfg = _load_config() + uvr_path = cfg.get('uvr_project_path', '') + if not uvr_path: + return {} + models_dir = os.path.join(uvr_path, 'models') + return { + 'uvr_path': uvr_path, + 'models_dir': models_dir, + 'vr_param_dir': os.path.join(uvr_path, 'lib_v5', 'vr_network', 'modelparams'), + 'mdx_c_config_path': os.path.join(models_dir, 'MDX_Net_Models', 'model_data', 'mdx_c_configs'), + 'mixer_path': os.path.join(uvr_path, 'lib_v5', 'mixer.ckpt'), + 'denoiser_path': os.path.join(models_dir, 'VR_Models', 'UVR-DeNoise-Lite.pth'), + 'deverb_path': os.path.join(models_dir, 'VR_Models', 'UVR-DeEcho-DeReverb.pth'), + } + + +# ── 延迟导入 UVR ────────────────────────────────────────────────────────────── + +_uvr_imported = False +_uvr_error = None +# 导入后的 UVR 模块引用 +_ModelParameters = None +_SeperateVR = _SeperateMDX = _SeperateMDXC = _SeperateDemucs = None +_secondary_stem = None + + +def _ensure_uvr_imports(): + """延迟导入 UVR 模块,仅在使用时加载""" + global _uvr_imported, _uvr_error + global _ModelParameters, _secondary_stem + global _SeperateVR, _SeperateMDX, _SeperateMDXC, _SeperateDemucs + + if _uvr_imported: + return True + if _uvr_error: + raise ImportError(_uvr_error) + + cfg = _load_config() + uvr_path = cfg.get('uvr_project_path', '') + if not uvr_path or not os.path.isdir(uvr_path): + _uvr_error = '请先配置 UVR 项目路径' + raise ImportError(_uvr_error) + + try: + if uvr_path not in sys.path: + sys.path.insert(0, uvr_path) + + from gui_data.constants import secondary_stem as _ss + _secondary_stem = _ss + + from lib_v5.vr_network.model_param_init import ModelParameters as _MP + _ModelParameters = _MP + + from separate import ( + SeperateVR as _SVR, SeperateMDX as _SMDX, + SeperateMDXC as _SMDXC, SeperateDemucs as _SDem, + ) + _SeperateVR = _SVR + _SeperateMDX = _SMDX + _SeperateMDXC = _SMDXC + _SeperateDemucs = _SDem + + # PyTorch 2.6+ 默认 weights_only=True,对 UVR 模型不兼容 + # patch torch.load,对 demucs 的模型加载使用 weights_only=False + import torch as _torch + _original_torch_load = _torch.load + def _safe_torch_load(*args, **kwargs): + if 'weights_only' not in kwargs: + kwargs['weights_only'] = False + return _original_torch_load(*args, **kwargs) + _torch.load = _safe_torch_load + + # 重新检测 CUDA(separate.py 在模块加载时检测一次,可能不准确) + import separate as _separate_module + _separate_module.cuda_available = _torch.cuda.is_available() + + # 修复 UVR demucs/apply.py 中 bag_num/prog_bar 未初始化的 bug + try: + from demucs import apply as _demucs_apply + _orig_apply = _demucs_apply.apply_model + def _patched_apply(*args, **kwargs): + _demucs_apply.bag_num = getattr(_demucs_apply, 'bag_num', 1) + _demucs_apply.prog_bar = getattr(_demucs_apply, 'prog_bar', 0) + return _orig_apply(*args, **kwargs) + _demucs_apply.apply_model = _patched_apply + except Exception: + pass + + # 修复 librosa 新版本 API 不兼容(位置参数 → 关键字参数) + import librosa as _librosa + _orig_librosa_load = _librosa.load + def _compat_librosa_load(path, *args, **kwargs): + if args: + if 'sr' not in kwargs and len(args) >= 1: + kwargs['sr'] = args[0] + if 'mono' not in kwargs and len(args) >= 2: + kwargs['mono'] = args[1] + args = () + return _orig_librosa_load(path, *args, **kwargs) + _librosa.load = _compat_librosa_load + + _orig_librosa_stft = _librosa.stft + def _compat_librosa_stft(y, *args, **kwargs): + names = ['n_fft', 'hop_length', 'win_length', 'window', 'center', + 'pad_mode', 'length', 'return_complex'] + for i, v in enumerate(args): + if i < len(names) and names[i] not in kwargs: + kwargs[names[i]] = v + return _orig_librosa_stft(y, **kwargs) + _librosa.stft = _compat_librosa_stft + + _orig_librosa_resample = _librosa.resample + def _compat_librosa_resample(y, *args, **kwargs): + names = ['orig_sr', 'target_sr', 'fix', 'scale', 'axis', 'res_type'] + for i, v in enumerate(args): + if i < len(names) and names[i] not in kwargs: + kwargs[names[i]] = v + return _orig_librosa_resample(y, **kwargs) + _librosa.resample = _compat_librosa_resample + + _orig_librosa_istft = getattr(_librosa, 'istft', None) + if _orig_librosa_istft: + def _compat_librosa_istft(stft_matrix, *args, **kwargs): + names = ['hop_length', 'win_length', 'window', 'center', 'length', 'dtype'] + for i, v in enumerate(args): + if i < len(names) and names[i] not in kwargs: + kwargs[names[i]] = v + return _orig_librosa_istft(stft_matrix, **kwargs) + _librosa.istft = _compat_librosa_istft + + # 修复 VR Arc: cmb_spectrogram_to_wave 中 np.ndarray(dtype=complex) + # 未初始化包含垃圾值 → 用 np.zeros 替代 + from lib_v5 import spec_utils as _spec_utils + import numpy as _cmb_np + import librosa as _cmb_librosa + def _fixed_cmb_spectrogram_to_wave(spec_m, mp, extra_bins_h=None, extra_bins=None, is_v51_model=False): + spec_m = _cmb_np.nan_to_num(spec_m, nan=0.0, posinf=0.0, neginf=0.0) + bands_n = len(mp.param['band']) + offset = 0 + for d in range(1, bands_n + 1): + bp = mp.param['band'][d] + spec_s = _cmb_np.zeros(shape=(2, bp['n_fft'] // 2 + 1, spec_m.shape[2]), dtype=complex) + h = bp['crop_stop'] - bp['crop_start'] + spec_s[:, bp['crop_start']:bp['crop_stop'], :] = spec_m[:, offset:offset+h, :] + offset += h + if d == bands_n: + if extra_bins_h: + max_bin = bp['n_fft'] // 2 + spec_s[:, max_bin-extra_bins_h:max_bin, :] = extra_bins[:, :extra_bins_h, :] + if bp['hpf_start'] > 0: + if is_v51_model: + spec_s *= _spec_utils.get_hp_filter_mask(spec_s.shape[1], bp['hpf_start'], bp['hpf_stop'] - 1) + else: + spec_s = _spec_utils.fft_hp_filter(spec_s, bp['hpf_start'], bp['hpf_stop'] - 1) + if bands_n == 1: + wav = _spec_utils.spectrogram_to_wave(spec_s, bp['hl'], mp, d, is_v51_model) + else: + wav = _cmb_np.add(wav, _spec_utils.spectrogram_to_wave(spec_s, bp['hl'], mp, d, is_v51_model)) + else: + sr = mp.param['band'][d+1]['sr'] + if d == 1: + if is_v51_model: + spec_s *= _spec_utils.get_lp_filter_mask(spec_s.shape[1], bp['lpf_start'], bp['lpf_stop']) + else: + spec_s = _spec_utils.fft_lp_filter(spec_s, bp['lpf_start'], bp['lpf_stop']) + wav = _cmb_librosa.resample(_spec_utils.spectrogram_to_wave(spec_s, bp['hl'], mp, d, is_v51_model), orig_sr=bp['sr'], target_sr=sr, res_type=_spec_utils.wav_resolution) + else: + if is_v51_model: + spec_s *= _spec_utils.get_hp_filter_mask(spec_s.shape[1], bp['hpf_start'], bp['hpf_stop'] - 1) + spec_s *= _spec_utils.get_lp_filter_mask(spec_s.shape[1], bp['lpf_start'], bp['lpf_stop']) + else: + spec_s = _spec_utils.fft_hp_filter(spec_s, bp['hpf_start'], bp['hpf_stop'] - 1) + spec_s = _spec_utils.fft_lp_filter(spec_s, bp['lpf_start'], bp['lpf_stop']) + wav2 = _cmb_np.add(wav, _spec_utils.spectrogram_to_wave(spec_s, bp['hl'], mp, d, is_v51_model)) + wav = _cmb_librosa.resample(wav2, orig_sr=bp['sr'], target_sr=sr, res_type=_spec_utils.wav_resolution) + return wav + _spec_utils.cmb_spectrogram_to_wave = _fixed_cmb_spectrogram_to_wave + + # 修复 torch.stft/istft 参数兼容性 + import torch as _torch + _orig_stft = _torch.stft + def _compat_stft(input, *args, **kwargs): + names = ['n_fft', 'hop_length', 'win_length', 'window', + 'center', 'pad_mode', 'normalized', 'onesided', 'return_complex'] + for i, v in enumerate(args): + if i < len(names) and names[i] not in kwargs: + kwargs[names[i]] = v + return _orig_stft(input, **kwargs) + _torch.stft = _compat_stft + _torch.functional.stft = _compat_stft + + _uvr_imported = True + return True + except Exception as e: + _uvr_error = f'UVR 模块加载失败: {e}' + if 'No module named' in str(e): + _uvr_error += '\n请安装缺少的依赖: pip install pyrubberband ml_collections' + raise ImportError(_uvr_error) from e + + +def _secondary_stem_fallback(stem): + """当 UVR 未加载时的备用 stem 映射""" + pairs = {'Vocals': 'Instrumental', 'Instrumental': 'Vocals', + 'Primary Stem': 'Secondary Stem', 'Other': 'No Other', + 'Drums': 'No Drums', 'Bass': 'No Bass', 'Guitar': 'No Guitar'} + return pairs.get(stem, f'No {stem}' if not stem.startswith('No ') else stem.replace('No ', '')) + + +def _get_secondary_stem(stem): + """获取配对音轨名""" + if _secondary_stem: + return _secondary_stem(stem) + return _secondary_stem_fallback(stem) + + +# ── 模型元数据 ───────────────────────────────────────────────────────────────── + +_vr_hash_data = None +_mdx_hash_data = None + + +def _load_model_hash_data(): + """加载 UVR 的模型哈希数据""" + global _vr_hash_data, _mdx_hash_data + if _vr_hash_data is not None: + return + + cfg = _load_config() + uvr_path = cfg.get('uvr_project_path', '') + if not uvr_path: + _vr_hash_data, _mdx_hash_data = {}, {} + return + + vr_dir = os.path.join(uvr_path, 'models', 'VR_Models', 'model_data') + mdx_dir = os.path.join(uvr_path, 'models', 'MDX_Net_Models', 'model_data') + + _vr_hash_data = {} + _mdx_hash_data = {} + + for path in [ + os.path.join(vr_dir, 'model_data.json'), + os.path.join(vr_dir, 'model_data_new.json'), + ]: + if os.path.isfile(path): + try: + with open(path, 'r', encoding='utf-8') as f: + _vr_hash_data.update(json.load(f)) + except Exception: + pass + + for path in [ + os.path.join(mdx_dir, 'model_data.json'), + os.path.join(mdx_dir, 'model_data_new.json'), + ]: + if os.path.isfile(path): + try: + with open(path, 'r', encoding='utf-8') as f: + _mdx_hash_data.update(json.load(f)) + except Exception: + pass + + +def _compute_model_hash(model_path): + """计算模型文件哈希(与 UVR 相同的算法)""" + try: + with open(model_path, 'rb') as f: + f.seek(-10000 * 1024, 2) + return hashlib.md5(f.read()).hexdigest() + except Exception: + try: + return hashlib.md5(open(model_path, 'rb').read()).hexdigest() + except Exception: + return None + + +def _lookup_model_meta(model_hash, arch_type): + """通过哈希查找模型元数据""" + _load_model_hash_data() + if not model_hash: + return None + hash_data = _vr_hash_data if 'VR' in arch_type else _mdx_hash_data + return hash_data.get(model_hash) + + +# ── HeadlessModelData ───────────────────────────────────────────────────────── + +class HeadlessModelData: + """替代 UVR 的 ModelData,无需 tkinter GUI""" + + def __init__(self, model_path, process_method, params, model_meta=None): + self.model_path = model_path + self.model_name = os.path.splitext(os.path.basename(model_path))[0] + self.model_basename = self.model_name + self.process_method = process_method + self.model_status = True + self.model_meta = model_meta + + # GPU 设置 + self.is_gpu_conversion = 0 if params.get('is_gpu', True) else -1 + self.device_set = params.get('device_set', 'Default') + + # 通用参数 + self.is_normalization = params.get('is_normalization', False) + self.is_primary_stem_only = params.get('is_primary_stem_only', False) + self.is_secondary_stem_only = params.get('is_secondary_stem_only', False) + self.wav_type_set = params.get('wav_type', 'PCM_16') + self.mp3_bit_set = params.get('mp3_bitrate', '320k') + self.save_format = params.get('save_format', 'WAV').upper() + + self.is_invert_spec = False + self.is_mixer_mode = False + self.is_mdx_c_seg_def = True + self.mdx_batch_size = params.get('mdx_batch_size', 1) + self.mdxnet_stem_select = 'Vocals' + self.overlap = params.get('mdx_overlap', 0.25) + self.overlap_mdx = params.get('mdx_overlap', 0.25) + self.overlap_mdx23 = 8 + self.semitone_shift = 0 + self.is_pitch_change = False + self.is_match_frequency_pitch = True + self.is_mdx_combine_stems = False + self.is_use_opencl = False + + # MDX 模型数据 + self.is_mdx_ckpt = model_path.endswith('.ckpt') or model_path.endswith('.ckptc') + self.is_mdx_c = False + self.mdx_c_configs = None + self.mdx_model_stems = [] + self.mdx_dim_f_set = None + self.mdx_dim_t_set = None + self.mdx_stem_count = 1 + self.compensate = None + self.mdx_n_fft_scale_set = None + + # 路径 + paths = _get_uvr_paths() + self.mixer_path = paths.get('mixer_path', '') + + # Demucs 默认 + self.demucs_stems = 'All Stems' + self.is_demucs_combine_stems = False + self.demucs_source_list = [] + self.demucs_stem_count = 0 + self.demucs_source_map = {} + self.demucs_version = 'v4' + + # 主音轨设置 + self.primary_stem = None + self.secondary_stem = None + self.primary_stem_native = None + + # 禁用复杂功能 + self.is_ensemble_mode = False + self.ensemble_primary_stem = None + self.ensemble_secondary_stem = None + self.primary_model_primary_stem = None + self.is_secondary_model = False + self.is_secondary_model_activated = False + self.secondary_model = None + self.secondary_model_scale = None + self.pre_proc_model = None + self.pre_proc_model_activated = False + self.is_pre_proc_model = False + self.is_dry_check = False + self.is_vocal_split_model = False + self.is_vocal_split_model_activated = False + self.is_primary_model_primary_stem_only = False + self.is_primary_model_secondary_stem_only = False + self.is_save_inst_vocal_splitter = False + self.is_inst_only_voc_splitter = False + self.is_save_vocal_only = False + self.is_deverb_vocals = False + self.deverb_vocal_opt = 'Vocals' + self.is_denoise = False + self.is_denoise_model = False + self.is_karaoke = False + self.is_bv_model = False + self.bv_model_rebalance = 0 + self.is_sec_bv_rebalance = False + + # 默认 + self.model_samplerate = 44100 + self.model_capacity = (32, 128) + self.is_vr_51_model = False + self.is_demucs_pre_proc_model_inst_mix = False + self.is_change_def = False + self.is_4_stem_ensemble = False + self.is_multi_stem_ensemble = False + self.is_demucs_4_stem_secondaries = False + self.demucs_4_stem_added_count = 0 + self.model_hash_dir = None + self.is_get_hash_dir_only = False + + # 多模型占位 + self.secondary_model_4_stem = [] + self.secondary_model_4_stem_scale = [] + self.secondary_model_4_stem_names = [] + self.secondary_model_4_stem_model_names_list = [] + self.all_models = [] + self.secondary_model_other = None + self.secondary_model_scale_other = None + self.secondary_model_bass = None + self.secondary_model_scale_bass = None + self.secondary_model_drums = None + self.secondary_model_scale_drums = None + + # DeNoise / DeVerb 模型路径 + self.DENOISER_MODEL = paths.get('denoiser_path', '') + self.DEVERBER_MODEL = paths.get('deverb_path', '') + self.vocal_split_model = None + + # 根据架构类型初始化特定参数 + if process_method == VR_ARCH: + self._init_vr(params, model_meta, paths) + elif process_method == MDX_ARCH: + self._init_mdx(params, model_meta, paths) + elif process_method == DEMUCS_ARCH: + self._init_demucs(params) + + def _init_vr(self, params, model_meta, paths): + """VR 架构特定参数""" + self.aggression_setting = float(int(params.get('vr_aggression', 5)) / 100) + self.is_tta = False + self.is_post_process = False + self.window_size = params.get('vr_window_size', 1024) + self.batch_size = params.get('vr_batch_size', 4) + self.crop_size = 256 + self.is_high_end_process = 'None' + self.post_process_threshold = 0.2 + + if model_meta: + self.primary_stem = model_meta.get('primary_stem', 'Vocals') + vr_param_name = model_meta.get('vr_model_param', 'bandparam_opposite') + vr_param_path = os.path.join(paths.get('vr_param_dir', ''), f'{vr_param_name}.json') + if os.path.isfile(vr_param_path) and _ModelParameters: + self.vr_model_param = _ModelParameters(vr_param_path) + self.model_samplerate = self.vr_model_param.param['sr'] + else: + self.vr_model_param = None + if 'nout' in model_meta and 'nout_lstm' in model_meta: + self.model_capacity = (model_meta['nout'], model_meta['nout_lstm']) + self.is_vr_51_model = True + else: + self.vr_model_param = None + self.primary_stem = 'Vocals' + + self.primary_stem_native = self.primary_stem + self.secondary_stem = _get_secondary_stem(self.primary_stem) + + def _init_mdx(self, params, model_meta, paths): + """MDX 架构特定参数""" + self.margin = 44100 + self.chunks = 0 + seg = params.get('mdx_segment_size', 'Default') + self.mdx_segment_size = int(seg) if seg and seg != 'Default' else 256 + + if model_meta: + if 'config_yaml' in model_meta: + self.is_mdx_c = True + config_path = os.path.join( + paths.get('mdx_c_config_path', ''), + model_meta['config_yaml'] + ) + if os.path.isfile(config_path): + import yaml + from ml_collections import ConfigDict + with open(config_path) as f: + self.mdx_c_configs = ConfigDict(yaml.load(f, Loader=yaml.FullLoader)) + if self.mdx_c_configs.training.target_instrument: + target = self.mdx_c_configs.training.target_instrument + self.mdx_model_stems = [target] + self.primary_stem = target + else: + self.mdx_model_stems = self.mdx_c_configs.training.instruments + self.mdx_stem_count = len(self.mdx_model_stems) + self.primary_stem = self.mdx_model_stems[0] if self.mdx_stem_count == 2 else self.mdxnet_stem_select + else: + self.primary_stem = model_meta.get('primary_stem', 'Vocals') + else: + self.compensate = model_meta.get('compensate', 1.0) + self.mdx_dim_f_set = model_meta.get('mdx_dim_f_set') + self.mdx_dim_t_set = model_meta.get('mdx_dim_t_set') + self.mdx_n_fft_scale_set = model_meta.get('mdx_n_fft_scale_set') + self.primary_stem = model_meta.get('primary_stem', 'Vocals') + else: + self.primary_stem = 'Vocals' + + self.primary_stem_native = self.primary_stem + self.secondary_stem = _get_secondary_stem(self.primary_stem) + + def _init_demucs(self, params): + """Demucs 架构特定参数""" + self.margin_demucs = 44100 + self.chunks_demucs = 0 + self.shifts = 1 + self.is_split_mode = True + self.segment = params.get('demucs_segment', 'Default') + self.is_chunk_demucs = False + self.demucs_stems = params.get('demucs_stems', 'All Stems') + + # 从文件名推断版本和音轨数 + self.demucs_version = 'v4' + for ver, tag in [('v1', 'v1 | '), ('v2', 'v2 | '), ('v3', 'v3 | '), ('v4', 'v4 | ')]: + if tag in self.model_name: + self.demucs_version = ver + break + + # .th 文件名格式为 "sig-checksum",get_model 只需要 sig 部分 + if self.model_path.endswith('.th') and '-' in self.model_basename: + self.model_basename = self.model_basename.split('-')[0] + + if 'UVR_Model' in self.model_name: + self.demucs_source_list = ['instrumental', 'vocals'] + self.demucs_source_map = {'instrumental': 0, 'vocals': 1} + self.demucs_stem_count = 2 + self.primary_stem = 'Vocals' + self.secondary_stem = 'Instrumental' + else: + self.demucs_source_list = ['drums', 'bass', 'other', 'vocals'] + self.demucs_source_map = { + 'Bass': 0, 'Drums': 1, 'Other': 2, 'Vocals': 3 + } + self.demucs_stem_count = 4 + self.primary_stem = 'Vocals' + self.secondary_stem = _get_secondary_stem('Vocals') + + +# ── 工具函数 ─────────────────────────────────────────────────────────────────── + +def _update_progress(task_id, step, inference_iterations=0): + """更新任务进度 (step: 0.0~1.0)""" + task = _tasks.get(task_id) + if not task: + return + progress = min(99, max(1, int((step + inference_iterations) * 100))) + task['progress'] = progress + + +def _make_process_data(task_id, model_data, audio_path, export_path): + """构造 process_data 字典""" + base = os.path.splitext(os.path.basename(audio_path))[0] + 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, + 'process_iteration': lambda: None, + 'cached_source_callback': lambda *_, **__: (None, None), + 'cached_model_source_holder': lambda *_, **__: None, + 'list_all_models': [], + 'is_ensemble_master': False, + 'is_4_stem_ensemble': False, + } + + +def _do_separate(task_id, model_path, process_method, audio_path, params, model_meta): + """后台线程执行分离""" + task = _tasks[task_id] + try: + _ensure_uvr_imports() + + import separate as _sep_mod + import torch + cuda_ok = getattr(_sep_mod, 'cuda_available', False) and torch.cuda.is_available() + + model_data = HeadlessModelData(model_path, process_method, params, model_meta) + + export_path = os.path.join(tempfile.gettempdir(), f'uvr_out_{task_id}') + os.makedirs(export_path, exist_ok=True) + + process_data = _make_process_data(task_id, model_data, audio_path, export_path) + + # 根据架构类型选择分离器 + if process_method == VR_ARCH: + separator = _SeperateVR(model_data, process_data) + elif process_method == MDX_ARCH: + if model_data.is_mdx_c: + separator = _SeperateMDXC(model_data, process_data) + else: + separator = _SeperateMDX(model_data, process_data) + elif process_method == DEMUCS_ARCH: + separator = _SeperateDemucs(model_data, process_data) + else: + raise ValueError(f'不支持的架构: {process_method}') + + actual_device = str(getattr(separator, 'device', 'unknown')) + task['device'] = actual_device + print(f'[UVR-Sep] is_gpu={params.get("is_gpu")}, cuda={cuda_ok}, device={actual_device}') + + separator.seperate() + + # 扫描输出文件 + stems = [] + if os.path.isdir(export_path): + for fname in sorted(os.listdir(export_path)): + fpath = os.path.join(export_path, fname) + if os.path.isfile(fpath): + stem_name = fname + if '_(' in fname and fname.endswith(').wav'): + start = fname.index('_(') + 2 + end = fname.rindex(')') + stem_name = fname[start:end] + stems.append({ + 'stem': stem_name, + 'filename': fname, + 'path': fpath, + }) + + if not stems: + task['status'] = 'error' + task['error'] = '分离完成但未生成输出文件' + return + + task['stems'] = stems + task['status'] = 'done' + task['progress'] = 100 + + except Exception as e: + task['status'] = 'error' + task['error'] = str(e) + traceback.print_exc() + finally: + try: + os.remove(audio_path) + except OSError: + pass + + +# ── 路由 ─────────────────────────────────────────────────────────────────────── + +@bp.route('/') +def page(): + cfg = _load_config() + return render_template('uvr_sep.html', config=cfg) + + +@bp.route('/gpu-status') +def gpu_status(): + """检测 GPU/CUDA 状态""" + info = {'cuda_available': False, 'gpu_name': '', 'torch_version': 'N/A', 'torch_cuda': False} + # 方法1: ctranslate2 + try: + import ctranslate2 + count = ctranslate2.get_cuda_device_count() + if count > 0: + info['cuda_available'] = True + try: + info['gpu_name'] = ctranslate2.get_cuda_device_name(0) or '' + except Exception: + pass + except Exception: + pass + # 方法2: PyTorch + try: + import torch + info['torch_version'] = torch.__version__ + info['torch_cuda'] = torch.cuda.is_available() + if info['torch_cuda']: + info['cuda_available'] = True + if not info['gpu_name']: + info['gpu_name'] = torch.cuda.get_device_name(0) + except ImportError: + pass + return jsonify(info) + + +@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) + global _uvr_imported, _uvr_error, _vr_hash_data, _mdx_hash_data + if 'uvr_project_path' in data: + _uvr_imported = False + _uvr_error = None + _vr_hash_data = None + _mdx_hash_data = None + return jsonify({'success': True}) + + +@bp.route('/scan-models', methods=['POST']) +def scan_models(): + """扫描模型目录""" + data = request.get_json() + model_dir = data.get('model_dir', '') + arch_type = data.get('arch_type', 'MDX-Net') + + cfg = _load_config() + model_dir = _get_model_dir_for_arch(arch_type, model_dir, cfg) + + if not model_dir or not os.path.isdir(model_dir): + return jsonify({'success': False, 'error': '模型目录无效'}), 400 + + _load_model_hash_data() + + exts = { + 'VR Arc': ('.pth',), + 'MDX-Net': ('.onnx', '.ckpt', '.ckptc'), + 'Demucs': ('.yaml',), + }.get(arch_type, ('.onnx', '.ckpt', '.ckptc')) + + models = [] + try: + for fname in os.listdir(model_dir): + fpath = os.path.join(model_dir, fname) + if not os.path.isfile(fpath): + continue + ext = os.path.splitext(fname)[1].lower() + if ext not in exts: + continue + + info = { + 'name': fname, + 'path': fpath, + 'size': os.path.getsize(fpath), + } + + model_hash = _compute_model_hash(fpath) + if model_hash: + meta = _lookup_model_meta(model_hash, arch_type) + if meta: + info['primary_stem'] = meta.get('primary_stem', '') + info['secondary_stem'] = _get_secondary_stem(meta.get('primary_stem', 'Vocals')) + info['has_meta'] = True + else: + info['has_meta'] = False + + models.append(info) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + return jsonify({'success': True, 'models': models}) + + +@bp.route('/separate', methods=['POST']) +def start_separate(): + """开始分离""" + task_id = request.form.get('task_id', '') + model_path = request.form.get('model_path', '') + process_method = request.form.get('process_method', 'Demucs') + primary_stem = request.form.get('primary_stem', '') + demucs_stems = request.form.get('demucs_stems', 'All Stems') + segment = request.form.get('segment', 'Default') + mdx_overlap = request.form.get('mdx_overlap', '') + vr_window_size = request.form.get('vr_window_size', '') + vr_aggression = request.form.get('vr_aggression', '') + is_primary_stem_only = request.form.get('is_primary_stem_only', '') + is_secondary_stem_only = request.form.get('is_secondary_stem_only', '') + is_gpu = request.form.get('is_gpu', '') + is_normalization = request.form.get('is_normalization', '') + + 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 + if not model_path: + return jsonify({'success': False, 'error': '请指定模型路径'}), 400 + if not os.path.isfile(model_path): + return jsonify({'success': False, 'error': f'模型文件不存在: {model_path}'}), 400 + + ext = os.path.splitext(audio_file.filename)[1].lower() + if ext not in AUDIO_EXTS: + return jsonify({'success': False, 'error': f'不支持的音频格式: {ext}'}), 400 + + save_path = os.path.join(tempfile.gettempdir(), f'uvr_{uuid.uuid4().hex}{ext}') + audio_file.save(save_path) + + # 架构名直接使用 UVR 常量名 + arch = process_method + + model_hash = _compute_model_hash(model_path) + model_meta = _lookup_model_meta(model_hash, + 'VR' if arch == VR_ARCH else 'MDX' if arch == MDX_ARCH else 'Demucs') + + if not model_meta and primary_stem: + model_meta = {'primary_stem': primary_stem} + + if model_meta and primary_stem and primary_stem != model_meta.get('primary_stem', ''): + model_meta = dict(model_meta) + model_meta['primary_stem'] = primary_stem + + if not model_meta and arch != DEMUCS_ARCH: + try: + os.remove(save_path) + except OSError: + pass + return jsonify({ + 'success': False, + 'error': '无法识别模型,请在模型列表中选择正确的主音轨类型', + }), 400 + + params = _load_config() + # 前端传来的主/副音轨选项覆盖配置 + if is_primary_stem_only: + params['is_primary_stem_only'] = is_primary_stem_only == '1' + if is_secondary_stem_only: + params['is_secondary_stem_only'] = is_secondary_stem_only == '1' + if is_gpu: + params['is_gpu'] = is_gpu == '1' + if is_normalization: + params['is_normalization'] = is_normalization == '1' + # 架构特异参数覆盖 + if arch == DEMUCS_ARCH: + params['demucs_stems'] = demucs_stems + params['demucs_segment'] = segment + elif arch == MDX_ARCH: + params['mdx_segment_size'] = segment + if mdx_overlap: + try: + params['mdx_overlap'] = float(mdx_overlap) + except ValueError: + pass + elif arch == VR_ARCH: + if vr_window_size: + try: + params['vr_window_size'] = int(vr_window_size) + except ValueError: + pass + if vr_aggression: + try: + params['vr_aggression'] = int(vr_aggression) + except ValueError: + pass + + task_id = task_id or uuid.uuid4().hex + _tasks[task_id] = { + 'status': 'processing', + 'progress': 0, + 'stems': [], + 'error': None, + 'device': '', + } + + threading.Thread( + target=_do_separate, + args=(task_id, model_path, arch, save_path, params, model_meta), + daemon=True, + ).start() + + return jsonify({'success': True, 'task_id': task_id}) + + +@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.get('device'): + resp['device'] = task['device'] + if task['status'] == 'done': + resp['stems'] = task['stems'] + resp['count'] = len(task['stems']) + elif task['status'] == 'error': + resp['error'] = task.get('error', '未知错误') + return jsonify(resp) + + +@bp.route('/download//') +def download_stem(task_id, stem): + task = _tasks.get(task_id) + if not task or task.get('status') != 'done': + return jsonify({'error': '文件不存在'}), 404 + for s in task['stems']: + if s['stem'] == stem and os.path.isfile(s['path']): + return send_file(s['path'], mimetype='audio/wav', + as_attachment=True, download_name=s['filename']) + return jsonify({'error': '文件不存在'}), 404 + + +@bp.route('/download-all/') +def download_all(task_id): + task = _tasks.get(task_id) + if not task or task.get('status') != 'done': + return jsonify({'error': '无文件可下载'}), 404 + + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + for s in task['stems']: + if os.path.isfile(s['path']): + zf.write(s['path'], s['filename']) + buf.seek(0) + return send_file(buf, mimetype='application/zip', as_attachment=True, + download_name='uvr_stems.zip') + + +@bp.route('/cleanup/', methods=['POST']) +def cleanup(task_id): + task = _tasks.pop(task_id, None) + if not task: + return jsonify({'success': True}) + export_path = os.path.join(tempfile.gettempdir(), f'uvr_out_{task_id}') + if os.path.isdir(export_path): + shutil.rmtree(export_path, ignore_errors=True) + return jsonify({'success': True}) diff --git a/flask-dev-api/config/audio_slicer_config.json b/flask-dev-api/config/audio_slicer_config.json new file mode 100644 index 0000000..6538501 --- /dev/null +++ b/flask-dev-api/config/audio_slicer_config.json @@ -0,0 +1,7 @@ +{ + "threshold": -40, + "min_length": 5000, + "min_interval": 100, + "hop_size": 10, + "max_sil_kept": 1000 +} \ No newline at end of file diff --git a/flask-dev-api/config/dv_cookies/x.com_cookies.txt b/flask-dev-api/config/dv_cookies/x.com_cookies.txt index d41dd0d..598a90f 100644 --- a/flask-dev-api/config/dv_cookies/x.com_cookies.txt +++ b/flask-dev-api/config/dv_cookies/x.com_cookies.txt @@ -12,7 +12,7 @@ zhutix.com FALSE / FALSE 1782047186 X_CACHE_KEY 0291346395e7ad60497281e3e38d4231 .x.com TRUE / TRUE 1815998952 guest_id_marketing v1%3A177285432573681062 .x.com TRUE / TRUE 1812974953 twid u%3D1051785185509822466 .x.com TRUE / FALSE 1809050447 g_state {"i_l":0,"i_ll":1772854333755,"i_e":{"enable_itp_optimization":0}} -.x.com TRUE / TRUE 1781551671 __cf_bm Y8Rce.E3Yxoh5EYX50LfajfNrmXT685C2ouYXp15erI-1781549871.0334666-1.0.1.1-O1jKivmiYHSUXZNwxtl9tFYsJ.e9bqR5h0Ct_LDNTxmqa6dbjMJz6k1M18IDYzJCL0sQNGf44f7.xfAHvDB1WsZzToX5s7J1.NlMYz5eWG5L1aJ9.ABmgZ1dOkjOQQE6 +.x.com TRUE / TRUE 1781601759 __cf_bm JzRjXTH8u9OEtJ9mQuhEKSLp6QiLBfizQ.1Y2mLheG4-1781599959.1226218-1.0.1.1-CR3AvWSJNniEEG9_buiMAnHFH5kvVsgAbLKSt7_jCJC2Ag8d.S1e07LzNqRBrZMfmMvXl5Tj928CfyzdLrmxuD5UOiky3JoslAQ6HHKHqDpE_I.8pK6cXINlD8VQ_n1Z www.dropbox.com FALSE / TRUE 1790090452 gvc MTk3NzU0NTUwMjg0MDYzMDYzMzA4NTQ0MjEyNzAzODM1MzUxNTYz www.dropbox.com FALSE / TRUE 1787066452 __Host-js_csrf ee7vG1QZzX2hhBoptjGNMkhg .dropbox.com TRUE / TRUE 1809050447 t ee7vG1QZzX2hhBoptjGNMkhg diff --git a/flask-dev-api/config/uvr_sep_config.json b/flask-dev-api/config/uvr_sep_config.json new file mode 100644 index 0000000..c6bd410 --- /dev/null +++ b/flask-dev-api/config/uvr_sep_config.json @@ -0,0 +1,27 @@ +{ + "uvr_project_path": "E:\\AI\\ultimatevocalremovergui", + "model_dir_mode": "absolute", + "demucs_model_dir": "uvr5-model\\demucs_model", + "vr_model_dir": "E:\\AI\\uvr5-model\\vr-arc", + "mdx_model_dir": "E:\\AI\\uvr5-model\\mdx-net", + "arch_type": "Demucs", + "save_format": "wav", + "wav_type": "PCM_16", + "mp3_bitrate": "320k", + "is_gpu": true, + "device_set": "Default", + "is_normalization": false, + "is_primary_stem_only": false, + "is_secondary_stem_only": false, + "demucs_stems": "Vocals", + "demucs_segment": "Default", + "mdx_segment_size": "Default", + "mdx_overlap": 0.25, + "mdx_batch_size": 1, + "vr_window_size": 1024, + "vr_aggression": 5, + "vr_batch_size": 4, + "demucs_selected_model": "", + "mdx_selected_model": "", + "vr_selected_model": "" +} \ No newline at end of file diff --git a/flask-dev-api/stats.db b/flask-dev-api/stats.db index 9270be0ba2d9c43827fe5c718f1f509dd5f80cb2..cc90c8b9e200f25f80b6cbaa41642c43e3b54eb1 100644 GIT binary patch delta 28455 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 delta 123 zcmZo@;A&{#m>|W*?##fzpfFLvj)~oQW5N=C4hjAupa_G)WF7&7%{B&R{F{s9!vz@G zCZEzbWt81y5NOY3s$gVbWo%|;WH~uHKLXA*Hfi3QzkP2$qk99hDRa diff --git a/flask-dev-api/templates/audio_slicer.html b/flask-dev-api/templates/audio_slicer.html new file mode 100644 index 0000000..f8823bd --- /dev/null +++ b/flask-dev-api/templates/audio_slicer.html @@ -0,0 +1,566 @@ + + + + + + + Audio Slicer 音频分割 + + + +
+
+
+
+

Audio Slicer

+
+

基于静音检测的智能音频分割

+ + +
+
上传音频
+
+ +
点击或拖拽音频文件到此处
+ + +
+ +
+ + +
+
+ 分割参数 + +
+
+ 静音阈值 + + + dB +
+
+ 最短片段 + + + ms +
+
+ 最短静音 + + + ms +
+
+ 帧长 + + + ms +
+
+ 保留静音 + + + ms +
+
+ + +
+ + +
+
+
+ +
+
+

切片结果

+
+
+
+ +

上传音频文件后开始分析

+
+ +
+
+ + + + diff --git a/flask-dev-api/templates/base64.html b/flask-dev-api/templates/base64.html index fb2ecff..d353e80 100644 --- a/flask-dev-api/templates/base64.html +++ b/flask-dev-api/templates/base64.html @@ -119,6 +119,13 @@ z-index: 9999; pointer-events: none; } .toast.show { opacity: 1; } + + @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; } + .right-panel { height: auto; min-height: 50vh; } + .input-section { padding: 14px 16px 20px; } + } diff --git a/flask-dev-api/templates/chmod_calc.html b/flask-dev-api/templates/chmod_calc.html index 9eb64cf..fad1405 100644 --- a/flask-dev-api/templates/chmod_calc.html +++ b/flask-dev-api/templates/chmod_calc.html @@ -95,6 +95,10 @@ } .btn-cp:hover { background: #006cbd; } .btn-cp.copied { background: #2e7d32; } + + @media (max-width: 768px) { + .container { padding: 14px 16px 28px; } + } diff --git a/flask-dev-api/templates/content_tag.html b/flask-dev-api/templates/content_tag.html index efab91e..74a37e4 100644 --- a/flask-dev-api/templates/content_tag.html +++ b/flask-dev-api/templates/content_tag.html @@ -340,6 +340,14 @@ background: #fff; color: #333; transition: border-color 0.15s; } .prompt-textarea:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); } + @media (max-width: 768px) { + body { flex-direction: column; height: auto; overflow: auto; } + .left-panel { width: 100% !important; max-width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; } + .right-panel { height: auto; min-height: 50vh; } + .card-pair { flex-direction: column; } + .modal { width: 92vw !important; max-height: 90vh; overflow-y: auto; left: 4vw !important; transform: none !important; } + .input-section { padding: 14px 16px 20px; } + } diff --git a/flask-dev-api/templates/down_video.html b/flask-dev-api/templates/down_video.html index c9723c9..ffa9282 100644 --- a/flask-dev-api/templates/down_video.html +++ b/flask-dev-api/templates/down_video.html @@ -445,6 +445,13 @@ max-height: 50px; opacity: 1; } + + @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; } + .right-panel { height: auto; min-height: 50vh; } + .input-section { padding: 14px 16px 20px; } + } diff --git a/flask-dev-api/templates/fen_ci.html b/flask-dev-api/templates/fen_ci.html index c2f28d6..d603329 100644 --- a/flask-dev-api/templates/fen_ci.html +++ b/flask-dev-api/templates/fen_ci.html @@ -219,6 +219,13 @@ } .toast.show { opacity: 1; } .toast.error { background: #c62828; } + + @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; } + .right-panel { height: auto; min-height: 50vh; } + .input-section { padding: 14px 16px 20px; } + } diff --git a/flask-dev-api/templates/http_status.html b/flask-dev-api/templates/http_status.html index 6efa24a..5e92fb6 100644 --- a/flask-dev-api/templates/http_status.html +++ b/flask-dev-api/templates/http_status.html @@ -94,6 +94,13 @@ .match-count { font-size: 12px; color: #999; margin-bottom: 16px; } + + @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; } + .right-panel { height: auto; min-height: 50vh; } + .input-section { padding: 14px 16px 20px; } + } diff --git a/flask-dev-api/templates/index.html b/flask-dev-api/templates/index.html index c378772..9c50532 100644 --- a/flask-dev-api/templates/index.html +++ b/flask-dev-api/templates/index.html @@ -125,6 +125,73 @@ .sidebar-search-input input::placeholder { color: #bbb; } .nav-item.hidden { display: none; } + /* 侧边栏底部设置按钮 */ + .sidebar-footer { + margin-top: auto; padding: 12px 16px 8px; + border-top: 1px solid #e0e0e0; + } + .sidebar-settings-btn { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 8px 10px; border: none; background: none; + border-radius: 8px; cursor: pointer; font-size: 13px; color: #999; + transition: all 0.15s; + } + .sidebar-settings-btn:hover { background: #f0f0f0; color: #666; } + .sidebar-settings-btn i { width: 20px; text-align: center; } + + /* 功能设置弹窗 */ + .feat-modal-overlay { + display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; + background: rgba(0,0,0,0.35); z-index: 1000; + justify-content: center; align-items: flex-start; padding-top: 60px; + } + .feat-modal-overlay.show { display: flex; } + .feat-modal { + background: #fff; border-radius: 12px; width: 380px; max-height: 70vh; + box-shadow: 0 12px 40px rgba(0,0,0,0.2); overflow: hidden; + display: flex; flex-direction: column; + } + .feat-modal-header { + padding: 14px 18px; border-bottom: 1px solid #eee; + display: flex; align-items: center; justify-content: space-between; + flex-shrink: 0; + } + .feat-modal-header h3 { font-size: 14px; font-weight: 600; color: #333; } + .feat-modal-close { + width: 26px; height: 26px; border: none; background: #f5f5f5; + border-radius: 6px; cursor: pointer; font-size: 13px; color: #999; + display: flex; align-items: center; justify-content: center; + } + .feat-modal-close:hover { background: #eee; color: #333; } + .feat-modal-body { + padding: 12px 18px; overflow-y: auto; flex: 1; + } + .feat-group-title { + font-size: 11px; font-weight: 600; color: #999; text-transform: uppercase; + letter-spacing: 0.05em; margin: 12px 0 6px; + } + .feat-group-title:first-child { margin-top: 0; } + .feat-item { + display: flex; align-items: center; gap: 8px; padding: 7px 0; + font-size: 13px; color: #333; cursor: pointer; + } + .feat-item input[type="checkbox"] { + width: 16px; height: 16px; accent-color: #0078d4; cursor: pointer; + flex-shrink: 0; + } + .feat-modal-footer { + padding: 10px 18px; border-top: 1px solid #eee; + display: flex; justify-content: flex-end; gap: 8px; flex-shrink: 0; + } + .feat-modal-footer button { + padding: 6px 16px; font-size: 12px; font-weight: 600; + border-radius: 6px; cursor: pointer; transition: all 0.15s; + } + .feat-btn-cancel { border: 1px solid #e0e0e0; background: #fff; color: #666; } + .feat-btn-cancel:hover { background: #f5f5f5; color: #333; } + .feat-btn-save { border: none; background: #0078d4; color: #fff; } + .feat-btn-save:hover { background: #006cbd; } + /* 主内容区 */ .main { flex: 1; @@ -424,18 +491,68 @@ .status-badge.s4xx { background: #fff3e0; color: #e65100; } .status-badge.s5xx { background: #fce4ec; color: #c62828; } + /* 汉堡按钮 */ + .hamburger { + display: none; + width: 36px; height: 36px; + background: none; border: none; cursor: pointer; + font-size: 18px; color: #333; + align-items: center; justify-content: center; + border-radius: 6px; transition: background 0.15s; + } + .hamburger:hover { background: #f0f0f0; } + + /* 侧边栏遮罩 */ + .sidebar-overlay { + display: none; + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.3); z-index: 999; + } + @media (max-width: 768px) { + .hamburger { display: flex; } + .sidebar { - display: none; + z-index: 1000; + transform: translateX(-100%); + transition: transform 0.25s ease; } + .sidebar.open { + transform: translateX(0); + } + .sidebar-overlay.open { display: block; } .main { margin-left: 0; } + + .stat-cards { + flex-wrap: wrap; + } + .stat-card { + min-width: calc(50% - 8px); + flex: 1 1 calc(50% - 8px); + } + + .chart-row { + flex-direction: column; + } + .chart-row .chart-section { + min-width: 0; + margin-bottom: 12px; + } + .chart-row .chart-section:last-child { margin-bottom: 0; } + + .pie-chart-wrap { flex-direction: column; height: auto; } + .pie-legend { width: 100%; max-height: 120px; flex-direction: row; flex-wrap: wrap; } + + .data-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } } + + + +
+
+
+

功能显示设置

+ +
+
+ +
+
+
+
+
@@ -699,6 +848,7 @@
+
@@ -712,12 +862,23 @@ const dashboardHTML = document.getElementById('dashboard').outerHTML; let charts = {}; + // 侧边栏 移动端开关 + function toggleSidebar() { + document.querySelector('.sidebar').classList.toggle('open'); + document.getElementById('sidebar-overlay').classList.toggle('open'); + } + function closeSidebar() { + document.querySelector('.sidebar').classList.remove('open'); + document.getElementById('sidebar-overlay').classList.remove('open'); + } + function disposeCharts() { Object.values(charts).forEach(c => c && c.dispose()); charts = {}; } function showDashboard() { + closeSidebar(); document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active')); document.getElementById('nav-dashboard').classList.add('active'); document.getElementById('title-text').textContent = '仪表盘'; @@ -732,6 +893,7 @@ } function loadTool(element, url) { + closeSidebar(); disposeCharts(); document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active')); element.classList.add('active'); @@ -1180,6 +1342,87 @@ Object.values(charts).forEach(c => c && c.resize()); }); + // ===== 功能显示设置 ===== + var FEATURES = [ + { id: 'dashboard', name: '仪表盘', section: '概览' }, + { id: 'data-manage', name: '数据管理', section: '概览' }, + { id: 'pin-tu', name: '路径文件查阅器', section: '工具' }, + { id: 'down-video', name: 'XBIY视频下载器', section: '工具' }, + { id: 'content-tag', name: 'AI生成文章标签', section: '工具' }, + { id: 'stt', name: 'STT 语音转文字', section: '工具' }, + { id: 'audio-slicer', name: 'AudioSlicer 音频分割', section: '工具' }, + { id: 'uvr-sep', name: 'UVR 人声分离', section: '工具' }, + { id: 'ai-dubbing', name: 'GPT-SoVITS (引擎版)', section: '工具' }, + { id: 'rvc', name: 'RVC 语音音色转换', section: '工具' }, + { id: 'fen-ci', name: 'Jieba 分词网页版', section: '工具' }, + { id: 'base64', name: 'Base64 编码/解码', section: '工具' }, + { id: 'json-format', name: 'JSON 美化/压缩', section: '工具' }, + { id: 'http-status', name: 'HTTP 状态码查询', section: '工具' }, + { id: 'url-parser', name: 'URL 路径解析器', section: '工具' }, + { id: 'chmod-calc', name: 'Chmod 计算器', section: '工具' }, + { id: 'token-gen', name: 'Token 随机生成器', section: '工具' }, + { id: 'qr-code', name: 'Qr-code 生成器', section: '工具' }, + { id: 'sovits-tts', name: 'GPT-SoVITS (接口版)', section: '不常用' } + ]; + + function getHiddenFeatures() { + try { return JSON.parse(localStorage.getItem('hidden_features') || '[]'); } catch(e) { return []; } + } + + function applyFeatSettings() { + var hidden = getHiddenFeatures(); + document.querySelectorAll('.nav-item[data-feature]').forEach(function(item) { + var fid = item.getAttribute('data-feature'); + item.style.display = hidden.indexOf(fid) !== -1 ? 'none' : ''; + }); + // 隐藏空的 section-title + document.querySelectorAll('.nav-section').forEach(function(section) { + var hasVisible = false; + section.querySelectorAll('.nav-item[data-feature]').forEach(function(item) { + if (item.style.display !== 'none') hasVisible = true; + }); + var title = section.querySelector('.nav-section-title'); + if (title && title.textContent.trim() !== '概览') { + title.style.display = hasVisible ? '' : 'none'; + } + }); + } + + function openFeatModal() { + var hidden = getHiddenFeatures(); + var body = document.getElementById('featModalBody'); + var html = ''; + var currentSection = ''; + FEATURES.forEach(function(f) { + if (f.section !== currentSection) { + currentSection = f.section; + html += '
' + currentSection + '
'; + } + var checked = hidden.indexOf(f.id) === -1 ? 'checked' : ''; + html += ''; + }); + body.innerHTML = html; + document.getElementById('featModalOverlay').classList.add('show'); + } + + function closeFeatModal(e) { + if (e && e.target !== document.getElementById('featModalOverlay')) return; + document.getElementById('featModalOverlay').classList.remove('show'); + } + + function saveFeatSettings() { + var hidden = []; + document.querySelectorAll('#featModalBody input[type="checkbox"]').forEach(function(cb) { + if (!cb.checked) hidden.push(cb.getAttribute('data-feature-id')); + }); + localStorage.setItem('hidden_features', JSON.stringify(hidden)); + applyFeatSettings(); + document.getElementById('featModalOverlay').classList.remove('show'); + } + + // 页面加载时应用功能显示设置 + applyFeatSettings(); + loadDashboard(); diff --git a/flask-dev-api/templates/json_format.html b/flask-dev-api/templates/json_format.html index 422f428..51b5a26 100644 --- a/flask-dev-api/templates/json_format.html +++ b/flask-dev-api/templates/json_format.html @@ -100,6 +100,13 @@ display: none; align-items: center; gap: 6px; } .err-bar.on { display: flex; } + + @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; } + .right-panel { height: auto; min-height: 50vh; } + .input-section { padding: 14px 16px 20px; } + } diff --git a/flask-dev-api/templates/login.html b/flask-dev-api/templates/login.html index e663c7c..f510b7a 100644 --- a/flask-dev-api/templates/login.html +++ b/flask-dev-api/templates/login.html @@ -50,6 +50,10 @@ border-radius: 6px; padding: 8px 12px; font-size: 13px; text-align: center; margin-bottom: 16px; } + + @media (max-width: 768px) { + .login-card { width: 92vw; padding: 28px 20px; } + } diff --git a/flask-dev-api/templates/qr_code.html b/flask-dev-api/templates/qr_code.html index 00eef75..f7d665b 100644 --- a/flask-dev-api/templates/qr_code.html +++ b/flask-dev-api/templates/qr_code.html @@ -103,6 +103,10 @@ } .btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; } .btn-outline:hover { background: #f5f5f5; color: #333; } + + @media (max-width: 768px) { + .container { padding: 14px 16px 28px; } + } diff --git a/flask-dev-api/templates/rvc.html b/flask-dev-api/templates/rvc.html index 9871d20..a6dab29 100644 --- a/flask-dev-api/templates/rvc.html +++ b/flask-dev-api/templates/rvc.html @@ -217,6 +217,14 @@ .toast.error { background: #fce4ec; color: #c62828; border: 1px solid #f8bbd0; } .toast.info { background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; } @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + @media (max-width: 768px) { + body { flex-direction: column; height: auto; overflow: auto; } + .left-panel { width: 100% !important; max-width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; } + .right-panel { height: auto; min-height: 50vh; } + .card-pair { flex-direction: column; } + .modal { width: 92vw !important; max-height: 90vh; overflow-y: auto; left: 4vw !important; transform: none !important; } + .input-section { padding: 14px 16px 20px; } + } diff --git a/flask-dev-api/templates/sovits_tts.html b/flask-dev-api/templates/sovits_tts.html index 6f778e5..5624fbd 100644 --- a/flask-dev-api/templates/sovits_tts.html +++ b/flask-dev-api/templates/sovits_tts.html @@ -328,6 +328,14 @@ @keyframes slideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } } @keyframes slideOut { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-8px); } } .message.removing { animation: slideOut 0.2s ease forwards; } + @media (max-width: 768px) { + body { flex-direction: column; height: auto; overflow: auto; } + .left-panel { width: 100% !important; max-width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; } + .right-panel { height: auto; min-height: 50vh; } + .card-pair { flex-direction: column; } + .modal { width: 92vw !important; max-height: 90vh; overflow-y: auto; left: 4vw !important; transform: none !important; } + .input-section { padding: 14px 16px 20px; } + } diff --git a/flask-dev-api/templates/stt.html b/flask-dev-api/templates/stt.html index 265c6ce..aff4d72 100644 --- a/flask-dev-api/templates/stt.html +++ b/flask-dev-api/templates/stt.html @@ -17,7 +17,7 @@ } /* 左侧面板 */ .left-panel { - width: 420px; + max-width: 420px; width: 100%; flex-shrink: 0; display: flex; flex-direction: column; @@ -316,6 +316,14 @@ padding: 12px 20px; border-top: 1px solid #f0f0f0; display: flex; justify-content: flex-end; gap: 8px; } + @media (max-width: 768px) { + body { flex-direction: column; height: auto; overflow: auto; } + .left-panel { width: 100% !important; max-width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; } + .right-panel { height: auto; min-height: 50vh; } + .card-pair { flex-direction: column; } + .modal { width: 92vw !important; max-height: 90vh; overflow-y: auto; left: 4vw !important; transform: none !important; } + .input-section { padding: 14px 16px 20px; } + } diff --git a/flask-dev-api/templates/token_gen.html b/flask-dev-api/templates/token_gen.html index 1955f37..9b7e6a8 100644 --- a/flask-dev-api/templates/token_gen.html +++ b/flask-dev-api/templates/token_gen.html @@ -96,6 +96,10 @@ min-height: 60px; } .token-len { margin-top: 10px; font-size: 12px; color: #bbb; } + + @media (max-width: 768px) { + .container { padding: 14px 16px 28px; } + } diff --git a/flask-dev-api/templates/url_parser.html b/flask-dev-api/templates/url_parser.html index af2f943..837db90 100644 --- a/flask-dev-api/templates/url_parser.html +++ b/flask-dev-api/templates/url_parser.html @@ -134,6 +134,10 @@ } .empty-state i { font-size: 40px; margin-bottom: 12px; display: block; } .empty-state p { font-size: 13px; } + + @media (max-width: 768px) { + .container { padding: 14px 16px 28px; } + } diff --git a/flask-dev-api/templates/uvr_sep.html b/flask-dev-api/templates/uvr_sep.html new file mode 100644 index 0000000..5927276 --- /dev/null +++ b/flask-dev-api/templates/uvr_sep.html @@ -0,0 +1,1261 @@ + + + + + + + UVR 人声分离 + + + +
+ + +
+
+
+

UVR 人声分离

+ +
+

基于 Ultimate Vocal Remover 的音频源分离

+ + +
+
上传音频
+
+ +
点击或拖拽音频文件到此处
+ +
+
+ + +
+
模型 & 参数 + +
+ +
+ + +
+ +
+ + +
+ + + + +
+ +
+
Demucs 参数
+
+ + +
全部分轨 - 提取所有可用分分轨
+
+
+ + + + +
较小的尺寸占用资源较少。较大的尺寸效果可能更好。
+
+
+ + + + + + +
+
+ + +
+
通用参数 + +
+ +
+ + + + + + + +
+ +
+ + +
+
+ + + +
+
+ + +
+
+

分离结果

+
+
+
+ +

上传音频并选择模型后开始分离

+
+ + +
+
+ + + + + + + + + diff --git a/flask-dev-api/utils/audio_slicer_core.py b/flask-dev-api/utils/audio_slicer_core.py new file mode 100644 index 0000000..ca6b590 --- /dev/null +++ b/flask-dev-api/utils/audio_slicer_core.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +""" +audio-slicer 核心切割逻辑 +移植自 audio-slicer 项目 (slicer2.py + gui/slicing_tasks.py) +仅依赖 numpy + soundfile +""" +import os +import numpy as np +import soundfile + + +def get_rms(y, *, frame_length=2048, hop_length=512, pad_mode="constant"): + """计算 RMS(移植自 librosa)""" + padding = (int(frame_length // 2), int(frame_length // 2)) + y = np.pad(y, padding, mode=pad_mode) + axis = -1 + out_strides = y.strides + tuple([y.strides[axis]]) + x_shape_trimmed = list(y.shape) + x_shape_trimmed[axis] -= frame_length - 1 + out_shape = tuple(x_shape_trimmed) + tuple([frame_length]) + xw = np.lib.stride_tricks.as_strided(y, shape=out_shape, strides=out_strides) + if axis < 0: + target_axis = axis - 1 + else: + target_axis = axis + 1 + xw = np.moveaxis(xw, -1, target_axis) + slices = [slice(None)] * xw.ndim + slices[axis] = slice(0, None, hop_length) + x = xw[tuple(slices)] + power = np.mean(np.abs(x) ** 2, axis=-2, keepdims=True) + return np.sqrt(power) + + +class Slicer: + def __init__(self, sr, threshold=-40., min_length=5000, min_interval=300, + hop_size=20, max_sil_kept=5000): + if not min_length >= min_interval >= hop_size: + raise ValueError('min_length >= min_interval >= hop_size') + if not max_sil_kept >= hop_size: + raise ValueError('max_sil_kept >= hop_size') + min_interval_f = sr * min_interval / 1000 + self.threshold = 10 ** (threshold / 20.) + self.hop_size = round(sr * hop_size / 1000) + self.win_size = min(round(min_interval_f), 4 * self.hop_size) + self.min_length = round(sr * min_length / 1000 / self.hop_size) + self.min_interval = round(min_interval_f / self.hop_size) + self.max_sil_kept = round(sr * max_sil_kept / 1000 / self.hop_size) + + def _frame_to_sample(self, frame_index, total_samples): + return min(total_samples, frame_index * self.hop_size) + + def slice_ranges(self, waveform): + if len(waveform.shape) > 1: + samples = waveform.mean(axis=0) + total_samples = waveform.shape[1] + else: + samples = waveform + total_samples = waveform.shape[0] + if (samples.shape[0] + self.hop_size - 1) // self.hop_size <= self.min_length: + return [(0, total_samples)] + rms_list = get_rms(y=samples, frame_length=self.win_size, hop_length=self.hop_size).squeeze(0) + return self.slice_ranges_from_rms(rms_list, total_samples) + + def slice_ranges_from_rms(self, rms_list, total_samples): + if rms_list.shape[0] == 0: + return [(0, total_samples)] + total_frames = rms_list.shape[0] + if total_frames <= self.min_length: + return [(0, total_samples)] + sil_tags = [] + silence_start = None + clip_start = 0 + for i, rms in enumerate(rms_list): + if rms < self.threshold: + if silence_start is None: + silence_start = i + continue + if silence_start is None: + continue + is_leading_silence = silence_start == 0 and i > self.max_sil_kept + need_slice_middle = i - silence_start >= self.min_interval and i - clip_start >= self.min_length + if not is_leading_silence and not need_slice_middle: + silence_start = None + continue + if i - silence_start <= self.max_sil_kept: + pos = rms_list[silence_start: i + 1].argmin() + silence_start + if silence_start == 0: + sil_tags.append((0, pos)) + else: + sil_tags.append((pos, pos)) + clip_start = pos + elif i - silence_start <= self.max_sil_kept * 2: + pos = rms_list[i - self.max_sil_kept: silence_start + self.max_sil_kept + 1].argmin() + pos += i - self.max_sil_kept + pos_l = rms_list[silence_start: silence_start + self.max_sil_kept + 1].argmin() + silence_start + pos_r = rms_list[i - self.max_sil_kept: i + 1].argmin() + i - self.max_sil_kept + if silence_start == 0: + sil_tags.append((0, pos_r)) + clip_start = pos_r + else: + sil_tags.append((min(pos_l, pos), max(pos_r, pos))) + clip_start = max(pos_r, pos) + else: + pos_l = rms_list[silence_start: silence_start + self.max_sil_kept + 1].argmin() + silence_start + pos_r = rms_list[i - self.max_sil_kept: i + 1].argmin() + i - self.max_sil_kept + if silence_start == 0: + sil_tags.append((0, pos_r)) + else: + sil_tags.append((pos_l, pos_r)) + clip_start = pos_r + silence_start = None + if silence_start is not None and total_frames - silence_start >= self.min_interval: + silence_end = min(total_frames, silence_start + self.max_sil_kept) + pos = rms_list[silence_start: silence_end + 1].argmin() + silence_start + sil_tags.append((pos, total_frames + 1)) + if len(sil_tags) == 0: + return [(0, total_samples)] + ranges = [] + if sil_tags[0][0] > 0: + ranges.append((0, self._frame_to_sample(sil_tags[0][0], total_samples))) + for i in range(len(sil_tags) - 1): + ranges.append(( + self._frame_to_sample(sil_tags[i][1], total_samples), + self._frame_to_sample(sil_tags[i + 1][0], total_samples), + )) + if sil_tags[-1][1] < total_frames: + ranges.append((self._frame_to_sample(sil_tags[-1][1], total_samples), total_samples)) + return ranges + + +def build_rms_list_from_file(source_file, slicer, read_size=131072): + """流式计算 RMS 列表,避免大文件一次性加载到内存""" + source_file.seek(0) + pad = slicer.win_size // 2 + buffer = np.zeros(pad, dtype=np.float32) + rms_parts = [] + while True: + chunk = source_file.read(read_size, dtype="float32", always_2d=True) + if len(chunk) == 0: + break + mono = chunk.mean(axis=1, dtype=np.float32) + buffer = np.concatenate((buffer, mono.astype(np.float32, copy=False))) + values, buffer = _consume_rms_frames(buffer, slicer) + if values.size: + rms_parts.append(values) + buffer = np.concatenate((buffer, np.zeros(pad, dtype=np.float32))) + values, _ = _consume_rms_frames(buffer, slicer) + if values.size: + rms_parts.append(values) + if not rms_parts: + return np.zeros(0, dtype=np.float32) + return np.concatenate(rms_parts) + + +def _consume_rms_frames(buffer, slicer): + if buffer.shape[0] < slicer.win_size: + return np.zeros(0, dtype=np.float32), buffer + usable = ((buffer.shape[0] - slicer.win_size) // slicer.hop_size) + 1 + window_view = np.lib.stride_tricks.sliding_window_view(buffer, slicer.win_size) + windows = window_view[::slicer.hop_size][:usable] + rms_values = np.sqrt(np.mean(np.abs(windows) ** 2, axis=1, dtype=np.float64)).astype(np.float32) + remaining = buffer[usable * slicer.hop_size:] + return rms_values, remaining + + +def analyze_audio(source_path, settings): + """分析音频,返回 (ranges, sample_rate, channels, total_samples)""" + with soundfile.SoundFile(source_path) as f: + sr = f.samplerate + ch = f.channels + total = len(f) + slicer = Slicer(sr=sr, **settings) + if (total + slicer.hop_size - 1) // slicer.hop_size <= slicer.min_length: + return [(0, total)], sr, ch, total + rms_list = build_rms_list_from_file(f, slicer) + ranges = slicer.slice_ranges_from_rms(rms_list, total) + return ranges, sr, ch, total + + +def write_slice_range(source_path, output_path, sample_rate, channels, begin, end, chunk_size=65536): + """流式写出单个切片""" + frames_remaining = max(0, end - begin) + with soundfile.SoundFile(source_path) as src, \ + soundfile.SoundFile(output_path, mode="w", samplerate=sample_rate, channels=channels) as dst: + src.seek(begin) + while frames_remaining > 0: + block = src.read(min(chunk_size, frames_remaining), dtype="float32", always_2d=True) + if len(block) == 0: + break + dst.write(block) + frames_remaining -= len(block)