generated from dellevin/template
11111
This commit is contained in:
@@ -41,7 +41,38 @@
|
|||||||
"Bash(where python *)",
|
"Bash(where python *)",
|
||||||
"Bash(conda env *)",
|
"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(\"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 '<script' in l or '</script>' 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\\('<script'\\)[0] if '<script' in data else 'no script'\\)\")",
|
||||||
|
"Bash(python -c \"import sys; lines=sys.stdin.read\\(\\).split\\('\\\\n'\\); [print\\(f'{i+1}: {l.rstrip\\(\\)}'\\) for i,l in enumerate\\(lines\\) if 310 <= i+1 <= 330]\")",
|
||||||
|
"Bash(python -c \"import sys; lines=sys.stdin.read\\(\\).split\\('\\\\n'\\); [print\\(f'{i+1}: {l.rstrip\\(\\)}'\\) for i,l in enumerate\\(lines\\) if 330 <= i+1 <= 360]\")",
|
||||||
|
"Bash(python app.py)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5000/uvr-sep/)",
|
||||||
|
"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/)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ from datetime import datetime
|
|||||||
from flask import Flask, render_template, jsonify, g, request, session, redirect
|
from flask import Flask, render_template, jsonify, g, request, session, redirect
|
||||||
|
|
||||||
from config import PORT, LOGIN_ENABLED, LOGIN_USERNAME, LOGIN_PASSWORD, SECRET_KEY
|
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
|
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 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 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
|
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 = 15
|
TOOL_COUNT = 17
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
@@ -226,6 +226,8 @@ def create_app():
|
|||||||
app.register_blueprint(stt_bp)
|
app.register_blueprint(stt_bp)
|
||||||
app.register_blueprint(ai_dubbing_bp)
|
app.register_blueprint(ai_dubbing_bp)
|
||||||
app.register_blueprint(rvc_bp)
|
app.register_blueprint(rvc_bp)
|
||||||
|
app.register_blueprint(audio_slicer_bp)
|
||||||
|
app.register_blueprint(uvr_sep_bp)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ from .sovits_tts import bp as sovits_tts_bp
|
|||||||
from .stt import bp as stt_bp
|
from .stt import bp as stt_bp
|
||||||
from .ai_dubbing import bp as ai_dubbing_bp
|
from .ai_dubbing import bp as ai_dubbing_bp
|
||||||
from .rvc import bp as rvc_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
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'pin_tu_bp', 'base64_bp', 'down_video_bp', 'fen_ci_bp', 'content_tag_bp',
|
'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',
|
'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',
|
'sovits_tts_bp', 'stt_bp', 'ai_dubbing_bp', 'rvc_bp', 'audio_slicer_bp', 'uvr_sep_bp',
|
||||||
]
|
]
|
||||||
|
|||||||
273
flask-dev-api/blueprints/audio_slicer.py
Normal file
273
flask-dev-api/blueprints/audio_slicer.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
audio-slicer 音频分割蓝图
|
||||||
|
基于 RMS 静音检测自动切割音频
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
import threading
|
||||||
|
import tempfile
|
||||||
|
from flask import Blueprint, render_template, request, jsonify, send_file, after_this_request
|
||||||
|
import soundfile
|
||||||
|
|
||||||
|
try:
|
||||||
|
from config import BASE_DIR
|
||||||
|
except ImportError:
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from utils.audio_slicer_core import analyze_audio, write_slice_range
|
||||||
|
|
||||||
|
bp = Blueprint('audio_slicer', __name__, url_prefix='/audio-slicer')
|
||||||
|
|
||||||
|
AUDIO_EXTS = ('.wav', '.flac', '.ogg', '.mp3', '.aac', '.m4a', '.wma', '.aiff', '.opus')
|
||||||
|
CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'audio_slicer_config.json')
|
||||||
|
|
||||||
|
DEFAULT_PARAMS = {
|
||||||
|
'threshold': -40, 'min_length': 5000, 'min_interval': 300,
|
||||||
|
'hop_size': 10, 'max_sil_kept': 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 任务缓存: task_id -> {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/<task_id>')
|
||||||
|
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/<task_id>/<int:index>')
|
||||||
|
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/<task_id>')
|
||||||
|
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/<task_id>', 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})
|
||||||
1015
flask-dev-api/blueprints/uvr_sep.py
Normal file
1015
flask-dev-api/blueprints/uvr_sep.py
Normal file
File diff suppressed because it is too large
Load Diff
7
flask-dev-api/config/audio_slicer_config.json
Normal file
7
flask-dev-api/config/audio_slicer_config.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"threshold": -40,
|
||||||
|
"min_length": 5000,
|
||||||
|
"min_interval": 100,
|
||||||
|
"hop_size": 10,
|
||||||
|
"max_sil_kept": 1000
|
||||||
|
}
|
||||||
@@ -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 1815998952 guest_id_marketing v1%3A177285432573681062
|
||||||
.x.com TRUE / TRUE 1812974953 twid u%3D1051785185509822466
|
.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 / 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 1790090452 gvc MTk3NzU0NTUwMjg0MDYzMDYzMzA4NTQ0MjEyNzAzODM1MzUxNTYz
|
||||||
www.dropbox.com FALSE / TRUE 1787066452 __Host-js_csrf ee7vG1QZzX2hhBoptjGNMkhg
|
www.dropbox.com FALSE / TRUE 1787066452 __Host-js_csrf ee7vG1QZzX2hhBoptjGNMkhg
|
||||||
.dropbox.com TRUE / TRUE 1809050447 t ee7vG1QZzX2hhBoptjGNMkhg
|
.dropbox.com TRUE / TRUE 1809050447 t ee7vG1QZzX2hhBoptjGNMkhg
|
||||||
|
|||||||
27
flask-dev-api/config/uvr_sep_config.json
Normal file
27
flask-dev-api/config/uvr_sep_config.json
Normal file
@@ -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": ""
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -277,6 +277,14 @@
|
|||||||
@keyframes slideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
|
@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); } }
|
@keyframes slideOut { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-8px); } }
|
||||||
.message.removing { animation: slideOut 0.2s ease forwards; }
|
.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; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
566
flask-dev-api/templates/audio_slicer.html
Normal file
566
flask-dev-api/templates/audio_slicer.html
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||||
|
<title>Audio Slicer 音频分割</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: #f5f5f5; color: #333;
|
||||||
|
height: 100vh; overflow: hidden; display: flex;
|
||||||
|
}
|
||||||
|
/* 左侧面板 */
|
||||||
|
.left-panel {
|
||||||
|
width: 460px; flex-shrink: 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
border-right: 1px solid #e8e8e8;
|
||||||
|
background: #fff; height: 100vh; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.left-panel::-webkit-scrollbar { width: 4px; }
|
||||||
|
.left-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
|
||||||
|
.input-section { padding: 20px 24px 28px; }
|
||||||
|
|
||||||
|
.header { display: flex; align-items: center; margin-bottom: 2px; }
|
||||||
|
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; letter-spacing: -0.01em; }
|
||||||
|
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
background: #fafafa; border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 10px; padding: 14px 16px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 11px; font-weight: 600; color: #999;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.section-title i { font-size: 11px; }
|
||||||
|
|
||||||
|
/* 上传区 */
|
||||||
|
.upload-area {
|
||||||
|
border: 2px dashed #e0e0e0; border-radius: 10px;
|
||||||
|
padding: 28px 16px; text-align: center; cursor: pointer;
|
||||||
|
transition: all 0.2s; background: #fff;
|
||||||
|
}
|
||||||
|
.upload-area:hover, .upload-area.dragover {
|
||||||
|
border-color: #0078d4; background: #f0f7ff;
|
||||||
|
}
|
||||||
|
.upload-area i { font-size: 28px; color: #ccc; margin-bottom: 8px; display: block; }
|
||||||
|
.upload-area .label { font-size: 13px; color: #999; }
|
||||||
|
.upload-area .filename { font-size: 13px; color: #0078d4; font-weight: 600; margin-top: 4px; }
|
||||||
|
.upload-area input[type="file"] { display: none; }
|
||||||
|
|
||||||
|
/* 音频信息 */
|
||||||
|
.audio-info {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.info-item {
|
||||||
|
background: #fff; border: 1px solid #f0f0f0; border-radius: 6px;
|
||||||
|
padding: 8px 10px; font-size: 12px;
|
||||||
|
}
|
||||||
|
.info-item .info-label { color: #999; font-size: 11px; }
|
||||||
|
.info-item .info-value { font-weight: 600; color: #333; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* 参数 */
|
||||||
|
.param-row {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.param-row:last-child { margin-bottom: 0; }
|
||||||
|
.param-label {
|
||||||
|
font-size: 12px; color: #666; width: 100px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.param-row input[type="range"] { flex: 1; accent-color: #0078d4; }
|
||||||
|
.param-val {
|
||||||
|
width: 60px; text-align: center; padding: 4px 6px;
|
||||||
|
border: 1px solid #e0e0e0; border-radius: 4px;
|
||||||
|
font-size: 12px; font-weight: 600; color: #333;
|
||||||
|
}
|
||||||
|
.param-unit { font-size: 11px; color: #999; width: 24px; }
|
||||||
|
|
||||||
|
/* 按钮 */
|
||||||
|
.action-btn {
|
||||||
|
width: 100%; padding: 10px 18px;
|
||||||
|
border: none; border-radius: 8px;
|
||||||
|
font-size: 14px; font-weight: 600; cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
|
||||||
|
}
|
||||||
|
.action-btn:hover { transform: translateY(-1px); }
|
||||||
|
.action-btn:active { transform: translateY(0); }
|
||||||
|
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||||
|
.action-btn.analyze {
|
||||||
|
background: linear-gradient(135deg, #0078d4, #005fa3); color: #fff;
|
||||||
|
}
|
||||||
|
.action-btn.slice {
|
||||||
|
background: linear-gradient(135deg, #0a7a1a, #065f12); color: #fff;
|
||||||
|
}
|
||||||
|
.btn-row { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
.btn-row .action-btn { flex: 1; }
|
||||||
|
|
||||||
|
/* 右侧面板 */
|
||||||
|
.right-panel {
|
||||||
|
flex: 1; background: #fff;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
.right-header {
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
.right-header h2 { font-size: 15px; font-weight: 600; color: #333; }
|
||||||
|
.right-body {
|
||||||
|
flex: 1; overflow-y: auto; padding: 16px 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 预览表格 */
|
||||||
|
.preview-table {
|
||||||
|
width: 100%; border-collapse: collapse; font-size: 13px;
|
||||||
|
}
|
||||||
|
.preview-table th {
|
||||||
|
text-align: left; padding: 8px 12px; color: #999;
|
||||||
|
font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
font-size: 11px; border-bottom: 2px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
.preview-table td {
|
||||||
|
padding: 8px 12px; border-bottom: 1px solid #f0f0f0; color: #555;
|
||||||
|
}
|
||||||
|
.preview-table tr:hover td { background: #fafafa; }
|
||||||
|
|
||||||
|
/* 切片行 */
|
||||||
|
.slice-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.slice-row:last-child { border-bottom: none; }
|
||||||
|
.slice-idx { width: 36px; font-size: 13px; font-weight: 700; color: #999; text-align: center; }
|
||||||
|
.slice-name { flex: 1; font-size: 13px; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.slice-dur { font-size: 12px; color: #999; width: 60px; text-align: right; }
|
||||||
|
.slice-actions { display: flex; gap: 6px; }
|
||||||
|
.slice-btn {
|
||||||
|
width: 30px; height: 30px; border: 1px solid #e0e0e0; border-radius: 6px;
|
||||||
|
background: #fff; color: #666; cursor: pointer; display: flex;
|
||||||
|
align-items: center; justify-content: center; font-size: 12px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.slice-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
|
||||||
|
.slice-btn.play-btn:hover { color: #0078d4; border-color: #0078d4; }
|
||||||
|
|
||||||
|
/* 进度条 */
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%; height: 6px; background: #f0f0f0; border-radius: 3px;
|
||||||
|
overflow: hidden; margin: 12px 0;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%; background: linear-gradient(90deg, #0078d4, #0a7a1a);
|
||||||
|
border-radius: 3px; transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; height: 100%; color: #ccc;
|
||||||
|
}
|
||||||
|
.empty-state i { font-size: 48px; margin-bottom: 16px; }
|
||||||
|
.empty-state p { font-size: 14px; }
|
||||||
|
|
||||||
|
/* 顶部操作栏 */
|
||||||
|
.top-actions {
|
||||||
|
display: flex; gap: 8px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.top-btn {
|
||||||
|
padding: 7px 14px; border: 1px solid #e0e0e0; border-radius: 6px;
|
||||||
|
background: #fff; color: #666; font-size: 12px; font-weight: 600;
|
||||||
|
cursor: pointer; display: flex; align-items: center; gap: 5px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.top-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
|
||||||
|
.top-btn.primary { background: #0078d4; color: #fff; border-color: #0078d4; }
|
||||||
|
.top-btn.primary:hover { background: #006cbd; }
|
||||||
|
|
||||||
|
.status-text { font-size: 12px; color: #999; margin: 8px 0; }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.toast {
|
||||||
|
padding: 10px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15); animation: slideIn 0.3s ease;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.toast.success { background: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; }
|
||||||
|
.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; 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; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
<div class="left-panel">
|
||||||
|
<div class="input-section">
|
||||||
|
<div class="header">
|
||||||
|
<h1><i class="fas fa-scissors" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>Audio Slicer</h1>
|
||||||
|
</div>
|
||||||
|
<p class="subtitle">基于静音检测的智能音频分割</p>
|
||||||
|
|
||||||
|
<!-- 上传 -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title"><i class="fas fa-file"></i> 上传音频</div>
|
||||||
|
<div class="upload-area" id="uploadArea" onclick="document.getElementById('audioInput').click()">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
<div class="label" id="uploadLabel">点击或拖拽音频文件到此处</div>
|
||||||
|
<div class="filename" id="uploadFilename" style="display:none;"></div>
|
||||||
|
<input type="file" id="audioInput" accept=".wav,.flac,.ogg,.mp3,.aac,.m4a,.wma,.aiff,.opus">
|
||||||
|
</div>
|
||||||
|
<div class="audio-info" id="audioInfo" style="display:none;">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">采样率</div>
|
||||||
|
<div class="info-value" id="infoSr">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">声道数</div>
|
||||||
|
<div class="info-value" id="infoCh">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">时长</div>
|
||||||
|
<div class="info-value" id="infoDur">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">文件名</div>
|
||||||
|
<div class="info-value" id="infoName" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 参数 -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">
|
||||||
|
<i class="fas fa-wrench"></i> 分割参数
|
||||||
|
<button class="slice-btn" style="margin-left:auto;" onclick="saveConfig()" title="保存参数"><i class="fas fa-check"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="param-row">
|
||||||
|
<span class="param-label">静音阈值</span>
|
||||||
|
<input type="range" id="pThreshold" min="-80" max="-10" value="{{ config.threshold }}" oninput="syncParam(this, 'vThreshold')">
|
||||||
|
<input type="number" class="param-val" id="vThreshold" value="{{ config.threshold }}" onchange="syncSlider(this, 'pThreshold')">
|
||||||
|
<span class="param-unit">dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="param-row">
|
||||||
|
<span class="param-label">最短片段</span>
|
||||||
|
<input type="range" id="pMinLength" min="1000" max="30000" step="500" value="{{ config.min_length }}" oninput="syncParam(this, 'vMinLength')">
|
||||||
|
<input type="number" class="param-val" id="vMinLength" value="{{ config.min_length }}" onchange="syncSlider(this, 'pMinLength')">
|
||||||
|
<span class="param-unit">ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="param-row">
|
||||||
|
<span class="param-label">最短静音</span>
|
||||||
|
<input type="range" id="pMinInterval" min="50" max="3000" step="50" value="{{ config.min_interval }}" oninput="syncParam(this, 'vMinInterval')">
|
||||||
|
<input type="number" class="param-val" id="vMinInterval" value="{{ config.min_interval }}" onchange="syncSlider(this, 'pMinInterval')">
|
||||||
|
<span class="param-unit">ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="param-row">
|
||||||
|
<span class="param-label">帧长</span>
|
||||||
|
<input type="range" id="pHopSize" min="5" max="100" step="5" value="{{ config.hop_size }}" oninput="syncParam(this, 'vHopSize')">
|
||||||
|
<input type="number" class="param-val" id="vHopSize" value="{{ config.hop_size }}" onchange="syncSlider(this, 'pHopSize')">
|
||||||
|
<span class="param-unit">ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="param-row">
|
||||||
|
<span class="param-label">保留静音</span>
|
||||||
|
<input type="range" id="pMaxSilKept" min="100" max="10000" step="100" value="{{ config.max_sil_kept }}" oninput="syncParam(this, 'vMaxSilKept')">
|
||||||
|
<input type="number" class="param-val" id="vMaxSilKept" value="{{ config.max_sil_kept }}" onchange="syncSlider(this, 'pMaxSilKept')">
|
||||||
|
<span class="param-unit">ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="action-btn analyze" id="btnAnalyze" onclick="doAnalyze()" disabled>
|
||||||
|
<i class="fas fa-search"></i> 分析
|
||||||
|
</button>
|
||||||
|
<button class="action-btn slice" id="btnSlice" onclick="doSlice()" disabled>
|
||||||
|
<i class="fas fa-scissors"></i> 开始切割
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-panel">
|
||||||
|
<div class="right-header">
|
||||||
|
<h2 id="rightTitle"><i class="fas fa-check-circle" style="color:#0078d4;margin-right:6px;"></i>切片结果</h2>
|
||||||
|
</div>
|
||||||
|
<div class="right-body" id="rightBody">
|
||||||
|
<div class="empty-state" id="emptyState">
|
||||||
|
<i class="fas fa-scissors"></i>
|
||||||
|
<p>上传音频文件后开始分析</p>
|
||||||
|
</div>
|
||||||
|
<div id="resultArea" style="display:none;">
|
||||||
|
<div class="top-actions" id="topActions" style="display:none;">
|
||||||
|
<button class="top-btn primary" onclick="downloadAll()">
|
||||||
|
<i class="fas fa-folder-plus"></i> 打包下载全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="status-text" id="statusText"></div>
|
||||||
|
<div class="progress-bar" id="progressWrap" style="display:none;">
|
||||||
|
<div class="progress-fill" id="progressFill" style="width:0%;"></div>
|
||||||
|
</div>
|
||||||
|
<div id="sliceList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let taskId = null;
|
||||||
|
let pollTimer = null;
|
||||||
|
|
||||||
|
function showMessage(text, type, duration) {
|
||||||
|
type = type || 'info'; duration = duration || 3000;
|
||||||
|
var t = document.createElement('div');
|
||||||
|
t.className = 'toast ' + type;
|
||||||
|
t.textContent = text;
|
||||||
|
document.getElementById('toastContainer').appendChild(t);
|
||||||
|
setTimeout(function() { t.style.opacity = '0'; t.style.transition = 'opacity 0.3s'; setTimeout(function(){ t.remove(); }, 300); }, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 参数同步
|
||||||
|
function syncParam(slider, valId) {
|
||||||
|
document.getElementById(valId).value = slider.value;
|
||||||
|
}
|
||||||
|
function syncSlider(input, sliderId) {
|
||||||
|
document.getElementById(sliderId).value = input.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSettings() {
|
||||||
|
return {
|
||||||
|
threshold: parseInt(document.getElementById('vThreshold').value),
|
||||||
|
min_length: parseInt(document.getElementById('vMinLength').value),
|
||||||
|
min_interval: parseInt(document.getElementById('vMinInterval').value),
|
||||||
|
hop_size: parseInt(document.getElementById('vHopSize').value),
|
||||||
|
max_sil_kept: parseInt(document.getElementById('vMaxSilKept').value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
try {
|
||||||
|
var res = await fetch('/audio-slicer/config', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(getSettings())
|
||||||
|
});
|
||||||
|
var data = await res.json();
|
||||||
|
if (data.success) showMessage('参数已保存', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('保存失败: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDur(sec) {
|
||||||
|
if (sec < 60) return sec.toFixed(1) + 's';
|
||||||
|
var m = Math.floor(sec / 60);
|
||||||
|
var s = (sec % 60).toFixed(1);
|
||||||
|
return m + 'm ' + s + 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传区拖拽
|
||||||
|
var uploadArea = document.getElementById('uploadArea');
|
||||||
|
uploadArea.addEventListener('dragover', function(e) { e.preventDefault(); this.classList.add('dragover'); });
|
||||||
|
uploadArea.addEventListener('dragleave', function() { this.classList.remove('dragover'); });
|
||||||
|
uploadArea.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault(); this.classList.remove('dragover');
|
||||||
|
var files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) uploadFile(files[0]);
|
||||||
|
});
|
||||||
|
document.getElementById('audioInput').addEventListener('change', function() {
|
||||||
|
if (this.files.length > 0) uploadFile(this.files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function uploadFile(file) {
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('audio', file);
|
||||||
|
document.getElementById('uploadLabel').textContent = '上传中...';
|
||||||
|
document.getElementById('uploadFilename').style.display = 'none';
|
||||||
|
document.getElementById('btnAnalyze').disabled = true;
|
||||||
|
document.getElementById('btnSlice').disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = await fetch('/audio-slicer/upload', { method: 'POST', body: fd });
|
||||||
|
var data = await res.json();
|
||||||
|
if (!data.success) {
|
||||||
|
showMessage(data.error, 'error');
|
||||||
|
document.getElementById('uploadLabel').textContent = '点击或拖拽音频文件到此处';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
taskId = data.task_id;
|
||||||
|
var info = data.info;
|
||||||
|
document.getElementById('uploadLabel').textContent = '已选择文件';
|
||||||
|
document.getElementById('uploadFilename').textContent = data.filename;
|
||||||
|
document.getElementById('uploadFilename').style.display = 'block';
|
||||||
|
document.getElementById('audioInfo').style.display = 'grid';
|
||||||
|
document.getElementById('infoSr').textContent = info.sample_rate + ' Hz';
|
||||||
|
document.getElementById('infoCh').textContent = info.channels === 1 ? '单声道' : info.channels === 2 ? '立体声' : info.channels + '声道';
|
||||||
|
document.getElementById('infoDur').textContent = formatDur(info.duration);
|
||||||
|
document.getElementById('infoName').textContent = data.filename;
|
||||||
|
document.getElementById('btnAnalyze').disabled = false;
|
||||||
|
// 重置右侧面板
|
||||||
|
document.getElementById('emptyState').style.display = 'flex';
|
||||||
|
document.getElementById('resultArea').style.display = 'none';
|
||||||
|
document.getElementById('sliceList').innerHTML = '';
|
||||||
|
document.getElementById('topActions').style.display = 'none';
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('上传失败: ' + e.message, 'error');
|
||||||
|
document.getElementById('uploadLabel').textContent = '点击或拖拽音频文件到此处';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doAnalyze() {
|
||||||
|
if (!taskId) return;
|
||||||
|
var btn = document.getElementById('btnAnalyze');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-refresh"></i> 分析中...';
|
||||||
|
document.getElementById('emptyState').style.display = 'none';
|
||||||
|
document.getElementById('resultArea').style.display = 'block';
|
||||||
|
document.getElementById('statusText').textContent = '正在分析音频...';
|
||||||
|
document.getElementById('sliceList').innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var params = Object.assign({ task_id: taskId }, getSettings());
|
||||||
|
var res = await fetch('/audio-slicer/analyze', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(params)
|
||||||
|
});
|
||||||
|
var data = await res.json();
|
||||||
|
if (!data.success) {
|
||||||
|
showMessage(data.error, 'error');
|
||||||
|
document.getElementById('statusText').textContent = '分析失败: ' + data.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('statusText').textContent = '分析完成: 检测到 ' + data.count + ' 个片段';
|
||||||
|
renderPreview(data.preview, data.sample_rate);
|
||||||
|
document.getElementById('btnSlice').disabled = false;
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('分析失败: ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-search"></i> 分析';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview(preview, sr) {
|
||||||
|
var html = '<table class="preview-table"><thead><tr>'
|
||||||
|
+ '<th>#</th><th>时长</th><th>采样数</th></tr></thead><tbody>';
|
||||||
|
for (var i = 0; i < preview.length; i++) {
|
||||||
|
var p = preview[i];
|
||||||
|
html += '<tr><td>' + (i + 1) + '</td><td>' + formatDur(p.duration) + '</td><td>' + p.samples.toLocaleString() + '</td></tr>';
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
document.getElementById('sliceList').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSlice() {
|
||||||
|
if (!taskId) return;
|
||||||
|
var btn = document.getElementById('btnSlice');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-refresh"></i> 切割中...';
|
||||||
|
document.getElementById('btnAnalyze').disabled = true;
|
||||||
|
document.getElementById('progressWrap').style.display = 'block';
|
||||||
|
document.getElementById('progressFill').style.width = '0%';
|
||||||
|
document.getElementById('statusText').textContent = '正在切割...';
|
||||||
|
document.getElementById('topActions').style.display = 'none';
|
||||||
|
document.getElementById('sliceList').innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = await fetch('/audio-slicer/slice', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ task_id: taskId })
|
||||||
|
});
|
||||||
|
var data = await res.json();
|
||||||
|
if (!data.success) {
|
||||||
|
showMessage(data.error, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 开始轮询
|
||||||
|
startPoll();
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('请求失败: ' + e.message, 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
|
||||||
|
document.getElementById('btnAnalyze').disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPoll() {
|
||||||
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
pollTimer = setInterval(async function() {
|
||||||
|
try {
|
||||||
|
var res = await fetch('/audio-slicer/status/' + taskId);
|
||||||
|
var data = await res.json();
|
||||||
|
if (!data.success) return;
|
||||||
|
document.getElementById('progressFill').style.width = data.progress + '%';
|
||||||
|
document.getElementById('statusText').textContent = '切割进度: ' + data.progress + '%';
|
||||||
|
|
||||||
|
if (data.status === 'done') {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
document.getElementById('statusText').textContent = '切割完成! 共 ' + data.count + ' 个片段';
|
||||||
|
document.getElementById('progressWrap').style.display = 'none';
|
||||||
|
renderSlices(data.slices);
|
||||||
|
document.getElementById('topActions').style.display = 'flex';
|
||||||
|
document.getElementById('btnSlice').disabled = false;
|
||||||
|
document.getElementById('btnSlice').innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
|
||||||
|
document.getElementById('btnAnalyze').disabled = false;
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
document.getElementById('statusText').textContent = '切割失败: ' + data.error;
|
||||||
|
document.getElementById('progressWrap').style.display = 'none';
|
||||||
|
document.getElementById('btnSlice').disabled = false;
|
||||||
|
document.getElementById('btnSlice').innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
|
||||||
|
document.getElementById('btnAnalyze').disabled = false;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSlices(slices) {
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < slices.length; i++) {
|
||||||
|
var s = slices[i];
|
||||||
|
html += '<div class="slice-row">'
|
||||||
|
+ '<span class="slice-idx">' + (i + 1) + '</span>'
|
||||||
|
+ '<span class="slice-name" title="' + s.filename + '">' + s.filename + '</span>'
|
||||||
|
+ '<span class="slice-dur">' + formatDur(s.duration) + '</span>'
|
||||||
|
+ '<div class="slice-actions">'
|
||||||
|
+ '<button class="slice-btn play-btn" onclick="playSlice(' + i + ')" title="播放"><i class="fas fa-play"></i></button>'
|
||||||
|
+ '<button class="slice-btn" onclick="downloadSlice(' + i + ')" title="下载"><i class="fas fa-file"></i></button>'
|
||||||
|
+ '</div></div>';
|
||||||
|
}
|
||||||
|
document.getElementById('sliceList').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentAudio = null;
|
||||||
|
function playSlice(index) {
|
||||||
|
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
|
||||||
|
var audio = new Audio('/audio-slicer/download/' + taskId + '/' + index);
|
||||||
|
audio.play();
|
||||||
|
currentAudio = audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadSlice(index) {
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = '/audio-slicer/download/' + taskId + '/' + index;
|
||||||
|
a.download = '';
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadAll() {
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = '/audio-slicer/download-all/' + taskId;
|
||||||
|
a.download = '';
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -119,6 +119,13 @@
|
|||||||
z-index: 9999; pointer-events: none;
|
z-index: 9999; pointer-events: none;
|
||||||
}
|
}
|
||||||
.toast.show { opacity: 1; }
|
.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; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -95,6 +95,10 @@
|
|||||||
}
|
}
|
||||||
.btn-cp:hover { background: #006cbd; }
|
.btn-cp:hover { background: #006cbd; }
|
||||||
.btn-cp.copied { background: #2e7d32; }
|
.btn-cp.copied { background: #2e7d32; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container { padding: 14px 16px 28px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -340,6 +340,14 @@
|
|||||||
background: #fff; color: #333; transition: border-color 0.15s;
|
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); }
|
.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; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -445,6 +445,13 @@
|
|||||||
max-height: 50px;
|
max-height: 50px;
|
||||||
opacity: 1;
|
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; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -219,6 +219,13 @@
|
|||||||
}
|
}
|
||||||
.toast.show { opacity: 1; }
|
.toast.show { opacity: 1; }
|
||||||
.toast.error { background: #c62828; }
|
.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; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -94,6 +94,13 @@
|
|||||||
.match-count {
|
.match-count {
|
||||||
font-size: 12px; color: #999; margin-bottom: 16px;
|
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; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -125,6 +125,73 @@
|
|||||||
.sidebar-search-input input::placeholder { color: #bbb; }
|
.sidebar-search-input input::placeholder { color: #bbb; }
|
||||||
.nav-item.hidden { display: none; }
|
.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 {
|
.main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -424,18 +491,68 @@
|
|||||||
.status-badge.s4xx { background: #fff3e0; color: #e65100; }
|
.status-badge.s4xx { background: #fff3e0; color: #e65100; }
|
||||||
.status-badge.s5xx { background: #fce4ec; color: #c62828; }
|
.status-badge.s5xx { background: #fce4ec; color: #c62828; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
/* 汉堡按钮 */
|
||||||
.sidebar {
|
.hamburger {
|
||||||
display: none;
|
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 {
|
||||||
|
z-index: 1000;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
.sidebar-overlay.open { display: block; }
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
margin-left: 0;
|
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; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- 侧边栏遮罩 -->
|
||||||
|
<div class="sidebar-overlay" id="sidebar-overlay" onclick="closeSidebar()"></div>
|
||||||
<!-- 侧边栏 -->
|
<!-- 侧边栏 -->
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
@@ -452,11 +569,11 @@
|
|||||||
|
|
||||||
<nav class="nav-section">
|
<nav class="nav-section">
|
||||||
<div class="nav-section-title">概览</div>
|
<div class="nav-section-title">概览</div>
|
||||||
<a class="nav-item active" id="nav-dashboard" onclick="showDashboard()">
|
<a class="nav-item active" id="nav-dashboard" data-feature="dashboard" onclick="showDashboard()">
|
||||||
<i class="fas fa-chart-bar"></i>
|
<i class="fas fa-chart-bar"></i>
|
||||||
仪表盘
|
仪表盘
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-url="/data-manage" onclick="loadTool(this, '/data-manage')">
|
<a class="nav-item" data-url="/data-manage" data-feature="data-manage" onclick="loadTool(this, '/data-manage')">
|
||||||
<i class="fas fa-database"></i>
|
<i class="fas fa-database"></i>
|
||||||
数据管理
|
数据管理
|
||||||
</a>
|
</a>
|
||||||
@@ -465,79 +582,110 @@
|
|||||||
<nav class="nav-section">
|
<nav class="nav-section">
|
||||||
<div class="nav-section-title">工具</div>
|
<div class="nav-section-title">工具</div>
|
||||||
|
|
||||||
<a class="nav-item" data-url="/pin-tu" onclick="loadTool(this, '/pin-tu')">
|
<a class="nav-item" data-url="/pin-tu" data-feature="pin-tu" onclick="loadTool(this, '/pin-tu')">
|
||||||
<i class="fas fa-images"></i>
|
<i class="fas fa-images"></i>
|
||||||
路径文件查阅器
|
路径文件查阅器
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-url="/down-video" onclick="loadTool(this, '/down-video')">
|
<a class="nav-item" data-url="/down-video" data-feature="down-video" onclick="loadTool(this, '/down-video')">
|
||||||
<i class="fas fa-video"></i>
|
<i class="fas fa-video"></i>
|
||||||
XBIY视频下载器
|
XBIY视频下载器
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-url="/content-tag" onclick="loadTool(this, '/content-tag')">
|
<a class="nav-item" data-url="/content-tag" data-feature="content-tag" onclick="loadTool(this, '/content-tag')">
|
||||||
<i class="fas fa-tags"></i>
|
<i class="fas fa-tags"></i>
|
||||||
AI生成文章标签
|
AI生成文章标签
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-url="/stt" onclick="loadTool(this, '/stt')">
|
<a class="nav-item" data-url="/audio-slicer" data-feature="audio-slicer" onclick="loadTool(this, '/audio-slicer')">
|
||||||
|
<i class="fas fa-scissors"></i>
|
||||||
|
AS 音频分割
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-url="/stt" data-feature="stt" onclick="loadTool(this, '/stt')">
|
||||||
<i class="fas fa-microphone"></i>
|
<i class="fas fa-microphone"></i>
|
||||||
STT 语音转文字
|
STT 语音转文字
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-url="/rvc" onclick="loadTool(this, '/rvc')">
|
<a class="nav-item" data-url="/uvr-sep" data-feature="uvr-sep" onclick="loadTool(this, '/uvr-sep')">
|
||||||
<i class="fas fa-microphone"></i>
|
<i class="fas fa-exchange-alt"></i>
|
||||||
RVC 语音音色变换
|
UVR 人声分离
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-url="/ai-dubbing" onclick="loadTool(this, '/ai-dubbing')">
|
<a class="nav-item" data-url="/rvc" data-feature="rvc" onclick="loadTool(this, '/rvc')">
|
||||||
|
<i class="fas fa-microphone"></i>
|
||||||
|
RVC 音色转换
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-url="/ai-dubbing" data-feature="ai-dubbing" onclick="loadTool(this, '/ai-dubbing')">
|
||||||
<i class="fas fa-microphone"></i>
|
<i class="fas fa-microphone"></i>
|
||||||
GPT-SoVITS (引擎版)
|
GPT-SoVITS (引擎版)
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="nav-item" data-url="/fen-ci" onclick="loadTool(this, '/fen-ci')">
|
|
||||||
|
<a class="nav-item" data-url="/sovits-tts" data-feature="sovits-tts" onclick="loadTool(this, '/sovits-tts')">
|
||||||
|
<i class="fas fa-microphone"></i>
|
||||||
|
GPT-SoVITS (接口版)
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-url="/fen-ci" data-feature="fen-ci" onclick="loadTool(this, '/fen-ci')">
|
||||||
<i class="fas fa-scissors"></i>
|
<i class="fas fa-scissors"></i>
|
||||||
Jieba 分词网页版
|
Jieba 分词网页版
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-url="/base64-de-in-code" onclick="loadTool(this, '/base64-de-in-code')">
|
<a class="nav-item" data-url="/base64-de-in-code" data-feature="base64" onclick="loadTool(this, '/base64-de-in-code')">
|
||||||
<i class="fas fa-lock"></i>
|
<i class="fas fa-lock"></i>
|
||||||
Base64 编码/解码
|
Base64 编码/解码
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-url="/json-format" onclick="loadTool(this, '/json-format')">
|
<a class="nav-item" data-url="/json-format" data-feature="json-format" onclick="loadTool(this, '/json-format')">
|
||||||
<i class="fas fa-code"></i>
|
<i class="fas fa-code"></i>
|
||||||
JSON 美化/压缩
|
JSON 美化/压缩
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="nav-item" data-url="/http-status" onclick="loadTool(this, '/http-status')">
|
<a class="nav-item" data-url="/http-status" data-feature="http-status" onclick="loadTool(this, '/http-status')">
|
||||||
<i class="fas fa-info-circle"></i>
|
<i class="fas fa-info-circle"></i>
|
||||||
HTTP 状态码查询
|
HTTP 状态码查询
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-url="/url-parser" onclick="loadTool(this, '/url-parser')">
|
<a class="nav-item" data-url="/url-parser" data-feature="url-parser" onclick="loadTool(this, '/url-parser')">
|
||||||
<i class="fas fa-link"></i>
|
<i class="fas fa-link"></i>
|
||||||
URL 路径解析器
|
URL 路径解析器
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-url="/chmod-calc" onclick="loadTool(this, '/chmod-calc')">
|
<a class="nav-item" data-url="/chmod-calc" data-feature="chmod-calc" onclick="loadTool(this, '/chmod-calc')">
|
||||||
<i class="fas fa-key"></i>
|
<i class="fas fa-key"></i>
|
||||||
Chmod 计算器
|
Chmod 计算器
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-url="/token-gen" onclick="loadTool(this, '/token-gen')">
|
<a class="nav-item" data-url="/token-gen" data-feature="token-gen" onclick="loadTool(this, '/token-gen')">
|
||||||
<i class="fas fa-lock"></i>
|
<i class="fas fa-lock"></i>
|
||||||
Token 随机生成器
|
Token 随机生成器
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-url="/qr-code" onclick="loadTool(this, '/qr-code')">
|
<a class="nav-item" data-url="/qr-code" data-feature="qr-code" onclick="loadTool(this, '/qr-code')">
|
||||||
<i class="fas fa-qrcode"></i>
|
<i class="fas fa-qrcode"></i>
|
||||||
Qr-code 生成器
|
Qr-code 生成器
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="nav-section-title" style="margin-top: 20px">不常用</div>
|
|
||||||
<a class="nav-item" data-url="/sovits-tts" onclick="loadTool(this, '/sovits-tts')">
|
|
||||||
<i class="fas fa-microphone"></i>
|
|
||||||
GPT-SoVITS (接口版)
|
|
||||||
</a>
|
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button class="sidebar-settings-btn" onclick="openFeatModal()">
|
||||||
|
<i class="fas fa-eye"></i> 功能显示设置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<!-- 功能显示设置弹窗 -->
|
||||||
|
<div class="feat-modal-overlay" id="featModalOverlay" onclick="closeFeatModal(event)">
|
||||||
|
<div class="feat-modal" onclick="event.stopPropagation()">
|
||||||
|
<div class="feat-modal-header">
|
||||||
|
<h3><i class="fas fa-eye" style="margin-right:6px;color:#999;"></i>功能显示设置</h3>
|
||||||
|
<button class="feat-modal-close" onclick="closeFeatModal()"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="feat-modal-body" id="featModalBody"></div>
|
||||||
|
<div class="feat-modal-footer">
|
||||||
|
<button class="feat-btn-cancel" onclick="closeFeatModal()">取消</button>
|
||||||
|
<button class="feat-btn-save" onclick="saveFeatSettings()">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
<!-- 主内容区 -->
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar-title" id="topbar-title">
|
<div class="topbar-title" id="topbar-title">
|
||||||
|
<button class="hamburger" id="hamburger" onclick="toggleSidebar()">
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
</button>
|
||||||
<button class="back-btn" id="back-btn" onclick="goBack()">
|
<button class="back-btn" id="back-btn" onclick="goBack()">
|
||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
返回
|
返回
|
||||||
@@ -688,6 +836,7 @@
|
|||||||
<i class="fas fa-list"></i> 最近调用记录
|
<i class="fas fa-list"></i> 最近调用记录
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-body" style="max-height:300px; overflow-y:auto;">
|
<div class="chart-body" style="max-height:300px; overflow-y:auto;">
|
||||||
|
<div class="data-table-wrap">
|
||||||
<table class="data-table" id="recent-table">
|
<table class="data-table" id="recent-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -702,6 +851,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -712,12 +862,23 @@
|
|||||||
const dashboardHTML = document.getElementById('dashboard').outerHTML;
|
const dashboardHTML = document.getElementById('dashboard').outerHTML;
|
||||||
let charts = {};
|
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() {
|
function disposeCharts() {
|
||||||
Object.values(charts).forEach(c => c && c.dispose());
|
Object.values(charts).forEach(c => c && c.dispose());
|
||||||
charts = {};
|
charts = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDashboard() {
|
function showDashboard() {
|
||||||
|
closeSidebar();
|
||||||
document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
|
document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
|
||||||
document.getElementById('nav-dashboard').classList.add('active');
|
document.getElementById('nav-dashboard').classList.add('active');
|
||||||
document.getElementById('title-text').textContent = '仪表盘';
|
document.getElementById('title-text').textContent = '仪表盘';
|
||||||
@@ -732,6 +893,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadTool(element, url) {
|
function loadTool(element, url) {
|
||||||
|
closeSidebar();
|
||||||
disposeCharts();
|
disposeCharts();
|
||||||
document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
|
document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
|
||||||
element.classList.add('active');
|
element.classList.add('active');
|
||||||
@@ -1180,6 +1342,87 @@
|
|||||||
Object.values(charts).forEach(c => c && c.resize());
|
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 += '<div class="feat-group-title">' + currentSection + '</div>';
|
||||||
|
}
|
||||||
|
var checked = hidden.indexOf(f.id) === -1 ? 'checked' : '';
|
||||||
|
html += '<label class="feat-item"><input type="checkbox" data-feature-id="' + f.id + '" ' + checked + '><span>' + f.name + '</span></label>';
|
||||||
|
});
|
||||||
|
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();
|
loadDashboard();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -100,6 +100,13 @@
|
|||||||
display: none; align-items: center; gap: 6px;
|
display: none; align-items: center; gap: 6px;
|
||||||
}
|
}
|
||||||
.err-bar.on { display: flex; }
|
.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; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -50,6 +50,10 @@
|
|||||||
border-radius: 6px; padding: 8px 12px; font-size: 13px;
|
border-radius: 6px; padding: 8px 12px; font-size: 13px;
|
||||||
text-align: center; margin-bottom: 16px;
|
text-align: center; margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.login-card { width: 92vw; padding: 28px 20px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -103,6 +103,10 @@
|
|||||||
}
|
}
|
||||||
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
|
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
|
||||||
.btn-outline:hover { background: #f5f5f5; color: #333; }
|
.btn-outline:hover { background: #f5f5f5; color: #333; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container { padding: 14px 16px 28px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -217,6 +217,14 @@
|
|||||||
.toast.error { background: #fce4ec; color: #c62828; border: 1px solid #f8bbd0; }
|
.toast.error { background: #fce4ec; color: #c62828; border: 1px solid #f8bbd0; }
|
||||||
.toast.info { background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; }
|
.toast.info { background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; }
|
||||||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
@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; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -328,6 +328,14 @@
|
|||||||
@keyframes slideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
|
@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); } }
|
@keyframes slideOut { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-8px); } }
|
||||||
.message.removing { animation: slideOut 0.2s ease forwards; }
|
.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; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
}
|
}
|
||||||
/* 左侧面板 */
|
/* 左侧面板 */
|
||||||
.left-panel {
|
.left-panel {
|
||||||
width: 420px;
|
max-width: 420px; width: 100%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -316,6 +316,14 @@
|
|||||||
padding: 12px 20px; border-top: 1px solid #f0f0f0;
|
padding: 12px 20px; border-top: 1px solid #f0f0f0;
|
||||||
display: flex; justify-content: flex-end; gap: 8px;
|
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; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -96,6 +96,10 @@
|
|||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
}
|
}
|
||||||
.token-len { margin-top: 10px; font-size: 12px; color: #bbb; }
|
.token-len { margin-top: 10px; font-size: 12px; color: #bbb; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container { padding: 14px 16px 28px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -134,6 +134,10 @@
|
|||||||
}
|
}
|
||||||
.empty-state i { font-size: 40px; margin-bottom: 12px; display: block; }
|
.empty-state i { font-size: 40px; margin-bottom: 12px; display: block; }
|
||||||
.empty-state p { font-size: 13px; }
|
.empty-state p { font-size: 13px; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container { padding: 14px 16px 28px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
1261
flask-dev-api/templates/uvr_sep.html
Normal file
1261
flask-dev-api/templates/uvr_sep.html
Normal file
File diff suppressed because it is too large
Load Diff
191
flask-dev-api/utils/audio_slicer_core.py
Normal file
191
flask-dev-api/utils/audio_slicer_core.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user