Skill: nabian1990amber-cmd/sound-split (version 1.0.0)

--- name: sound-split description: 智能音频分离工具，一键将任意音频或视频分离出人声、伴奏、鼓、贝斯、钢琴等独立音轨。适用于音乐人翻唱伴奏提取、歌曲 Remix 制作、播客人声降噪、视频配乐替换、音乐教学素材准备等场景。当用户需要「分离人声和伴奏」「提取伴奏」「去除人声」「拆分音轨」「vocal split」「stem splitter」等操作时触发此技能。 --- # Sound Split — AI 智能音频分离工具 ## 一句话说明 上传一段音频或视频，AI 自动将其中的**人声、伴奏、鼓点、贝斯、钢琴**等音轨一一分离出来，支持在线预览每条音轨并单独下载。 --- ## 附件文件说明 本 Skill 附带完整的核心实现代码（位于 `rules/` 目录）： | 文件 | 内容 | 说明 | |------|------|------| | `rules/server.py` | FastAPI Web 服务主程序（498 行）| 全部 API 路由、后台分离逻辑、进度追踪、历史管理 | | `rules/app-page.html` | 工具主操作页面（59KB）| 上传 + 配置 + 分离 + 结果展示的完整 UI | | `rules/editor-page.html` | 音频裁剪编辑器（31KB）| 波形可视化 + 时间选区 + 片段导出 | > 这些文件是纯文本，可直接复制到本地使用。部署方式见下方「使用流程」。 --- ## 核心能力一览 | 能力 | 说明 | 典型用途 | |------|------|----------| | **人声 / 伴奏分离** | 从歌曲中提取纯净人声或纯伴奏 | 翻唱、Remix、混音 | | **多轨分轨提取** | 一首歌拆为 2~5 条独立音轨 | 编曲学习、乐队分谱、混音练习 | | **视频直接处理** | 上传视频自动提取音频并分离 | 视频配乐替换、BGM 提取 | | **实时进度反馈** | 分离过程中实时进度条 | 了解处理状态，无需盲等 | | **片段裁剪下载** | 对分离后的音轨精确裁剪起止时间 | 只导出需要的片段 | | **历史记录管理** | 保留完整任务历史，随时回溯下载 | 重复使用之前的结果 | --- ## 前置条件 ### 环境要求 | 依赖项 | 版本要求 | 安装方式 | 必要性 | |--------|----------|----------|--------| | Python | ≥ 3.8 | 系统自带或 [python.org](https://python.org) 下载 | 必须 | | FFmpeg | 任意稳定版 | `brew install ffmpeg`（Mac）/ `apt install ffmpeg`（Linux） | 必须，用于音频提取与转码 | ### Python 包安装（必须执行一次） ```bash pip install fastapi uvicorn demucs ffmpeg-python ``` > ⚠️ **首次运行时会自动下载 AI 分离模型**（约 300~500MB），请确保网络通畅。下载完成后会缓存到本地 `~/.cache/demucs/` 目录，后续运行不再重复下载。 ### 验证环境是否就绪 ```bash # 1. 验证 FFmpeg 安装 ffmpeg -version | head -1 # 2. 验证 demucs 可用（首次会触发模型下载） python3 -m demucs --version # 3. 如果以上两个命令都正常输出，说明环境已就绪 ``` --- ## 使用流程（完整操作步骤） ### 第一步：启动服务 ```bash cd <skill_path>/audio-separator python3 server.py ``` **预期结果**：终端显示以下内容说明启动成功： ``` ╔══════════════════════════════════════════════╗ ║ 🎵 音频分离工具 - AI 智能分轨 ║ ║ http://127.0.0.1:8765 ║ ╚══════════════════════════════════════════════╝ [init] 已从历史记录恢复 N 个任务 ``` 服务监听地址：**`http://127.0.0.1:8765`** **失败处理**： - 若报错 `ModuleNotFoundError: No module named 'demucs'` → 先执行 `pip install demucs` 再重启 - 若报错 `ffmpeg not found` → 先安装 FFmpeg 再重启 - 若端口 8765 被占用 → 终端执行 `lsof -i :8765 | grep LISTEN | awk '{print $2}' | xargs kill -9` 释放端口后重试 --- ### 第二步：打开操作页面 浏览器访问以下地址： | 页面 | 地址 | 功能 | |------|------|------| | **工具主页面（推荐）** | `http://127.0.0.1:8765/app.html` | 上传文件 + 选择模式 + 分离操作 + 预览下载 | | **音频编辑器** | `http://127.0.0.1:8765/editor` | 对已完成分离的音轨进行裁剪，导出指定时间段 | | **产品介绍页** | `http://127.0.0.1:8765/` | 产品功能展示与 Demo 试听（非必需） | > 💡 **推荐直接使用 `/app.html` 主页面**，所有核心操作都在一个界面完成。 --- ### 第三步：上传文件并配置参数 在主页面（app.html）上按以下顺序操作： 1. **点击上传区域或拖拽文件到虚线框内** - 支持格式：MP3, WAV, FLAC, OGG, M4A（音频）；MP4, MOV, AVI, MKV, FLV（视频） - 文件大小建议 ≤ 100MB - 音频时长建议 ≤ 10 分钟（过长会显著增加处理时间） 2. **选择分离轨道数** - **2 轨道**（默认）：输出「人声」+「伴奏」，适合翻唱、去人声场景 - **4 轨道**：输出「人声」+「鼓」+「贝斯」+「其他」，适合编曲分析、混音练习 - **5 轨道**：输出「人声」+「鼓」+「贝斯」+「钢琴」+「其他」，适合专业音乐制作 3. **选择处理精度** - **推荐模式**（默认）：均衡质量与速度，适合大多数场景 - **高品质模式**：更精细的分离效果，处理时间稍长 - **经典模式**：基础分离能力，速度最快 - **增强模式**：针对特定类型优化 4. **点击「开始分离」按钮** --- ### 第四步：等待处理完成 - 页面会自动显示**进度条**（0% ~ 100%） - 进度阶段说明： | 进度范围 | 含义 | 说明 | |----------|------|------| | 0~15% | 初始化 | 视频文件正在提取音频流；音频文件跳过此阶段 | | 15~20% | 加载模型 | AI 模型加载到内存（仅首次较慢，后续有缓存加速） | | 20~85% | 正在分离 | AI 逐段分析音频并拆分音轨，耗时取决于文件长度 | | 85~100% | 整理输出 | 将分离结果整理为标准 MP3 文件 | - **典型耗时参考**： - 3 分钟歌曲（2 轨道）：约 30~60 秒 - 5 分钟歌曲（5 轨道）：约 1~3 分钟 - 10 分钟视频（4 轨道）：约 3~8 分钟 - > 10 分钟的文件不推荐，可能需要 10 分钟以上 - **异常情况处理**： - 进度卡住超过 5 分钟无变化 → 刷新页面后在历史记录中查看任务是否已完成 - 显示错误信息 → 查看终端 server.py 的错误日志，常见原因：文件损坏、磁盘空间不足、FFmpeg 未安装 --- ### 第五步：预览与下载 分离完成后页面自动展示结果： **每条音轨提供以下操作：** | 操作 | 说明 | |------|------| | ▶ 播放按钮 | 点击即时在浏览器中试听该音轨效果 | | 📥 下载按钮 | 下载该音轨的 MP3 文件（320kbps 高品质） | | ✂️ 裁剪 | 跳转到编辑器，选择起止时间后导出指定片段 | **输出文件位置**：`<skill_path>/audio-separator/outputs/{task_id}/` 下包含各音轨的 MP3 文件 **输出文件命名规则**： | 轨道数 | 输出文件名 | |--------|-----------| | 2stems | `vocals.mp3`（人声）、`accompaniment.mp3`（伴奏）| | 4stems | `vocals.mp3`、`drums.mp3`、`bass.mp3`、`other.mp3` | | 5stems | `vocals.mp3`、`drums.mp3`、`bass.mp3`、`piano.mp3`、`other.mp3` | --- ## API 接口详细说明 如需通过程序调用（而非浏览器界面），本工具提供完整的 RESTful API： ### 接口清单总览 | 接口路径 | 方法 | 功能 | |----------|------|------| | `/api/separate` | POST | 上传文件并启动分离任务 | | `/api/status/{task_id}` | GET | 查询任务进度和结果 | | `/api/download/{task_id}/{track_name}` | GET | 下载指定音轨文件 | | `/api/trim` | POST | 裁剪音轨片段 | | `/api/history` | GET | 获取历史任务列表 | | `/api/cleanup/{task_id}` | DELETE | 清理任务的输出文件 | --- ### 1. 上传并分离 — POST /api/separate **请求格式**： ``` POST /api/separate Content-Type: multipart/form-data ``` **请求参数**： | 参数名 | 类型 | 必填 | 默认值 | 说明 | |--------|------|------|--------|------| | file | File | ✅ 是 | 无 | 要处理的音频或视频文件 | | model | string | 否 | htdemucs | 处理精度模式（见下方可选值表）| | stems | string | 否 | 2stems | 输出轨道数（2stems / 4stems / 5stems）| **model 可选值**： | model 值 | 效果 | |----------|------| | htdemucs | 推荐，均衡质量与速度 | | htdemucs_ft | 高品质，更精细分离 | | demucs | 经典模式，速度最快 | | mdx | 人声增强模式 | | mdx_extra | 全面增强模式 | **成功响应**（HTTP 200）： ```json { "task_id": "a1b2c3d4e5f6", "status": "pending", "message": "任务已提交，请用 task_id 轮询进度" } ``` **错误响应**： - HTTP 400：`不支持的模式: xxx` 或 `不支持的轨道数: xxx` — 检查 model 和 stems 参数值 - HTTP 500：服务器内部错误 — 查看 server.py 终端日志 --- ### 2. 查询任务状态 — GET /api/status/{task_id} **请求**：`GET /api/status/a1b2c3d4e5f6` **成功响应示例**（处理中）： ```json { "status": "processing", "progress": 65, "message": "Separating... 65% (30 lines processed)", "model": "htdemucs", "stems": "2stems", "filename": "song.mp3", "result": null } ``` **成功响应示例**（完成）： ```json { "status": "completed", "progress": 100, "message": "Done!", "model": "htdemucs", "stems": "2stems", "filename": "song.mp3", "result": { "tracks": { "vocals": "vocals.mp3", "accompaniment": "accompaniment.mp3" }, "output_dir": "outputs/a1b2c3d4e5f6" } } ``` **状态字段含义**： | status 值 | 含义 | 建议操作 | |-----------|------|----------| | pending | 任务排队中等待开始 | 等待几秒后再次查询 | | processing | 正在分离中 | 每 2~3 秒轮询一次 progress | | completed | 分裂完成 | 用 tracks 字段的文件名去 download 接口下载 | | error | 处理出错 | 查看 message 字段获取具体错误信息 | **错误响应**：HTTP 404 `{ "detail": "任务不存在" }` — 检查 task_id 是否正确 --- ### 3. 下载音轨 — GET /api/download/{task_id}/{track_name} **请求示例**： ``` GET /api/download/a1b2c3d4e5f6/vocals GET /api/download/a1b2c3d4e5f6/accompaniment ``` **响应**：直接返回对应音轨的 MP3 文件（Content-Type: audio/mpeg） **错误响应**： - HTTP 404：任务目录不存在或音轨文件不存在 — 先确认任务已完成且 track_name 正确 --- ### 4. 裁剪音轨片段 — POST /api/trim **请求格式**： ``` POST /api/trim Content-Type: application/x-www-form-urlencoded ``` **请求参数**： | 参数名 | 类型 | 必填 | 说明 | |--------|------|------|------| | task_id | string | ✅ | 已完成的分离任务 ID | | track_name | string | ✅ | 要裁剪的音轨名称（vocals / accompaniment / drums 等）| | start_time | float | ✅ | 开始时间，单位秒（例如 30.5 表示 30 秒 500 毫秒）| | end_time | float | ✅ | 结束时间，单位秒（必须大于 start_time）| **请求示例**： ``` POST /api/trim task_id=a1b2c3d4e5f6&track_name=vocals&start_time=30&end_time=90 ``` **响应**：返回裁剪后的 MP3 文件，文件名格式为 `{track_name}_{起始时间}_{结束时间}.mp3`（例如 `vocals_0030_0130.mp3`） **错误响应**： - HTTP 400：缺少必要参数，或 `end_time <= start_time` - HTTP 404：task_id 或 track_name 对应的文件不存在 - HTTP 500：FFmpeg 裁剪过程出错 --- ### 5. 获取历史记录 — GET /api/history **响应示例**： ```json [ { "task_id": "a1b2c3d4e5f6", "filename": "song.mp3", "model": "htdemucs", "stems": "2stems", "status": "completed", "track_count": 2, "created_at": "2026-04-15T11:30:00", "exists": true } ] ``` 其中 `exists: true/false` 表示该任务的输出文件在磁盘上是否仍然存在（可能已被清理删除）。 --- ### 6. 清理任务文件 — DELETE /api/cleanup/{task_id} **响应**：`{ "message": "已清理 N 个文件" }` > ⚠️ 此操作会**永久删除**该任务的所有输出文件和原始上传文件，不可恢复。建议仅在确认不再需要结果时使用。 --- ## 项目文件结构 ``` sound-split/ ├── SKILL.md # 本说明文档 ├── audio-separator/ # ★ 主程序目录（核心实现全部在此） │ ├── server.py # ★ Web 服务主程序（FastAPI 应用） │ │ # 包含：API 路由、后台分离逻辑、 │ │ # 历史管理、进度追踪、FFmpeg 调用 │ ├── static/ │ │ ├── index.html # ★ 工具主操作页面（上传 + 配置 + 分离 + 结果展示） │ │ ├── editor.html # ★ 音频裁剪编辑器（波形可视化 + 时间选区 + 导出） │ │ └── landing.html # 产品介绍页（Demo 试听 + 场景说明） │ ├── uploads/ # 上传文件临时存储目录（运行时自动创建） │ ├── outputs/ # 分离结果输出目录（每个任务一个子文件夹） │ ├── trims/ # 裁剪片段临时存储目录 │ └── history.json # 任务历史持久化记录（最多保留 50 条） ├── scripts/ # 技能脚本目录 └── assets/ # 技能资源目录 ``` > 标记 ★ 的为核心实现文件，缺一不可。其余目录均为运行时自动创建，不需要手动准备。 --- ## 关键约束与注意事项 1. **隐私安全**：所有音频处理均在**本地机器**完成，数据不会上传到任何外部服务器。AI 模型仅在本地运行。 2. **资源需求**： - 内存：建议至少 4GB RAM（处理长音频时峰值可达 2~3GB） - 磁盘空间：模型缓存 ~500MB + 输出文件（每首歌曲约 10~50MB） - CPU/GPU：优先使用 GPU 加速（需 NVIDIA CUDA），无 GPU 时自动回退 CPU 运行（速度慢 3~10 倍） 3. **并发限制**：同一时间只处理一个任务。提交新任务时会排队等待当前任务完成。 4. **文件限制**： - 单文件大小：建议 ≤ 100MB - 单次时长：建议 ≤ 10 分钟 - 超出限制不会报错但可能导致处理极慢或内存不足 5. **输出格式**：固定输出 MP3（320kbps），不支持自定义码率或格式（如需 WAV 可通过 FFmpeg 手动转码）。 6. **服务生命周期**：关闭终端窗口或 Ctrl+C 会停止服务。已完成的任务结果保存在 outputs/ 和 history.json 中，重启服务后会自动恢复。 --- ## 常见问题排查 | 问题现象 | 可能原因 | 解决方法 | |----------|----------|----------| | 启动报 `ModuleNotFoundError` | Python 包未安装 | 执行 `pip install fastapi uvicorn demucs ffmpeg-python` | | 启动报 `ffmpeg not found` | FFmpeg 未安装 | Mac: `brew install ffmpeg`; Linux: `apt install ffmpeg` | | 首次运行非常慢 | 正在下载 AI 模型 | 仅首次发生，模型会缓存到 ~/.cache/demucs/ | | 上传后一直显示 pending | 后台进程未启动 | 刷新页面，查看 server.py 终端是否有错误输出 | | 进度卡在某个百分比不动 | 大文件正常处理中 | 5 分钟以上无变化则可能是死锁，重启服务重试 | | 下载的文件无法播放 | 分离过程中出错 | 检查 outputs/ 对应目录下的 .mp3 文件大小是否 > 0 | | 视频上传后只有画面没有声音 | 视频编码特殊导致 FFmpeg 提取失败 | 先用 FFmpeg 手动提取音频再上传：`ffmpeg -i video.mp4 -vn -acodec mp3 audio.mp3` | --- ## 适用场景举例 ### 场景一：翻唱伴奏提取 1. 打开 `http://127.0.0.1:8765/app.html` 2. 上传原唱歌曲 MP3 3. 选择 **2 轨道** + **推荐模式** 4. 点击「开始分离」，等待完成 5. 下载 `accompaniment.mp3` 作为伴奏使用 ### 场景二：视频 BGM 替换 1. 打开工具页面 2. 直接上传 MP4 视频文件（无需预先提取音频） 3. 选择 **2 轨道** 4. 完成后下载 `accompaniment.mp3`（原背景音乐） 5. 在视频剪辑软件中替换为自己的 BGM ### 场景三：编曲学习分析 1. 上传想学习的歌曲 2. 选择 **5 轨道** + **高品质模式**（更精准的乐器分离） 3. 分别下载 `drums.mp3`（鼓点）、`bass.mp3`（贝斯线）、`piano.mp3`（钢琴部分） 4. 反复聆听单轨，学习每种乐器的编排方式

path: rules/app-page.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
    <meta http-equiv="Pragma" content="no-cache">
    <meta http-equiv="Expires" content="0">
    <title>🎵 音频分离工具 - AI 智能分轨</title>
    <style>
        :root {
            --primary: #6366f1;
            --primary-dark: #4f46e5;
            --bg: #f5f5f7;
            --card-bg: #ffffff;
            --text: #1d1d1f;
            --text-dim: #86868b;
            --border: #e0e0e2;
            --accent-vocals: #a78bfa;
            --accent-accomp: #93c5fd;
            --accent-drums: #fca5a5;
            --accent-bass: #86efac;
            --accent-piano: #fde68a;
            --accent-other: #c4b5fd;
            --play-btn: #ff6b35;
            --trim-color: rgba(255, 107, 53, 0.18);
            --trim-border: #ff6b35;
        }
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, 'PingFang SC', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }

        .header { text-align: center; padding: 1.8rem 1rem 1.2rem; background: linear-gradient(135deg, #1e1b4b 0%, #312e81 40%, #4338ca 100%); color: white; }
        .header h1 { font-size: 1.8rem; font-weight: 700; letter-spacing: 0.5px; }
        .header h1 span { margin-right: 6px; }
        .header p { color: rgba(255,255,255,0.75); margin-top: 0.45rem; font-size: 0.92rem; }
        .tech-badges { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; margin-top: 0.65rem; }
        .badge { padding: 3px 12px; border-radius: 20px; font-size: 0.73rem; background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.25); color: rgba(255,255,255,0.85); }

        .container { max-width: 1100px; margin: 0 auto; padding: 1.25rem; }

        .card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 14px; padding: 1.4rem; margin-bottom: 1.1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
        .card-title { font-size: 1.05rem; font-weight: 650; margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem; }

        /* Upload Zone */
        .drop-zone { border: 2px dashed #ccc; border-radius: 12px; padding: 2.2rem 1.5rem; text-align: center; cursor: pointer; transition: all 0.25s; background: #fafafa; position: relative; }
        .drop-zone:hover, .drop-zone.dragover { border-color: var(--primary); background: #f0efff; }
        .drop-zone.has-file { border-color: var(--primary); border-style: solid; background: #f8f7ff; }
        .drop-icon { font-size: 2.6rem; margin-bottom: 0.6rem; }
        .drop-text { color: var(--text-dim); font-size: 0.92rem; line-height: 1.5; }
        .file-name { color: var(--primary-dark); font-weight: 600; margin-top: 0.5rem; word-break: break-all; font-size: 0.95rem; }
        #fileInput { display: none; }

        .options-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
        @media (max-width: 600px) { .options-row { grid-template-columns: 1fr; } }
        .option-group label { display: block; font-size: 0.82rem; color: var(--text-dim); margin-bottom: 0.35rem; font-weight: 500; }
        .option-group select { width: 100%; padding: 0.58rem 0.8rem; border-radius: 10px; background: #fff; color: var(--text); border: 1.5px solid var(--border); font-size: 0.88rem; cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%2386868b'%3E%3Cpath d='M6 9L1 4h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; transition: border-color 0.2s; }
        .option-group select:focus { outline: none; border-color: var(--primary); }

        .btn-row { display: flex; gap: 0.7rem; flex-wrap: wrap; }
        .btn { padding: 0.65rem 1.4rem; border-radius: 10px; border: none; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: all 0.2s; display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; }
        .btn-primary { background: linear-gradient(135deg, var(--primary), var(--primary-dark)); color: white; flex: 1; justify-content: center; min-height: 46px; font-size: 0.95rem; }
        .btn-primary:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 18px rgba(99,102,241,0.35); }
        .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
        .btn-secondary { background: #f0f0f2; color: var(--text); border: 1.5px solid var(--border); }
        .btn-secondary:hover { background: #e8e8ea; }
        .btn-danger { background: #fef2f2; color: #dc2626; border: 1.5px solid #fecaca; }
        .btn-danger:hover { background: #fee2e2; }

        /* Progress Bar */
        .progress-section { display: none; margin-top: 1rem; }
        .progress-section.active { display: block; }
        .progress-bar-wrap { height: 26px; background: #f0f0f2; border-radius: 13px; overflow: hidden; position: relative; border: 1px solid var(--border); }
        .progress-bar-fill { height: 100%; border-radius: 13px; background: linear-gradient(90deg, var(--primary), #818cf8, #c084fc); transition: width 0.5s ease; width: 0%; }
        .progress-text { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 0.8rem; font-weight: 600; z-index: 1; color: var(--text); }
        .status-msg { text-align: center; margin-top: 0.55rem; font-size: 0.84rem; color: var(--text-dim); }

        /* ===== Waveform Results Section ===== */
        .results-section { display: none; }
        .results-section.active { display: block; }

        .result-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; flex-wrap: wrap; gap: 0.5rem; }
        .result-title { font-size: 1.15rem; font-weight: 700; }
        .result-subtitle { font-size: 0.83rem; color: var(--text-dim); margin-top: 2px; }
        .track-count-badge { font-size: 0.8rem; color: var(--text-dim); font-weight: 500; }

        /* Play All Button */
        .play-all-btn {
            display: inline-flex; align-items: center; gap: 0.45rem;
            padding: 0.55rem 1.2rem; border-radius: 10px;
            background: #1d1d1f; color: #fff; border: none;
            font-size: 0.88rem; font-weight: 600; cursor: pointer;
            transition: all 0.2s; white-space: nowrap;
        }
        .play-all-btn:hover { background: #333; transform: translateY(-1px); }
        .play-all-btn.playing { background: var(--play-btn); }

        /* Track Row */
        .track-row { background: var(--card-bg); border: 1.5px solid var(--border); border-radius: 14px; padding: 1rem 1.2rem; margin-bottom: 0.85rem; animation: fadeSlideIn 0.4s ease; overflow: hidden; position: relative; }
        @keyframes fadeSlideIn { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:none} }
        .track-row:hover { border-color: #bbb; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
        .track-row.trim-active { border-color: var(--trim-border); border-width: 2px; }

        .track-info-row { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.6rem; }
        .track-label { font-size: 0.98rem; font-weight: 650; min-width: 52px; flex-shrink: 0; }

        /* Play/Pause button */
        .track-play-btn {
            width: 38px; height: 38px; border-radius: 50%;
            border: 1.5px solid var(--border); background: #fff;
            display: flex; align-items: center; justify-content: center;
            cursor: pointer; flex-shrink: 0; transition: all 0.2s;
            font-size: 16px; color: var(--text);
        }
        .track-play-btn:hover { background: #f5f5f7; border-color: #999; }
        .track-play-btn.playing { background: var(--play-btn); border-color: var(--play-btn); color: #fff; }

        /* Volume slider */
        .vol-slider-container { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
        .vol-icon { font-size: 16px; color: var(--text-dim); }
        .vol-slider { width: 70px; height: 4px; -webkit-appearance: none; appearance: none; background: #ddd; border-radius: 2px; cursor: pointer; outline: none; }
        .vol-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--text-dim); cursor: pointer; }

        /* Trim info bar (between controls & waveform) */
        .trim-info-bar {
            display: none; align-items: center; gap: 0.7rem;
            padding: 0.4rem 0.75rem; margin: 0.3rem 0;
            background: var(--trim-color); border-radius: 8px;
            font-size: 0.78rem; font-weight: 550;
        }
        .trim-info-bar.visible { display: flex; }
        .trim-time-label { color: var(--trim-border); font-weight: 700; font-family: 'SF Mono', Monaco, monospace; }
        .trim-hint { color: var(--text-dim); font-size: 0.72rem; }
        .trim-duration { margin-left: auto; color: var(--trim-border); font-weight: 700; font-family: monospace; }

        /* Export trim button */
        .export-trim-btn {
            padding: 4px 12px; border-radius: 7px;
            background: var(--trim-color); color: var(--trim-border);
            border: 1.5px solid var(--trim-border); cursor: pointer;
            font-size: 0.78rem; font-weight: 700; transition: all 0.15s;
            white-space: nowrap;
        }
        .export-trim-btn:hover { background: var(--trim-border); color: #fff; }
        .clear-trim-btn {
            padding: 4px 10px; border-radius: 7px;
            background: transparent; color: var(--text-dim);
            border: 1px solid var(--border); cursor: pointer;
            font-size: 0.76rem; font-weight: 550; transition: all 0.15s;
        }
        .clear-trim-btn:hover { border-color: #999; color: var(--text); }

        /* Waveform Canvas Container */
        .waveform-wrap {
            position: relative; height: 80px;
            border-radius: 10px; overflow: hidden;
            background: #fafafa; border: 1px solid #eee;
            cursor: pointer; user-select: none;
        }
        .waveform-canvas { width: 100%; height: 100%; display: block; }

        /* Selection overlay for trim range */
        .trim-selection {
            position: absolute; top: 0; bottom: 0;
            background: var(--trim-color); border-left: 2px solid var(--trim-border); border-right: 2px solid var(--trim-border);
            pointer-events: none; z-index: 2; opacity: 0;
            transition: opacity 0.15s;
        }
        .trim-selection.visible { opacity: 1; }

        /* Playhead / Current time marker */
        .playhead-time {
            position: absolute; top: -22px; left: 0;
            background: var(--play-btn); color: #fff;
            font-size: 0.68rem; font-weight: 700; padding: 2px 7px;
            border-radius: 6px; white-space: nowrap;
            opacity: 0; transition: opacity 0.2s;
            pointer-events: none; z-index: 5;
        }
        .playhead-line {
            position: absolute; top: 0; bottom: 0; left: 0;
            width: 2px; background: var(--play-btn);
            z-index: 4; opacity: 0; transition: left 0.05s linear;
        }
        .waveform-wrap.playing .playhead-time,
        .waveform-wrap.playing .playhead-line { opacity: 1; }
        .waveform-wrap.playing .playhead-line { transition: left 0.05s linear; }

        /* Trim markers on waveform */
        .trim-marker {
            position: absolute; top: -4px; bottom: -4px; width: 3px;
            background: var(--trim-border); z-index: 6; opacity: 0;
            pointer-events: none; border-radius: 1px;
            transition: opacity 0.15s;
        }
        .trim-marker.visible { opacity: 1; }
        .trim-marker.start { left: 0; }
        .trim-marker.end { left: 0; }
        .trim-marker-label {
            position: absolute; top: -18px; transform: translateX(-50%);
            background: var(--trim-border); color: #fff;
            font-size: 0.62rem; font-weight: 700; padding: 1px 5px;
            border-radius: 4px; white-space: nowrap; z-index: 7;
            opacity: 0; pointer-events: none;
            transition: opacity 0.15s;
        }
        .trim-marker-label.visible { opacity: 1; }

        /* Download btn in track */
        .track-download-btn {
            margin-left: auto; flex-shrink: 0;
            padding: 6px 14px; border-radius: 8px;
            border: 1.5px solid var(--border); background: transparent;
            font-size: 0.8rem; font-weight: 550; cursor: pointer;
            color: var(--text-dim); transition: all 0.2s;
        }
        .track-download-btn:hover { border-color: var(--primary); color: var(--primary); background: #f8f7ff; }

        /* Edit / Trim button */
        .track-edit-btn {
            flex-shrink: 0;
            padding: 6px 14px; border-radius: 8px;
            border: 1.5px solid var(--trim-border); background: var(--trim-color);
            font-size: 0.8rem; font-weight: 650; cursor: pointer;
            color: var(--trim-border); transition: all 0.2s;
        }
        .track-edit-btn:hover { background: var(--trim-border); color: #fff; }

        /* Quick Access */
        .quick-access {
            background: linear-gradient(135deg, #f0eeff, #ede9fe);
            border: 1.5px dashed var(--primary); border-radius: 12px;
            padding: 0.8rem 1.2rem; margin-bottom: 1rem; text-align: center;
        }
        .quick-access button {
            background: var(--primary); color: white; border: none;
            padding: 0.5rem 1.2rem; border-radius: 8px; font-size: 0.86rem;
            cursor: pointer; font-weight: 600;
        }
        .quick-access button:hover { background: var(--primary-dark); }

        /* History Panel */
        .history-panel {
            background: var(--card-bg); border: 1px solid var(--border);
            border-radius: 14px; margin-bottom: 1rem; overflow: hidden;
        }
        .history-header {
            display: flex; align-items: center; justify-content: space-between;
            padding: 10px 18px; border-bottom: 1px solid var(--border);
            background: linear-gradient(135deg, #f8f7ff, #f5f4ff);
        }
        .history-title { font-size: 0.95rem; font-weight: 700; }
        .history-hint { font-size: 0.78rem; color: var(--text-dim); }
        .history-item {
            display: flex; align-items: center; gap: 10px;
            padding: 9px 18px; border-bottom: 1px solid #f2f2f4;
            cursor: pointer; transition: background 0.15s;
        }
        .history-item:hover { background: #fafaff; }
        .history-item:last-child { border-bottom: none; }
        .history-item-icon {
            width: 32px; height: 32px; border-radius: 8px;
            background: linear-gradient(135deg, var(--primary), #818cf8);
            color: white; display: flex; align-items: center; justify-content: center;
            font-size: 15px; flex-shrink: 0;
        }
        .history-item-info { flex: 1; min-width: 0; }
        .history-item-name {
            font-size: 0.85rem; font-weight: 600; white-space: nowrap;
            overflow: hidden; text-overflow: ellipsis;
        }
        .history-item-meta {
            font-size: 0.73rem; color: var(--text-dim); margin-top: 1px;
        }
        .history-item-action {
            padding: 4px 12px; border-radius: 6px;
            background: var(--primary); color: white; border: none;
            font-size: 0.75rem; font-weight: 600; cursor: pointer;
            flex-shrink: 0; transition: all 0.15s;
        }
        .history-item-action:hover { background: var(--primary-dark); }
        .history-item-badge {
            font-size: 0.68rem; padding: 2px 7px; border-radius: 4px;
            background: #fee2e2; color: #dc2626; font-weight: 600; flex-shrink: 0;
        }
        .history-empty {
            text-align: center; padding: 24px; color: var(--text-dim);
            font-size: 0.84rem;
        }

        .footer { text-align: center; padding: 1.2rem; color: var(--text-dim); font-size: 0.78rem; margin-top: 0.5rem; }
        .footer a { color: var(--primary); text-decoration: none; }
        .footer a:hover { text-decoration: underline; }

        /* Disclaimer section in footer - 轻量风格 */
        .disclaimer-section {
            max-width: 640px; margin: 1.2rem auto 0;
            background: rgba(255,255,255,0.03); border: 1px solid var(--border);
            border-radius: 12px; padding: 1.1rem 1.4rem; text-align: left;
        }
        .disclaimer-title {
            display: flex; align-items: center; gap: 0.4rem;
            font-size: 0.82rem; font-weight: 700; color: var(--text-dim); margin-bottom: 0.55rem;
        }
        .disclaimer-body { font-size: 0.74rem; color: var(--text-muted); line-height: 1.7; }
        .disclaimer-body p { margin-bottom: 0.3rem; }
        .disclaimer-body p:last-child { margin-bottom: 0; }
        .disclaimer-highlight {
            display: inline-flex; align-items: center; gap: 0.35rem;
            padding: 0.38rem 0.8rem; border-radius: 8px;
            background: #ecfdf5; border: 1px solid #a7f3d0;
            font-size: 0.73rem; color: #065f46; font-weight: 600;
            margin-top: 0.55rem;
        }
        .privacy-badge {
            display: inline-flex; align-items: center; gap: 0.35rem;
            padding: 0.32rem 0.7rem; border-radius: 7px;
            background: #eff6ff; border: 1px solid #bfdbfe;
            font-size: 0.71rem; color: #1e40af; font-weight: 550; margin-top: 0.55rem;
        }

        @keyframes fadeIn { from{opacity:0} to{opacity:1} }
        .fade-in { animation: fadeIn 0.4s ease; }

        .wave-loading {
            position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
            background: rgba(250,250,250,0.85); z-index: 3; font-size: 0.8rem; color: var(--text-dim);
        }
        .spinner { width: 24px; height: 24px; border: 3px solid #ddd; border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 8px; display: inline-block; vertical-align: middle; }
        @keyframes spin { to{transform:rotate(360deg)} }
    </style>
</head>
<body>

<div class="header">
    <h1><span>🎵</span> 音频分离工具</h1>
    <p>上传任意音频或视频，AI 一键分离<strong>人声</strong>与<strong>伴奏</strong></p>
    <div class="tech-badges">
        <span class="badge">智能分轨</span>
        <span class="badge">实时进度</span>
        <span class="badge">高品质输出</span>
    </div>
</div>

<div class="container">

    <!-- Upload Card -->
    <div class="card fade-in" id="uploadCard">
        <div class="card-title">📂 上传文件</div>
        <div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
            <div class="drop-icon">📁</div>
            <div class="drop-text" id="dropText">拖拽文件到此处，或 <strong>点击选择文件</strong><br><small style="color:var(--text-dim)">支持 MP4 / MOV / AVI / MKV / MP3 / WAV / FLAC</small></div>
            <div class="file-name" id="fileName" style="display:none"></div>
            <input type="file" id="fileInput" accept="video/*,audio/*">
        </div>

        <div class="options-row" style="margin-top: 1rem;">
            <div class="option-group">
                <label>🤖 分离模型</label>
                <select id="modelSelect">
                    <option value="htdemucs" selected>⭐ 推荐模式（均衡质量与速度）</option>
                    <option value="htdemucs_ft">高品质模式（更精细分离）</option>
                    <option value="demucs">经典模式</option>
                    <option value="mdx">人声增强模式</option>
                    <option value="mdx_extra">增强模式</option>
                </select>
            </div>
            <div class="option-group">
                <label>🎼 输出轨道</label>
                <select id="stemsSelect">
                    <option value="2stems" selected>2 轨：人声 + 伴奏</option>
                    <option value="4stems">4 轨：人声 + 鼓 + 贝斯 + 其他</option>
                    <option value="5stems">5 轨：人声 + 鼓 + 贝斯 + 钢琴 + 其他</option>
                </select>
            </div>
        </div>

        <div class="btn-row">
            <button class="btn btn-primary" id="startBtn" onclick="startSeparate()" disabled>🚀 开始分离</button>
            <button class="btn btn-secondary" id="resetBtn" onclick="resetUpload()" style="display:none;">🔄 重选</button>
        </div>

        <div class="progress-section" id="progressSection">
            <div style="display:flex;justify-content:space-between;font-size:0.82rem;margin-bottom:0.4rem;color:var(--text-dim)">
                <span>分离进度</span><span id="pctText">0%</span>
            </div>
            <div class="progress-bar-wrap">
                <div class="progress-bar-fill" id="progressFill"></div>
                <div class="progress-text" id="progressText">准备中...</div>
            </div>
            <div class="status-msg" id="statusMsg"></div>
        </div>
    </div>

    <!-- History Panel -->
    <div class="history-panel fade-in" id="historyPanel">
        <div class="history-header">
            <span class="history-title">📋 历史记录</span>
            <span class="history-hint" id="historyHint"></span>
        </div>
        <div id="historyList"></div>
    </div>

    <!-- Results: Waveform Tracks -->
    <div class="card results-section" id="resultsSection">
        <div class="result-header">
            <div>
                <div class="result-title" id="resultTitle">—</div>
                <div class="result-subtitle"><span id="trackCountBadge" class="track-count-badge"></span></div>
            </div>
            <button class="play-all-btn" id="playAllBtn" onclick="togglePlayAll()">
                <span id="playAllIcon">▶</span> <span id="playAllText">播放全部音轨</span>
            </button>
        </div>
        <div id="tracksContainer"></div>

        <div class="btn-row" style="margin-top: 0.8rem;">
            <button class="btn btn-danger" id="cleanupBtn">🗑️ 清理</button>
            <button class="btn btn-secondary" id="newFileBtn">➕ 处理新文件</button>
        </div>
    </div>
</div>

<div class="footer">
    <div class="disclaimer-section">
        <div class="disclaimer-title">⚠️ 免责声明</div>
        <div class="disclaimer-body">
            <p><strong>版权：</strong>本工具仅提供音频信号处理技术服务，用户应对上传内容的版权合法性自行负责，严禁用于侵权用途。</p>
            <p><strong>用途：</strong>仅供学习、研究、个人创作参考及合理使用（Fair Use）。</p>
            <p><strong>效果：</strong>AI 分离结果仅供参考，复杂混音可能导致质量下降。</p>
            <span class="disclaimer-highlight">✅ 请优先使用原创或已获授权的音频内容</span>
            <br><br>
            <span class="privacy-badge">🔒 上传文件保留不超过 24 小时后自动清除</span>
        </div>
    </div>
    基于 <a href="https://github.com/facebookresearch/demucs" target="_blank">Demucs (Meta Research)</a> ·
    仅供技术交流与研究使用 ·
    <a href="/">返回首页</a>
</div>

<script>
// ============================================================
//  State
// ============================================================
let selectedFile = null;
let currentTaskId = null;
let knownCompletedTaskId = null;
let pollTimer = null;

// Audio engine state
const trackPlayers = {};      // { trackName: { audioEl, waveformData, duration, playing, color, gainValue } }
let isPlayingAll = false;

// Trim/cut state per track
const trackTrim = {};         // { trackName: { startTime(null|sec), endTime(null|sec) } }

const TRACK_COLORS = {
    vocals: '#a78bfa', accompaniment: '#93c5fd',
    drums: '#fca5a5', bass: '#86efac', piano: '#fde68a', other: '#c4b5fd'
};
const TRACK_LABELS = { vocals:'人声', accompaniment:'伴奏', drums:'鼓点', bass:'贝斯', piano:'钢琴', other:'其他' };
const TRACK_ORDER = ['vocals', 'accompaniment', 'drums', 'bass', 'piano', 'other'];
const ALL_TRACKS = [...TRACK_ORDER];

// ============================================================
//  Init
// ============================================================
window.addEventListener('DOMContentLoaded', async () => {
    await loadHistory();
});

async function loadHistory() {
    try {
        const r = await fetch('/api/history');
        if (!r.ok) return;
        const list = await r.json();
        renderHistory(list);
    } catch(e) {}
}

function renderHistory(list) {
    const panel = document.getElementById('historyPanel');
    const container = document.getElementById('historyList');
    const hint = document.getElementById('historyHint');

    if (!list || list.length === 0) {
        panel.style.display = 'none';
        return;
    }

    hint.textContent = list.length + ' 条记录';
    container.innerHTML = '';

    for (const rec of list) {
        const div = document.createElement('div');
        div.className = 'history-item';
        const exists = rec.exists !== false; // default true
        const fname = rec.filename || 'unknown';
        const tid = rec.task_id;
        const modelLabel = (rec.model || '').replace('htdemucs','HTDemucs').replace('htdemucs_ft','HTDemucs-FT').replace('_extra','-Extra');
        const timeStr = rec.created_at ? formatRelativeTime(rec.created_at) : '';

        div.innerHTML =
            '<div class="history-item-icon">🎵</div>' +
            '<div class="history-item-info">' +
                '<div class="history-item-name">' + escapeHtml(fname) + '</div>' +
                '<div class="history-item-meta">' + modelLabel + ' · ' + (rec.stems||'') + ' · ' + (rec.track_count||'?') + '轨' + (timeStr ? ' · '+timeStr : '') + '</div>' +
            '</div>';

        // Build action button
        if (exists) {
            const btn = document.createElement('button');
            btn.className = 'history-item-action';
            btn.textContent = '查看';
            btn.addEventListener('click', () => loadAndShowTaskResult(tid));
            div.appendChild(btn);
        } else {
            const badge = document.createElement('span');
            badge.className = 'history-item-badge';
            badge.textContent = '已清理';
            div.appendChild(badge);
        }

        container.appendChild(div);
    }

    panel.style.display = '';
}

function escapeHtml(str) {
    const d = document.createElement('div');
    d.textContent = str;
    return d.innerHTML;
}

function formatRelativeTime(isoString) {
    try {
        const d = new Date(isoString);
        const now = new Date();
        const diffMs = now - d;
        const diffMin = Math.floor(diffMs / 60000);
        if (diffMin < 1) return '刚刚';
        if (diffMin < 60) return diffMin+'分钟前';
        const diffH = Math.floor(diffMin / 60);
        if (diffH < 24) return diffH+'小时前';
        const diffD = Math.floor(diffH / 24);
        if (diffD < 7) return diffD+'天前';
        return d.toLocaleDateString('zh-CN');
    } catch(e) { return ''; }
}

async function loadAndShowResult() {
    if (!knownCompletedTaskId) return;
    await loadAndShowTaskResult(knownCompletedTaskId);
}

// ============================================================
//  File Upload
// ============================================================
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const startBtn = document.getElementById('startBtn');
const resetBtn = document.getElementById('resetBtn');

dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('dragover'); if(e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]); });
fileInput.addEventListener('change', () => { if(fileInput.files.length) handleFile(fileInput.files[0]); });

function handleFile(file) {
    selectedFile = file;
    dropZone.classList.add('has-file');
    document.getElementById('dropText').style.display = 'none';
    const fn = document.getElementById('fileName');
    fn.textContent = '📄 ' + file.name + ' (' + formatSize(file.size) + ')';
    fn.style.display = 'block';
    startBtn.disabled = false;
    resetBtn.style.display = 'inline-flex';
}
function formatSize(b) { if(b<1024)return b+' B'; if(b<1048576)return(b/1024).toFixed(1)+' KB'; return(b/1048576).toFixed(1)+' MB'; }
function resetUpload() {
    selectedFile=null; fileInput.value='';
    dropZone.classList.remove('has-file');
    document.getElementById('dropText').style.display='';
    document.getElementById('fileName').style.display='none';
    startBtn.disabled=true; resetBtn.style.display='none';
}

// ============================================================
//  Start Separation
// ============================================================
async function startSeparate() {
    if (!selectedFile) { alert('请先选择文件'); return; }
    const fd = new FormData();
    fd.append('file', selectedFile);
    fd.append('model', document.getElementById('modelSelect').value);
    fd.append('stems', document.getElementById('stemsSelect').value);

    startBtn.disabled = true;
    startBtn.textContent = '⏳ 提交中...';
    showProgress();
    updateProgress(0, '上传中...');
    try {
        const r = await fetch('/api/separate', { method:'POST', body:fd });
        const d = await r.json();
        if (!r.ok) throw new Error(d.detail || '提交失败');
        currentTaskId = d.task_id;
        updateProgress(5, '已提交，正在处理...');
        pollStatus(currentTaskId);
    } catch(err) {
        alert('错误: ' + err.message);
        startBtn.disabled = false;
        startBtn.textContent = '🚀 开始分离';
        hideProgress();
    }
}

function pollStatus(tid) {
    if(pollTimer) clearInterval(pollTimer);
    pollTimer = setInterval(async () => {
        try {
            const r = await fetch('/api/status/'+tid);
            const d = await r.json();
            updateProgress(d.progress||0, d.message||'');
            if (d.status === 'completed') {
                clearInterval(pollTimer);
                knownCompletedTaskId = tid;
                loadHistory(); // refresh history panel
                loadAndShowTaskResult(tid);
                startBtn.disabled=false; startBtn.textContent='🚀 开始分离';
                hideProgress();
            } else if (d.status === 'error') {
                clearInterval(pollTimer);
                updateProgress(-1, d.message);
                startBtn.disabled=false; startBtn.textContent='🚀 开始分离';
            }
        } catch(e){ console.error(e); }
    }, 2000);
}

function showProgress() { document.getElementById('progressSection').classList.add('active'); }
function hideProgress() { document.getElementById('progressSection').classList.remove('active'); updateProgress(0,''); document.getElementById('statusMsg').textContent=''; }
function updateProgress(pct, msg) {
    const fill=document.getElementById('progressFill'), txt=document.getElementById('progressText'), pctTxt=document.getElementById('pctText'), msgEl=document.getElementById('statusMsg');
    if(pct<0){fill.style.width='100%';fill.style.background='#ef4444';txt.textContent='❌失败';pctTxt.textContent='错误';}
    else{fill.style.width=Math.min(pct,100)+'%';txt.textContent=Math.min(pct,100)+'%';pctTxt.textContent=Math.min(pct,100)+'%';}
    if(msg!==undefined)msgEl.textContent=msg;
}

// ============================================================
//  Load Results & Render Waveform Tracks
// ============================================================
async function loadAndShowTaskResult(taskId) {
    console.log('[loadAndShowTaskResult] Loading task:', taskId);
    let data;
    try {
        const r = await fetch('/api/status/' + taskId);
        if (!r.ok) { alert('任务 ' + taskId + ' 不存在或已清理'); return; }
        data = await r.json();
        console.log('[loadAndShowTaskResult] API response:', data);
    } catch(e) { console.error('[loadAndShowTaskResult] Fetch error:', e); alert('无法获取任务状态: ' + e.message); return; }

    // Discover tracks from API response first
    let tracks = {};
    if (data.result && data.result.tracks && Object.keys(data.result.tracks).length > 0) {
        tracks = data.result.tracks;
        console.log('[loadAndShowTaskResult] Tracks from API:', tracks);
    } else {
        // Fallback: probe each possible track name
        for (const t of ALL_TRACKS) {
            try {
                const cr = await fetch('/api/download/' + taskId + '/' + t, {method:'HEAD'});
                if (cr.ok) { tracks[t] = t + '.mp3'; console.log('Found track:', t); }
            } catch(e){}
        }
    }
    if (Object.keys(tracks).length === 0) {
        console.error('[loadAndShowTaskResult] No tracks found! data.result=', data.result);
        alert('未找到分离后的音轨文件（任务可能还在处理中）');
        return;
    }

    currentTaskId = taskId;
    knownCompletedTaskId = taskId;

    // Sort: vocals first
    const sortedEntries = Object.entries(tracks).sort((a,b) => {
        const ai = TRACK_ORDER.indexOf(a[0]);
        const bi = TRACK_ORDER.indexOf(b[0]);
        return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
    });
    console.log('[loadAndShowTaskResult] Sorted tracks:', sortedEntries.map(e => e[0]));

    // Show results section FIRST so DOM has layout dimensions
    const resultsSection = document.getElementById('resultsSection');
    const uploadCard = document.getElementById('uploadCard');
    
    if (resultsSection) resultsSection.classList.add('active');
    if (uploadCard) uploadCard.style.display = 'none';
    
    document.getElementById('resultTitle').textContent = data.filename || '#' + taskId.toUpperCase().slice(0,8);
    document.getElementById('trackCountBadge').textContent = Object.keys(tracks).length + ' 音轨已就绪';

    const container = document.getElementById('tracksContainer');
    container.innerHTML = '';

    stopAllPlayback();
    for (const k in trackPlayers) delete trackPlayers[k];
    for (const k in trackTrim) delete trackTrim[k];
    isPlayingAll = false;
    updatePlayAllButton();

    for (const [name, filename] of sortedEntries) {
        try {
            const row = createTrackRow(name, taskId, filename);
            container.appendChild(row);
            trackPlayers[name] = { audioEl:null, waveformData:null, duration:0, playing:false, color:TRACK_COLORS[name]||'#999', gainValue:1.0 };
            trackTrim[name] = { startTime:null, endTime:null };
        } catch(err) {
            console.error('[loadAndShowTaskResult] Error creating track row for', name, err);
        }
    }

    // Verify rows were actually created
    const createdRows = container.children.length;
    console.log('[loadAndShowTaskResult] Created', createdRows, 'track rows');
    if (createdRows === 0) {
        alert('无法创建音轨显示区域（页面元素可能缺失）');
        return;
    }

    // Force a browser reflow so that getBoundingClientRect returns real values
    container.offsetHeight; // trigger reflow

    // Now load audio and draw waveforms
    let loadErrors = 0;
    for (const [name, filename] of sortedEntries) {
        try {
            await loadAudioAndDrawWaveform(name, '/api/download/' + taskId + '/' + name);
        } catch(err) {
            console.error('Failed to load track:', name, err);
            loadErrors++;
            // Show error on the waveform area
            const loader = document.getElementById('wload-' + name);
            if (loader) loader.innerHTML = '<span style="color:#ef4444">加载失败</span>';
        }
    }

    if (loadErrors === sortedEntries.length) {
        alert('所有音轨文件加载失败，任务数据可能不完整');
        return;
    }

    console.log('[loadAndShowTaskResult] Done. Scrolling to results.');
    document.getElementById('resultsSection').scrollIntoView({behavior:'smooth', block:'start'});
}

// ============================================================
//  Create Track DOM Row
// ============================================================
function createTrackRow(trackName, taskId, filename) {
    const div = document.createElement('div');
    div.className = 'track-row';
    div.id = 'track-' + trackName;
    div.dataset.track = trackName;
    div.dataset.task = taskId;
    const label = TRACK_LABELS[trackName] || trackName;
    const color = TRACK_COLORS[trackName] || '#999';

    // Build innerHTML with NO inline onclick handlers — use data attributes + event delegation
    div.innerHTML =
        '<div class="track-info-row">' +
            '<span class="track-label" style="color:'+color+';font-weight:700">'+label+'</span>' +
            '<button class="track-play-btn" id="playBtn-'+trackName+'" title="播放/暂停此音轨">' +
                '<span id="playIcon-'+trackName+'">▶</span>' +
            '</button>' +
            '<div class="vol-slider-container">' +
                '<span class="vol-icon">🔊</span>' +
                '<input type="range" class="vol-slider" min="0" max="150" value="100" title="音量">' +
            '</div>' +
            '<button class="track-download-btn" title="下载原始音轨">⬇ 原始</button>' +
            '<button class="track-edit-btn" title="打开剪辑页面">✂️ 剪辑</button>' +
        '</div>' +

        /* Trim info bar (shown when selection is active) */
        '<div class="trim-info-bar" id="trimBar-'+trackName+'">' +
            '<span class="trim-hint">✂️ 已选中:</span>' +
            '<span class="trim-time-label" id="trimStartLabel-'+trackName+'">--:--</span>' +
            '<span>→</span>' +
            '<span class="trim-time-label" id="trimEndLabel-'+trackName+'">--:--</span>' +
            '<span class="trim-duration" id="trimDurLabel-'+trackName+'"></span>' +
            '<button class="export-trim-btn" id="exportTrimBtn-'+trackName+'">导出剪裁</button>' +
            '<button class="clear-trim-btn">取消</button>' +
        '</div>' +

        /* Waveform canvas with trim overlay — no inline handlers, use event delegation from container */
        '<div class="waveform-wrap" id="waveWrap-'+trackName+'">' +
            '<canvas class="waveform-canvas" id="waveCanvas-'+trackName+'"></canvas>' +
            '<div class="trim-selection" id="trimSel-'+trackName+'"></div>' +
            '<div class="trim-marker start" id="trimMarkStart-'+trackName+'"><span class="trim-marker-label" id="trimLabelStart-'+trackName+'">起</span></div>' +
            '<div class="trim-marker end" id="trimMarkEnd-'+trackName+'"><span class="trim-marker-label" id="trimLabelEnd-'+trackName+'">止</span></div>' +
            '<div class="playhead-time" id="ptime-'+trackName+'">00:00.0</div>' +
            '<div class="playhead-line" id="pline-'+trackName+'"></div>' +
            '<div class="wave-loading" id="wload-'+trackName+'"><span class="spinner"></span>加载波形...</div>' +
        '</div>';

    // Attach events via addEventListener (no string concatenation → zero escaping issues)
    const playBtn = div.querySelector('#playBtn-' + trackName);
    playBtn.addEventListener('click', () => toggleSinglePlay(trackName));

    const volSlider = div.querySelector('.vol-slider');
    volSlider.addEventListener('input', (e) => setTrackVolume(trackName, e.target.value));

    const dlBtn = div.querySelector('.track-download-btn');
    dlBtn.addEventListener('click', () => window.open('/api/download/' + taskId + '/' + trackName, '_blank'));

    const editBtn = div.querySelector('.track-edit-btn');
    editBtn.addEventListener('click', () => openEditor(trackName, taskId));

    const exportTrimBtn = div.querySelector('#exportTrimBtn-' + trackName);
    exportTrimBtn.addEventListener('click', () => exportTrim(trackName));

    const clearTrimBtn = div.querySelector('.clear-trim-btn');
    clearTrimBtn.addEventListener('click', () => clearTrim(trackName));

    const waveWrap = div.querySelector('#waveWrap-' + trackName);
    waveWrap.addEventListener('dblclick', (e) => onWaveDblClick(trackName, e));
    waveWrap.addEventListener('click', (e) => onWaveClick(trackName, e));

    return div;
}

// ============================================================
//  Load Audio → Decode → Draw Waveform
// ============================================================
async function loadAudioAndDrawWaveform(trackName, url) {
    const player = trackPlayers[trackName];
    if (!player) return;
    try {
        const resp = await fetch(url);
        const arrayBuffer = await resp.arrayBuffer();
        const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
        const decoded = await audioCtx.decodeAudioData(arrayBuffer);
        const channelData = decoded.getChannelData(0);
        player.duration = decoded.duration;

        const targetPoints = Math.min(Math.floor(channelData.length / 200), 1200);
        const blockSize = Math.floor(channelData.length / targetPoints);
        const waveData = [];
        for (let i = 0; i < targetPoints; i++) {
            let sum = 0;
            const start = i * blockSize;
            for (let j = 0; j < blockSize && start + j < channelData.length; j++) {
                sum += Math.abs(channelData[start + j]);
            }
            waveData.push(sum / blockSize);
        }
        const maxVal = Math.max(...waveData, 0.0001);
        player.waveformData = waveData.map(v => v / maxVal);

        const audio = new Audio(url);
        audio.preload = 'auto';
        player.audioEl = audio;
        audio.addEventListener('timeupdate', () => onTimeUpdate(trackName));
        audio.addEventListener('ended', () => onTrackEnded(trackName));
        audio.addEventListener('loadedmetadata', () => {
            player.duration = audio.duration || decoded.duration;
            drawWaveform(trackName);
        });
        drawWaveform(trackName);
        audioCtx.close();
    } catch(err) {
        console.error('Failed to load audio for', trackName, err);
        document.getElementById('wload-' + trackName).innerHTML = '<span style="color:#ef4444">波形加载失败</span>';
    }
}

function drawWaveform(trackName) {
    const player = trackPlayers[trackName];
    if (!player || !player.waveformData) return;
    const canvas = document.getElementById('waveCanvas-' + trackName);
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    const wrapEl = canvas.parentElement;
    const rect = wrapEl.getBoundingClientRect();
    const dpr = window.devicePixelRatio || 1;
    let w = rect.width, h = 80;

    // Defense: if width is 0 (element not laid out yet), retry with requestAnimationFrame
    if (w < 10) {
        // Try getting width from computed style as fallback
        w = wrapEl.offsetWidth || Math.max(wrapEl.parentElement?.offsetWidth || 800, 800);
        if (w < 10) {
            console.warn('Canvas width is 0 for', trackName, '- scheduling redraw');
            requestAnimationFrame(() => drawWaveform(trackName));
            return;
        }
    }

    canvas.width = w * dpr; canvas.height = h * dpr;
    canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
    ctx.scale(dpr, dpr);
    const data = player.waveformData, len = data.length, color = player.color;
    const barW = Math.max(w / len - 0.5, 0.8), midY = h / 2;
    ctx.clearRect(0, 0, w, h);
    for (let i = 0; i < len; i++) {
        const val = data[i], barH = Math.max(val * (h * 0.42), 1), x = (i / len) * w;
        const alpha = 0.25 + val * 0.75;
        ctx.fillStyle = hexToRgba(color, alpha * 0.5);
        ctx.fillRect(x, midY - barH, barW, barH);
        ctx.fillStyle = hexToRgba(color, alpha * 0.3);
        ctx.fillRect(x, midY, barW, barH);
    }
    const loader = document.getElementById('wload-' + trackName);
    if (loader) loader.style.display = 'none';

    // Redraw trim selection if exists
    refreshTrimOverlay(trackName);
}

function hexToRgba(hex, a) {
    const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
    return 'rgba('+r+','+g+','+b+','+a+')';
}

function redrawAllWaveforms() { for (const n in trackPlayers) drawWaveform(n); }
window.addEventListener('resize', () => { clearTimeout(window._resizeTimer); window._resizeTimer = setTimeout(redrawAllWaveforms, 150); });

// ============================================================
//  Single Track Play/Pause
// ============================================================
function toggleSinglePlay(trackName) {
    trackPlayers[trackName].playing ? pauseTrack(trackName) : playTrack(trackName);
}
function playTrack(trackName) {
    const p = trackPlayers[trackName]; if(!p||!p.audioEl)return;
    p.audioEl.play(); p.playing=true; updateTrackPlayUI(trackName,true);
}
function pauseTrack(trackName) {
    const p = trackPlayers[trackName]; if(!p)return;
    p.audioEl.pause(); p.playing=false; updateTrackPlayUI(trackName,false);
}
function updateTrackPlayUI(trackName, playing) {
    const btn=document.getElementById('playBtn-'+trackName), icon=document.getElementById('playIcon-'+trackName), wrap=document.getElementById('waveWrap-'+trackName);
    // Guard: DOM elements may have been removed (e.g. during task switch or cleanup)
    if(!btn || !icon) return;
    if(playing){icon.textContent='❚❚';btn.classList.add('playing');if(wrap)wrap.classList.add('playing');}
    else{icon.textContent='▶';btn.classList.remove('playing');if(wrap)wrap.classList.remove('playing');}
}
function setTrackVolume(trackName,val) {
    const p=trackPlayers[trackName];if(!p||!p.audioEl)return;
    p.gainValue=val/100;p.audioEl.volume=Math.min(val/100,1.5);
}

// Click to seek
function seekTrack(trackName, event) {
    const p=trackPlayers[trackName];if(!p||!p.audioEl)return;
    const wrap=document.getElementById('waveWrap-'+trackName),rect=wrap.getBoundingClientRect();
    const pct=(event.clientX-rect.left)/rect.width;
    p.audioEl.currentTime=pct*p.duration;
    updatePlayheadPosition(trackName,pct);
}

function onTimeUpdate(trackName) {
    const p=trackPlayers[trackName];if(!p||!p.audioEl)return;
    const ct=p.audioEl.currentTime,dur=p.duration||1,pct=ct/dur;
    const timeEl=document.getElementById('ptime-'+trackName);
    if(timeEl)timeEl.textContent=formatTime(ct);
    updatePlayheadPosition(trackName,pct);
}
function updatePlayheadPosition(trackName,pct) {
    const line=document.getElementById('pline-'+trackName);
    if(line)line.style.left=(pct*100)+'%';
}
function onTrackEnded(trackName) {
    const p=trackPlayers[trackName];if(p)p.playing=false;
    updateTrackPlayUI(trackName,false);
    if(isPlayingAll){
        const anyPlaying=Object.values(trackPlayers).some(p=>p.playing);
        if(!anyPlaying){isPlayingAll=false;updatePlayAllButton();}
    }
}
function formatTime(sec) {
    const m=Math.floor(sec/60),s=Math.floor(sec%60),ms=Math.floor((sec%1)*10);
    return String(m).padStart(2,'0')+':'+String(s).padStart(2,'0')+'.'+ms;
}

// ============================================================
//  ✂️ TRIM / CUT — 双击设置起止点 + 点击取消 + 导出
// ============================================================

/** Double-click on waveform → set start or end point */
function onWaveDblClick(trackName, event) {
    event.preventDefault();
    event.stopPropagation();

    const p = trackPlayers[trackName];
    if (!p || !p.duration) return;

    const wrap = document.getElementById('waveWrap-' + trackName);
    const rect = wrap.getBoundingClientRect();
    const clickPct = (event.clientX - rect.left) / rect.width;
    const clickSec = clickPct * p.duration;

    const trim = trackTrim[trackName];

    // Toggle logic:
    //   If no start set → set as START
    //   If start set but no end → set as END (must be after start)
    //   If both set → RESET and set this new point as START
    if (trim.startTime === null) {
        // Set start
        trim.startTime = clickSec;
        trim.endTime = null;
    } else if (trim.endTime === null) {
        // Set end (must be >= start)
        if (clickSec >= trim.startTime) {
            trim.endTime = clickSec;
        } else {
            // Clicked before start → swap: this becomes new start
            trim.startTime = clickSec;
        }
    } else {
        // Both already set → reset, set new start
        trim.startTime = clickSec;
        trim.endTime = null;
    }

    refreshTrimOverlay(trackName);
    refreshTrimInfoBar(trackName);
}

/** Single click on an existing marker/time → deselect that marker */
function onWaveClick(trackName, event) {
    // Only handle if we have an active trim
    const trim = trackTrim[trackName];
    if (!trim || (trim.startTime === null && trim.endTime === null)) {
        // No trim active → treat as normal seek
        seekTrack(trackName, event);
        return;
    }

    const p = trackPlayers[trackName];
    if (!p || !p.duration) return;

    const wrap = document.getElementById('waveWrap-' + trackName);
    const rect = wrap.getBoundingClientRect();
    const clickPct = (event.clientX - rect.left) / rect.width;
    const clickSec = clickPct * p.duration;
    const tolerance = p.duration * 0.02; // ~2% of duration as tolerance

    // Check if clicked near the start or end marker
    if (trim.endTime !== null && Math.abs(clickSec - trim.endTime) < tolerance) {
        // Clicked near end → remove end
        trim.endTime = null;
    } else if (Math.abs(clickSec - trim.startTime) < tolerance) {
        // Clicked near start → remove start (and thus clear everything)
        trim.startTime = null;
        trim.endTime = null;
    } else {
        // Clicked elsewhere → normal seek
        seekTrack(trackName, event);
        return;
    }

    refreshTrimOverlay(trackName);
    refreshTrimInfoBar(trackName);
}

function clearTrim(trackName) {
    trackTrim[trackName] = { startTime: null, endTime: null };
    refreshTrimOverlay(trackName);
    refreshTrimInfoBar(trackName);
}

function refreshTrimOverlay(trackName) {
    const trim = trackTrim[trackName];
    const p = trackPlayers[trackName];
    if (!p || !p.duration) return;

    const sel = document.getElementById('trimSel-' + trackName);
    const mStart = document.getElementById('trimMarkStart-' + trackName);
    const mEnd = document.getElementById('trimMarkEnd-' + trackName);
    const lStart = document.getElementById('trimLabelStart-' + trackName);
    const lEnd = document.getElementById('trimLabelEnd-' + trackName);
    const row = document.getElementById('track-' + trackName);

    if (trim.startTime !== null && trim.endTime !== null) {
        // Both set → show selection highlight + markers
        const startPct = (trim.startTime / p.duration) * 100;
        const endPct = (trim.endTime / p.duration) * 100;
        const widthPct = endPct - startPct;

        sel.style.left = startPct + '%';
        sel.style.width = widthPct + '%';
        sel.classList.add('visible');

        mStart.style.left = startPct + '%'; mStart.classList.add('visible');
        lStart.textContent = formatTime(trim.startTime); lStart.classList.add('visible');

        mEnd.style.left = endPct + '%'; mEnd.classList.add('visible');
        lEnd.textContent = formatTime(trim.endTime); lEnd.classList.add('visible');

        row.classList.add('trim-active');
    } else if (trim.startTime !== null) {
        // Only start set → show start marker only
        const startPct = (trim.startTime / p.duration) * 100;

        sel.classList.remove('visible');

        mStart.style.left = startPct + '%'; mStart.classList.add('visible');
        lStart.textContent = formatTime(trim.startTime); lStart.classList.add('visible');

        mEnd.classList.remove('visible'); lEnd.classList.remove('visible');

        row.classList.add('trim-active');
    } else {
        // Nothing set
        sel.classList.remove('visible');
        mStart.classList.remove('visible'); lStart.classList.remove('visible');
        mEnd.classList.remove('visible'); lEnd.classList.remove('visible');
        row.classList.remove('trim-active');
    }
}

function refreshTrimInfoBar(trackName) {
    const trim = trackTrim[trackName];
    const bar = document.getElementById('trimBar-' + trackName);
    const sl = document.getElementById('trimStartLabel-' + trackName);
    const el = document.getElementById('trimEndLabel-' + trackName);
    const dl = document.getElementById('trimDurLabel-' + trackName);

    if (trim.startTime !== null && trim.endTime !== null) {
        sl.textContent = formatTime(trim.startTime);
        el.textContent = formatTime(trim.endTime);
        dl.textContent = '(' + formatDuration(trim.endTime - trim.startTime) + ')';
        bar.classList.add('visible');
    } else if (trim.startTime !== null) {
        sl.textContent = formatTime(trim.startTime);
        el.textContent = '等待结束点…';
        dl.textContent = '';
        bar.classList.add('visible');
    } else {
        bar.classList.remove('visible');
    }
}

function formatDuration(sec) {
    const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
    return m + ':' + String(s).padStart(2, '0');
}

async function exportTrim(trackName) {
    const trim = trackTrim[trackName];
    if (!currentTaskId || trim.startTime === null || trim.endTime === null) {
        alert('请先双击波形设置裁剪的起止时间');
        return;
    }

    const btn = document.getElementById('exportTrimBtn-' + trackName);
    const origText = btn.textContent;
    btn.textContent = '⏳ 导出中…';
    btn.disabled = true;

    try {
        const params = new URLSearchParams({
            task_id: currentTaskId,
            track_name: trackName,
            start_time: trim.startTime.toFixed(3),
            end_time: trim.endTime.toFixed(3)
        });
        const resp = await fetch('/api/trim?' + params, { method: 'POST' });

        if (!resp.ok) {
            const err = await resp.json();
            throw new Error(err.detail || '裁剪失败');
        }

        // Trigger download
        const blob = await resp.blob();
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = (TRACK_LABELS[trackName] || trackName) + '_' +
            formatTime(trim.startTime).replace(/[:.]/g,'') + '-' +
            formatTime(trim.endTime).replace(/[:.]/g,'') + '.mp3';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);

    } catch(err) {
        alert('导出失败: ' + err.message);
    } finally {
        btn.textContent = origText;
        btn.disabled = false;
    }
}

// ============================================================
//  ▶ 播放全部 / 暂停全部 — 对齐到最小时刻
// ============================================================
function togglePlayAll() {
    isPlayingAll ? pauseAllTracks() : playAllTracks();
}

function playAllTracks() {
    isPlayingAll = true;

    // Find minimum currentTime across all tracks
    let minTime = Infinity;
    for (const name in trackPlayers) {
        const p = trackPlayers[name];
        if (p && p.audioEl && p.duration > 0) {
            minTime = Math.min(minTime, p.audioEl.currentTime);
        }
    }
    if (!isFinite(minTime)) minTime = 0;

    // Align all tracks to minTime and play
    let startedCount = 0;
    for (const name in trackPlayers) {
        const p = trackPlayers[name];
        if (p && p.audioEl) {
            // Reset to start if at the end
            if (p.audioEl.ended || p.audioEl.currentTime >= p.duration - 0.1) {
                p.audioEl.currentTime = 0;
            }
            // Align to min time
            p.audioEl.currentTime = minTime;
            p.audioEl.play().catch(() => {});
            p.playing = true;
            updateTrackPlayUI(name, true);
            startedCount++;
        }
    }
    if (startedCount > 0) updatePlayAllButton();
}

function pauseAllTracks() {
    isPlayingAll = false;
    for (const name in trackPlayers) {
        const p = trackPlayers[name];
        if (p && p.audioEl && p.playing) {
            p.audioEl.pause();
            p.playing = false;
            updateTrackPlayUI(name, false);
        }
    }
    updatePlayAllButton();
}

function stopAllPlayback() {
    isPlayingAll = false;
    for (const name in trackPlayers) {
        const p = trackPlayers[name];
        if (p) {
            if (p.audioEl) { p.audioEl.pause(); p.audioEl.currentTime = 0; }
            p.playing = false;
            updateTrackPlayUI(name, false);
        }
    }
    updatePlayAllButton();
}

function updatePlayAllButton() {
    const btn=document.getElementById('playAllBtn'), icon=document.getElementById('playAllIcon'), txt=document.getElementById('playAllText');
    if(isPlayingAll){icon.textContent='❚❚';txt.textContent='暂停全部音轨';btn.classList.add('playing');}
    else{icon.textContent='▶';txt.textContent='播放全部音轨';btn.classList.remove('playing');}
}

// ============================================================
//  Cleanup
// ============================================================
async function cleanupCurrent() {
    if(!currentTaskId) return;
    if(!confirm('确定清理该任务的所有文件？'))return;
    stopAllPlayback();
    await fetch('/api/cleanup/'+currentTaskId,{method:'DELETE'});
    currentTaskId = null;
    knownCompletedTaskId = null;
    goHome();
}

function hideResults() {
    // IMPORTANT: stop playback BEFORE clearing DOM, otherwise updateTrackPlayUI
    // will try to access DOM elements that no longer exist (null reference)
    stopAllPlayback();
    document.getElementById('resultsSection').classList.remove('active');
    document.getElementById('tracksContainer').innerHTML = '';
}

/** Show the upload form + history panel (and hide results) — returns to initial page state */
function showUploadView() {
    const uploadCard = document.getElementById('uploadCard');
    if (uploadCard) uploadCard.style.display = 'block';
    document.getElementById('resultsSection').classList.remove('active');
    // Also ensure history panel is visible
    const historyPanel = document.getElementById('historyPanel');
    if (historyPanel) historyPanel.style.display = '';
}

/** Go back to home (upload + history), resetting everything */
function goHome() {
    hideResults();
    resetUpload();
    currentTaskId = null;
    knownCompletedTaskId = null;
    showUploadView();
    loadHistory(); // refresh history list
    window.scrollTo({ top: 0, behavior: 'smooth' });
}

function openEditor(trackName, taskId) {
    const url = '/editor?task_id=' + taskId + '&track=' + encodeURIComponent(trackName);
    window.open(url, '_blank');
}

// ============================================================
//  Wire up buttons that were moved out of inline onclick
// ============================================================
window.addEventListener('DOMContentLoaded', () => {
    const cleanupBtn = document.getElementById('cleanupBtn');
    if (cleanupBtn) cleanupBtn.addEventListener('click', () => cleanupCurrent());

    const newFileBtn = document.getElementById('newFileBtn');
    if (newFileBtn) newFileBtn.addEventListener('click', () => goHome());
});
</script>

</body>
</html>


path: rules/editor-page.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>✂️ 音频剪辑 - 音频分离工具</title>
    <style>
        :root {
            --primary: #6366f1;
            --primary-dark: #4f46e5;
            --bg: #0f0f13;
            --card-bg: #1a1a22;
            --text: #e4e4eb;
            --text-dim: #8b8b99;
            --border: #2e2e3a;
            --accent: #ff6b35;
            --trim-color: rgba(255, 107, 53, 0.15);
            --trim-border: #ff6b35;
            --wave-bg: #12121a;
        }
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, 'PingFang SC', sans-serif;
            background: var(--bg); color: var(--text);
            min-height: 100vh; display: flex; flex-direction: column;
        }

        /* ===== Header Bar ===== */
        .topbar {
            display: flex; align-items: center; justify-content: space-between;
            padding: 12px 24px; background: var(--card-bg); border-bottom: 1px solid var(--border);
            position: sticky; top: 0; z-index: 100;
        }
        .topbar-left { display: flex; align-items: center; gap: 14px; }
        .back-btn {
            width: 36px; height: 36px; border-radius: 50%;
            border: 1.5px solid var(--border); background: transparent;
            color: var(--text-dim); font-size: 18px; cursor: pointer;
            display: flex; align-items: center; justify-content: center;
            transition: all 0.2s;
        }
        .back-btn:hover { background: var(--border); color: var(--text); }
        .track-title { font-size: 1.15rem; font-weight: 700; }
        .track-badge {
            font-size: 0.78rem; font-weight: 600; padding: 3px 10px; border-radius: 6px;
            background: rgba(99,102,241,0.18); color: #818cf8; vertical-align: middle;
        }
        .topbar-right { display: flex; align-items: center; gap: 10px; }
        .new-task-btn {
            padding: 7px 16px; border-radius: 8px;
            background: linear-gradient(135deg, var(--primary), var(--primary-dark));
            color: #fff; text-decoration: none; font-size: 0.82rem;
            font-weight: 650; transition: all 0.2s; white-space: nowrap;
        }
        .new-task-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 14px rgba(99,102,241,0.35); }

        /* ===== Main Content ===== */
        .editor-main {
            flex: 1; display: flex; flex-direction: column;
            max-width: 1200px; margin: 0 auto; width: 100%; padding: 20px 24px;
        }

        /* Transport Controls */
        .transport-bar {
            display: flex; align-items: center; gap: 16px;
            padding: 20px 28px; background: var(--card-bg);
            border: 1px solid var(--border); border-radius: 16px;
            margin-bottom: 16px;
        }
        .play-btn-large {
            width: 56px; height: 56px; border-radius: 50%;
            background: linear-gradient(135deg, var(--primary), var(--primary-dark));
            color: #fff; border: none; font-size: 22px; cursor: pointer;
            display: flex; align-items: center; justify-content: center;
            transition: all 0.2s; flex-shrink: 0;
            box-shadow: 0 4px 16px rgba(99,102,241,0.35);
        }
        .play-btn-large:hover { transform: scale(1.06); box-shadow: 0 6px 24px rgba(99,102,241,0.5); }
        .play-btn-large.playing { background: linear-gradient(135deg, #ef4444, #dc2626); box-shadow: 0 4px 16px rgba(239,68,68,0.35); }

        .time-display {
            text-align: center; min-width: 100px; flex-shrink: 0;
        }
        .time-current { font-size: 1.65rem; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: 0.5px; }
        .time-duration { font-size: 0.82rem; color: var(--text-dim); margin-top: 2px; }

        /* Volume */
        .vol-group { display: flex; align-items: center; gap: 8px; margin-left: auto; }
        .vol-icon { font-size: 18px; color: var(--text-dim); }
        .vol-slider {
            width: 100px; height: 5px; -webkit-appearance: none; appearance: none;
            background: var(--border); border-radius: 3px; cursor: pointer; outline: none;
        }
        .vol-slider::-webkit-slider-thumb {
            -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
            background: var(--primary); cursor: pointer; border: 2px solid var(--card-bg);
        }

        /* ===== Waveform Area (Large) ===== */
        .waveform-section {
            background: var(--card-bg); border: 1px solid var(--border);
            border-radius: 16px; overflow: hidden;
        }
        .waveform-header {
            display: flex; justify-content: space-between; align-items: center;
            padding: 12px 20px; border-bottom: 1px solid var(--border);
            font-size: 0.85rem; color: var(--text-dim);
        }
        .wave-hint { font-size: 0.8rem; color: var(--text-dim); }
        .wave-hint kbd {
            background: var(--border); padding: 1px 7px; border-radius: 4px;
            font-size: 0.75rem; font-family: inherit; color: var(--text);
        }

        .wave-container {
            position: relative; height: 200px;
            background: var(--wave-bg); cursor: pointer; user-select: none;
        }
        .wave-canvas { width: 100%; height: 100%; display: block; }

        /* Playhead on editor waveform */
        .playhead-line-e {
            position: absolute; top: 0; bottom: 0; left: 0;
            width: 2px; background: var(--accent); z-index: 10;
            opacity: 0; transition: left 0.05s linear;
        }
        .playhead-time-e {
            position: absolute; top: -26px; left: 0; transform: translateX(-50%);
            background: var(--accent); color: #fff;
            font-size: 0.72rem; font-weight: 700; padding: 3px 8px;
            border-radius: 6px; white-space: nowrap;
            opacity: 0; transition: opacity 0.2s; z-index: 11;
        }
        .wave-container.playing .playhead-line-e,
        .wave-container.playing .playhead-time-e { opacity: 1; }

        /* Trim selection overlay for large waveform */
        .trim-overlay-e {
            position: absolute; top: 0; bottom: 0;
            background: var(--trim-color);
            border-left: 3px solid var(--trim-border);
            border-right: 3px solid var(--trim-border);
            pointer-events: none; z-index: 8; opacity: 0;
            transition: opacity 0.15s;
        }
        .trim-overlay-e.visible { opacity: 1; }

        .trim-marker-e {
            position: absolute; top: -6px; bottom: -6px; width: 4px;
            background: var(--trim-border); z-index: 9; opacity: 0;
            pointer-events: none; border-radius: 2px; transition: opacity 0.15s;
        }
        .trim-marker-e.visible { opacity: 1; }
        .trim-tag-e {
            position: absolute; top: -22px; left: 50%; transform: translateX(-50%);
            background: var(--trim-border); color: #fff;
            font-size: 0.68rem; font-weight: 700; padding: 2px 7px;
            border-radius: 5px; white-space: nowrap; z-index: 12;
            opacity: 0; transition: opacity 0.15s;
        }
        .trim-tag-e.visible { opacity: 1; }

        /* Time ruler below waveform */
        .time-ruler {
            display: flex; align-items: center; height: 32px;
            padding: 0 20px; background: var(--card-bg);
            border-bottom: 1px solid var(--border); position: relative;
        }
        .ruler-tick { position: absolute; top: 8px; width: 1px; height: 8px; background: var(--border); }
        .ruler-label { position: absolute; top: 18px; font-size: 0.68rem; color: var(--text-dim); font-variant-numeric: tabular-nums; transform: translateX(-50%); }

        /* ===== Trim Info Panel ===== */
        .trim-panel {
            background: var(--card-bg); border: 1px solid var(--border);
            border-radius: 14px; padding: 16px 24px;
            margin-top: 16px; display: none;
        }
        .trim-panel.visible { display: block; animation: fadeUp 0.25s ease; }
        @keyframes fadeUp { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:none} }

        .trim-panel-header {
            display: flex; align-items: center; justify-content: space-between;
            margin-bottom: 12px;
        }
        .trim-panel-title { font-size: 0.95rem; font-weight: 700; color: var(--trim-border); }
        .trim-times-row {
            display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
        }
        .trim-time-item {
            display: flex; align-items: center; gap: 6px;
            font-size: 0.88rem;
        }
        .trim-label-sm { color: var(--text-dim); font-size: 0.78rem; }
        .trim-val-lg {
            color: var(--trim-border); font-weight: 700; font-size: 1.05rem;
            font-variant-numeric: tabular-nums; font-family: 'SF Mono', Monaco, monospace;
        }
        .trim-dur-display {
            margin-left: auto;
            padding: 6px 16px; border-radius: 8px;
            background: var(--trim-color); color: var(--trim-border);
            font-weight: 700; font-size: 0.95rem; font-variant-numeric: tabular-nums;
        }

        /* Action buttons row */
        .action-bar {
            display: flex; gap: 10px; margin-top: 14px; justify-content: flex-end;
        }
        .btn-action {
            padding: 10px 22px; border-radius: 10px; border: none;
            font-size: 0.88rem; font-weight: 650; cursor: pointer;
            transition: all 0.2s; display: inline-flex; align-items: center; gap: 6px;
        }
        .btn-export {
            background: linear-gradient(135deg, var(--trim-border), #e55a2b);
            color: #fff; box-shadow: 0 3px 12px rgba(255,107,53,0.3);
        }
        .btn-export:hover { transform: translateY(-1px); box-shadow: 0 5px 20px rgba(255,107,53,0.45); }
        .btn-export:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
        .btn-clear {
            background: transparent; color: var(--text-dim);
            border: 1.5px solid var(--border);
        }
        .btn-clear:hover { border-color: #666; color: var(--text); }

        /* Loading overlay */
        .loading-overlay {
            position: absolute; inset: 0; display: flex; flex-direction: column;
            align-items: center; justify-content: center;
            background: rgba(18,18,26,0.92); z-index: 20; gap: 10px;
        }
        .spinner-l { width: 36px; height: 36px; border: 4px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
        @keyframes spin { to{transform:rotate(360deg)} }

        /* Error state */
        .error-msg {
            text-align: center; padding: 80px 20px; color: var(--text-dim);
        }
        .error-msg h2 { color: #ef4444; margin-bottom: 10px; }
    </style>
</head>
<body>

    <!-- Top Navigation -->
    <div class="topbar">
        <div class="topbar-left">
            <button class="back-btn" id="backBtn" title="返回">←</button>
            <span class="track-title" id="trackTitle">音频剪辑</span>
            <span class="track-badge" id="trackBadge"></span>
        </div>
        <div class="topbar-right">
            <a href="/" class="new-task-btn" title="上传新文件进行分析">🆕 新建任务</a>
        </div>
    </div>

<!-- Editor Content -->
<div class="editor-main">

    <!-- Transport Controls -->
    <div class="transport-bar">
        <button class="play-btn-large" id="playBtnLarge" onclick="togglePlay()" title="播放/暂停">▶</button>
        <div class="time-display">
            <div class="time-current" id="currentTimeDisplay">00:00.0</div>
            <div class="time-duration" id="durationDisplay">/ 00:00.0</div>
        </div>
        <div class="vol-group">
            <span class="vol-icon">🔊</span>
            <input type="range" class="vol-slider" min="0" max="150" value="100"
                   oninput="setVolume(this.value)">
        </div>
    </div>

    <!-- Waveform Section -->
    <div class="waveform-section">
        <div class="waveform-header">
            <span id="waveHint"><kbd>双击</kbd> 设置裁剪点 &nbsp; <kbd>单击</kbd> 标记处取消 / 跳转播放</span>
            <span id="fileInfo"></span>
        </div>
        <div class="wave-container" id="waveContainer"
             ondblclick="onDblClick(event)" onclick="onClick(event)">
            <canvas class="wave-canvas" id="waveCanvas"></canvas>
            <div class="playhead-line-e" id="plineE"></div>
            <div class="playhead-time-e" id="ptimeE">00:00.0</div>
            <div class="trim-overlay-e" id="trimOverlay"></div>
            <div class="trim-marker-e" id="markStartE"><span class="trim-tag-e" id="tagStartE">起点</span></div>
            <div class="trim-marker-e" id="markEndE"><span class="trim-tag-e" id="tagEndE">终点</span></div>
            <div class="loading-overlay" id="loadingOverlay">
                <div class="spinner-l"></div>
                <span style="color:var(--text-dim);font-size:0.88rem">加载音频波形...</span>
            </div>
        </div>
        <!-- Time ruler -->
        <div class="time-ruler" id="timeRuler"></div>
    </div>

    <!-- Trim Info Panel -->
    <div class="trim-panel" id="trimPanel">
        <div class="trim-panel-header">
            <span class="trim-panel-title">✂️ 裁剪范围</span>
        </div>
        <div class="trim-times-row">
            <div class="trim-time-item">
                <span class="trim-label-sm">起点</span>
                <span class="trim-val-lg" id="tpStartTime">--:--.-</span>
            </div>
            <span style="color:var(--text-dim);font-size:1.2rem">→</span>
            <div class="trim-time-item">
                <span class="trim-label-sm">终点</span>
                <span class="trim-val-lg" id="tpEndTime">--:--.-</span>
            </div>
            <div class="trim-dur-display" id="tpDuration">时长 0.0s</div>
        </div>
        <div class="action-bar">
            <button class="btn-action btn-clear" onclick="clearTrim()">取消选择</button>
            <button class="btn-action btn-export" id="exportBtn" onclick="exportTrim()">
                📥 导出剪裁音频
            </button>
        </div>
    </div>

    <!-- Error fallback -->
    <div class="error-msg" id="errorMsg" style="display:none">
        <h2>⚠️ 加载失败</h2>
        <p id="errorText"></p>
        <p style="margin-top:12px"><button class="back-btn" id="errorBackBtn" style="display:inline-flex">← 返回</button></p>
    </div>
</div>

<script>
// ============================================================
//  State
// ============================================================
let taskId = null;
let trackName = null;

// Audio player
let audioEl = null;
let waveformData = null;
let audioDuration = 0;
let isPlaying = false;
let audioContextUsed = null;

// Trim state
let trimStart = null;   // seconds or null
let trimEnd = null;     // seconds or null

const TRACK_COLORS = {
    vocals: '#a78bfa', accompaniment: '#93c5fd',
    drums: '#fca5a5', bass: '#86efac', piano: '#fde68a', other: '#c4b5fd'
};
const TRACK_LABELS = { vocals:'人声', accompaniment:'伴奏', drums:'鼓点', bass:'贝斯', piano:'钢琴', other:'其他' };

// ============================================================
//  Init — read URL params and load audio
// ============================================================
window.addEventListener('DOMContentLoaded', async () => {
    // Back button: return to home page
    const backBtn = document.getElementById('backBtn');
    if (backBtn) backBtn.addEventListener('click', () => window.location.href = '/');
    const errorBackBtn = document.getElementById('errorBackBtn');
    if (errorBackBtn) errorBackBtn.addEventListener('click', () => window.location.href = '/');

    const params = new URLSearchParams(window.location.search);
    taskId = params.get('task_id');
    trackName = params.get('track');

    if (!taskId || !trackName) {
        showError('缺少参数：需要 task_id 和 track');
        return;
    }

    const label = TRACK_LABELS[trackName] || trackName;
    const color = TRACK_COLORS[trackName] || '#818cf8';
    document.getElementById('trackTitle').textContent = label + ' · 剪辑';
    document.getElementById('trackBadge').textContent = trackName;
    document.getElementById('trackBadge').style.background = color + '22';
    document.getElementById('trackBadge').style.color = color;
    document.title = '✂️ ' + label + ' 剪辑';

    await loadAudio(taskId, trackName);
});

// ============================================================
//  Load Audio → Decode PCM → Draw Large Waveform
// ============================================================
async function loadAudio(tid, track) {
    try {
        // Verify task exists first
        const statusResp = await fetch('/api/status/' + tid);
        if (!statusResp.ok) {
            throw new Error('任务不存在或已清理');
        }

        const audioUrl = '/api/download/' + tid + '/' + encodeURIComponent(track);
        const resp = await fetch(audioUrl);
        if (!resp.ok) throw new Error('音轨文件不可用 (' + resp.status + ')');

        const arrayBuffer = await resp.arrayBuffer();
        const actx = new (window.AudioContext || window.webkitAudioContext)();
        audioContextUsed = actx;
        const decoded = await actx.decodeAudioData(arrayBuffer);
        const channelData = decoded.getChannelData(0);
        audioDuration = decoded.duration;

        // Downsample to ~2400 points for the large waveform
        const targetPoints = Math.min(Math.floor(channelData.length / 100), 2400);
        const blockSize = Math.floor(channelData.length / targetPoints);
        const waveData = [];
        for (let i = 0; i < targetPoints; i++) {
            let sum = 0;
            const start = i * blockSize;
            for (let j = 0; j < blockSize && start + j < channelData.length; j++) {
                sum += Math.abs(channelData[start + j]);
            }
            waveData.push(sum / blockSize);
        }
        const maxVal = Math.max(...waveData, 0.0001);
        waveformData = waveData.map(v => v / maxVal);

        // Create Audio element
        audioEl = new Audio(audioUrl);
        audioEl.preload = 'auto';
        audioEl.addEventListener('timeupdate', onTimeUpdate);
        audioEl.addEventListener('ended', onEnded);
        audioEl.addEventListener('loadedmetadata', () => {
            audioDuration = audioEl.duration || audioDuration;
            drawWaveform();
            drawTimeRuler();
            updateDurationDisplay();
        });

        // Hide loading, draw initial waveform
        document.getElementById('loadingOverlay').style.display = 'none';
        drawWaveform();
        drawTimeRuler();
        updateDurationDisplay();

        actx.close();

    } catch(err) {
        console.error('Load error:', err);
        showError(err.message);
    }
}

function showError(msg) {
    document.getElementById('loadingOverlay').style.display = 'none';
    document.getElementById('errorMsg').style.display = '';
    document.getElementById('errorText').textContent = msg;
}

// ============================================================
//  Draw Large Waveform (200px height)
// ============================================================
function drawWaveform() {
    if (!waveformData) return;
    const canvas = document.getElementById('waveCanvas');
    const container = document.getElementById('waveContainer');
    const ctx = canvas.getContext('2d');
    const dpr = window.devicePixelRatio || 1;
    let w = container.offsetWidth;
    let h = 200;

    if (w < 10) {
        requestAnimationFrame(drawWaveform);
        return;
    }

    canvas.width = w * dpr;
    canvas.height = h * dpr;
    canvas.style.width = w + 'px';
    canvas.style.height = h + 'px';
    ctx.scale(dpr, dpr);

    const data = waveformData, len = data.length;
    const color = TRACK_COLORS[trackName] || '#818cf8';
    const barW = Math.max(w / len - 0.4, 1), midY = h / 2;

    ctx.clearRect(0, 0, w, h);

    // Draw subtle grid lines
    ctx.strokeStyle = 'rgba(255,255,255,0.04)';
    ctx.lineWidth = 1;
    for (let g = 1; g < 5; g++) {
        const y = (g / 5) * h;
        ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
    }

    // Draw mirrored waveform bars
    for (let i = 0; i < len; i++) {
        const val = data[i];
        const barH = Math.max(val * (h * 0.43), 0.5);
        const x = (i / len) * w;
        const alpha = 0.2 + val * 0.8;
        ctx.fillStyle = hexToRgba(color, alpha * 0.55);
        ctx.fillRect(x, midY - barH, barW, barH);
        ctx.fillStyle = hexToRgba(color, alpha * 0.35);
        ctx.fillRect(x, midY, barW, barH);
    }

    refreshTrimVisuals();
}

function hexToRgba(hex, a) {
    const r = parseInt(hex.slice(1,3), 16);
    const g = parseInt(hex.slice(3,5), 16);
    const b = parseInt(hex.slice(5,7), 16);
    return 'rgba('+r+','+g+','+b+','+a+')';
}

window.addEventListener('resize', () => {
    clearTimeout(window._eresizeTimer);
    window._eresizeTimer = setTimeout(() => { drawWaveform(); drawTimeRuler(); }, 150);
});

// ============================================================
//  Time Ruler (below waveform)
// ============================================================
function drawTimeRuler() {
    const ruler = document.getElementById('timeRuler');
    ruler.innerHTML = '';
    if (!audioDuration || audioDuration <= 0) return;

    const w = ruler.offsetWidth || 800;
    const interval = calcRulerInterval(audioDuration);
    const totalMarks = Math.ceil(audioDuration / interval) + 1;

    for (let i = 0; i < totalMarks; i++) {
        const sec = i * interval;
        if (sec > audioDuration + 0.5) break;
        const pct = (sec / audioDuration) * 100;

        const tick = document.createElement('div');
        tick.className = 'ruler-tick';
        tick.style.left = pct + '%';
        ruler.appendChild(tick);

        const label = document.createElement('div');
        label.className = 'ruler-label';
        label.style.left = pct + '%';
        label.textContent = formatTimeShort(sec);
        ruler.appendChild(label);
    }
}

function calcRulerInterval(duration) {
    if (duration <= 15) return 1;
    if (duration <= 60) return 5;
    if (duration <= 180) return 10;
    if (duration <= 600) return 30;
    return 60;
}

function formatTimeShort(sec) {
    const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
    return m + ':' + String(s).padStart(2, '0');
}

// ============================================================
//  Playback Controls
// ============================================================
function togglePlay() {
    isPlaying ? pauseAudio() : playAudio();
}
function playAudio() {
    if (!audioEl) return;
    audioEl.play().catch(() => {});
    isPlaying = true;
    updatePlayUI(true);
}
function pauseAudio() {
    if (!audioEl) return;
    audioEl.pause();
    isPlaying = false;
    updatePlayUI(false);
}
function updatePlayUI(playing) {
    const btn = document.getElementById('playBtnLarge');
    const wc = document.getElementById('waveContainer');
    if (playing) {
        btn.textContent = '❚❚';
        btn.classList.add('playing');
        wc.classList.add('playing');
    } else {
        btn.textContent = '▶';
        btn.classList.remove('playing');
        wc.classList.remove('playing');
    }
}
function setVolume(val) {
    if (audioEl) audioEl.volume = Math.min(val / 100, 1.5);
}
function onEnded() {
    isPlaying = false;
    updatePlayUI(false);
}

// Click on waveform → seek
function seekToPosition(clientX) {
    if (!audioEl || !audioDuration) return;
    const container = document.getElementById('waveContainer');
    const rect = container.getBoundingClientRect();
    const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
    audioEl.currentTime = pct * audioDuration;
    updatePlayhead(pct);
}

function onTimeUpdate() {
    if (!audioEl || !audioDuration) return;
    const ct = audioEl.currentTime;
    const pct = ct / audioDuration;
    document.getElementById('currentTimeDisplay').textContent = formatTime(ct);
    document.getElementById('ptimeE').textContent = formatTime(ct);
    updatePlayhead(pct);
}

function updatePlayhead(pct) {
    const line = document.getElementById('plineE');
    if (line) line.style.left = (pct * 100) + '%';
}

function updateDurationDisplay() {
    document.getElementById('durationDisplay').textContent = '/ ' + formatTime(audioDuration || 0);
    document.getElementById('fileInfo').textContent = '时长 ' + formatTime(audioDuration || 0);
}

function formatTime(sec) {
    const m = Math.floor(sec / 60), s = Math.floor(sec % 60), ms = Math.floor((sec % 1) * 10);
    return String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0') + '.' + ms;
}

// ============================================================
//  ✂️ TRIM / CUT — Double-click set points, click to cancel
// ============================================================

/** Double-click → set START then END, then reset cycle */
function onDblClick(event) {
    event.preventDefault();
    event.stopPropagation();
    if (!audioDuration) return;

    const container = document.getElementById('waveContainer');
    const rect = container.getBoundingClientRect();
    const clickPct = (event.clientX - rect.left) / rect.width;
    const clickSec = clickPct * audioDuration;

    if (trimStart === null) {
        // First click → set START
        trimStart = clickSec;
        trimEnd = null;
    } else if (trimEnd === null) {
        // Second click → set END (must be >= start)
        if (clickSec >= trimStart) {
            trimEnd = clickSec;
        } else {
            // Clicked before start → swap as new start
            trimStart = clickSec;
        }
    } else {
        // Both already set → reset, this becomes new START
        trimStart = clickSec;
        trimEnd = null;
    }

    refreshTrimVisuals();
    refreshTrimPanel();
}

/** Single click → if near marker, cancel that marker; otherwise seek */
function onClick(event) {
    if (trimStart === null && trimEnd === null) {
        seekToPosition(event.clientX);
        return;
    }
    if (!audioDuration) return;

    const container = document.getElementById('waveContainer');
    const rect = container.getBoundingClientRect();
    const clickPct = (event.clientX - rect.left) / rect.width;
    const clickSec = clickPct * audioDuration;
    const tolerance = audioDuration * 0.025;

    if (trimEnd !== null && Math.abs(clickSec - trimEnd) < tolerance) {
        trimEnd = null;
    } else if (Math.abs(clickSec - trimStart) < tolerance) {
        trimStart = null;
        trimEnd = null;
    } else {
        seekToPosition(event.clientX);
        return;
    }

    refreshTrimVisuals();
    refreshTrimPanel();
}

function clearTrim() {
    trimStart = null;
    trimEnd = null;
    refreshTrimVisuals();
    refreshTrimPanel();
}

// ============================================================
//  Trim Visual Overlays on Waveform
// ============================================================
function refreshTrimVisuals() {
    const overlay = document.getElementById('trimOverlay');
    const mStart = document.getElementById('markStartE');
    const mEnd = document.getElementById('markEndE');
    const tStart = document.getElementById('tagStartE');
    const tEnd = document.getElementById('tagEndE');

    if (trimStart !== null && trimEnd !== null) {
        const sp = (trimStart / audioDuration) * 100;
        const ep = (trimEnd / audioDuration) * 100;
        overlay.style.left = sp + '%';
        overlay.style.width = (ep - sp) + '%';
        overlay.classList.add('visible');

        mStart.style.left = sp + '%'; mStart.classList.add('visible');
        tStart.textContent = formatTime(trimStart); tStart.classList.add('visible');
        mEnd.style.left = ep + '%'; mEnd.classList.add('visible');
        tEnd.textContent = formatTime(trimEnd); tEnd.classList.add('visible');
    } else if (trimStart !== null) {
        const sp = (trimStart / audioDuration) * 100;
        overlay.classList.remove('visible');
        mStart.style.left = sp + '%'; mStart.classList.add('visible');
        tStart.textContent = formatTime(trimStart); tStart.classList.add('visible');
        mEnd.classList.remove('visible'); tEnd.classList.remove('visible');
    } else {
        overlay.classList.remove('visible');
        mStart.classList.remove('visible'); tStart.classList.remove('visible');
        mEnd.classList.remove('visible'); tEnd.classList.remove('visible');
    }
}

// ============================================================
//  Trim Panel (info + export button area)
// ============================================================
function refreshTrimPanel() {
    const panel = document.getElementById('trimPanel');
    const sl = document.getElementById('tpStartTime');
    const el = document.getElementById('tpEndTime');
    const dl = document.getElementById('tpDuration');

    if (trimStart !== null && trimEnd !== null) {
        sl.textContent = formatTime(trimStart);
        el.textContent = formatTime(trimEnd);
        const dur = trimEnd - trimStart;
        dl.textContent = '时长 ' + dur.toFixed(1) + 's';
        panel.classList.add('visible');
    } else if (trimStart !== null) {
        sl.textContent = formatTime(trimStart);
        el.textContent = '等待终点…';
        dl.textContent = '';
        panel.classList.add('visible');
    } else {
        panel.classList.remove('visible');
    }
}

// ============================================================
//  📥 Export Trimmed Audio
// ============================================================
async function exportTrim() {
    if (!taskId || trimStart === null || trimEnd === null) {
        alert('请先在波形上双击设置裁剪的起止时间');
        return;
    }

    const btn = document.getElementById('exportBtn');
    const origText = btn.innerHTML;
    btn.innerHTML = '⏳ 导出中…';
    btn.disabled = true;

    try {
        const params = new URLSearchParams({
            task_id: taskId,
            track_name: trackName,
            start_time: trimStart.toFixed(3),
            end_time: trimEnd.toFixed(3)
        });
        const resp = await fetch('/api/trim?' + params, { method: 'POST' });

        if (!resp.ok) {
            const err = await resp.json();
            throw new Error(err.detail || '裁剪失败');
        }

        const blob = await resp.blob();
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        const label = TRACK_LABELS[trackName] || trackName;
        a.download = label + '_' +
            formatTime(trimStart).replace(/[:.]/g, '') + '-' +
            formatTime(trimEnd).replace(/[:.]/g, '') + '.mp3';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);

    } catch(err) {
        alert('导出失败: ' + err.message);
    } finally {
        btn.innerHTML = origText;
        btn.disabled = false;
    }
}
</script>

</body>
</html>


path: rules/server.py
content: """
音频分离 Web 服务 - AI 智能分轨
输入视频/音频 → 分离人声和伴奏 → 在线预览 + 下载
"""

import os
import shutil
import subprocess
import asyncio
import tempfile
import json
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
import uvicorn

app = FastAPI(title="🎵 音频分离工具 - AI 智能分轨", version="1.0.0")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

# ===== 配置 =====
UPLOAD_DIR = Path("uploads")
OUTPUT_DIR = Path("outputs")
HISTORY_FILE = Path("history.json")
UPLOAD_DIR.mkdir(exist_ok=True)
OUTPUT_DIR.mkdir(exist_ok=True)

# 任务状态存储: {task_id: {"status", "progress", "result"}}
tasks = {}


def load_history():
    """从 history.json 加载已完成任务记录"""
    if HISTORY_FILE.exists():
        try:
            return json.loads(HISTORY_FILE.read_text("utf-8"))
        except Exception:
            pass
    return []


def save_history(record):
    """追加一条历史记录到 history.json"""
    records = load_history()
    # 避免重复（同 task_id 只保留最新）
    records = [r for r in records if r.get("task_id") != record["task_id"]]
    records.insert(0, record)  # 最新的排前面
    # 最多保留 50 条
    records = records[:50]
    HISTORY_FILE.write_text(json.dumps(records, ensure_ascii=False, indent=2), "utf-8")


def rebuild_tasks_from_history():
    """启动时从 outputs 目录 + history.json 恢复任务状态到内存"""
    global tasks
    history = load_history()
    for rec in history:
        tid = rec.get("task_id")
        out_dir = OUTPUT_DIR / tid
        if out_dir.exists() and tid not in tasks:
            # 从磁盘重建 task 状态（标记为 completed）
            tracks = {}
            for mp3 in out_dir.glob("*.mp3"):
                tracks[mp3.stem] = mp3.name
            tasks[tid] = {
                "status": "completed",
                "progress": 100,
                "message": "Done!",
                "model": rec.get("model", "htdemucs"),
                "stems": rec.get("stems", "2stems"),
                "filename": rec.get("filename", "unknown"),
                "result": {"tracks": tracks, "output_dir": str(out_dir)},
            }
    # 同时扫描 outputs 目录中存在但不在 history 中的目录（兜底）
    if OUTPUT_DIR.exists():
        for d in OUTPUT_DIR.iterdir():
            if d.is_dir() and d.name not in tasks:
                tracks = {}
                for mp3 in d.glob("*.mp3"):
                    tracks[mp3.stem] = mp3.name
                if tracks:
                    tasks[d.name] = {
                        "status": "completed",
                        "progress": 100,
                        "message": "Done!",
                        "model": "unknown",
                        "stems": "unknown",
                        "filename": d.name,
                        "result": {"tracks": tracks, "output_dir": str(d)},
                    }

# 支持的模型
MODELS = {
    "htdemucs": "推荐模式（均衡质量与速度）",
    "htdemucs_ft": "高品质模式（更精细分离）",
    "demucs": "经典模式",
    "mdx": "人声增强模式",
    "mdx_extra": "增强模式",
}

# 支持的分离模式（输出轨道）
STEMS_CONFIG = {
    "2stems": {"description": "2轨：人声 + 伴奏", "tracks": ["vocals", "accompaniment"]},
    "4stems": {"description": "4轨：人声 + 鼓 + 贝斯 + 其他", "tracks": ["vocals", "drums", "bass", "other"]},
    "5stems": {"description": "5轨：人声 + 鼓 + 贝斯 + 钢琴 + 其他", "tracks": ["vocals", "drums", "bass", "piano", "other"]},
}


@app.get("/")
async def index():
    """返回着陆页（首页）"""
    return FileResponse("static/landing.html")


@app.get("/app.html")
async def app_page():
    """返回音频分离工具主页面"""
    return FileResponse("static/index.html")


@app.get("/editor")
async def editor_page():
    """返回音频剪辑编辑器页面"""
    return FileResponse("static/editor.html")


@app.get("/api/status/{task_id}")
async def get_status(task_id: str):
    """查询任务状态"""
    if task_id not in tasks:
        raise HTTPException(status_code=404, detail="任务不存在")
    return tasks[task_id]


@app.get("/api/history")
async def get_history():
    """获取历史任务列表（已完成的，最新的在前）"""
    history = load_history()
    # 标记每个任务在磁盘上是否仍然存在
    result = []
    for rec in history:
        tid = rec.get("task_id", "")
        out_dir = OUTPUT_DIR / tid
        rec["exists"] = out_dir.exists()
        result.append(rec)
    return result


@app.get("/api/download/{task_id}/{track_name}")
async def download_track(task_id: str, track_name: str):
    """下载分离后的音轨文件"""
    task_dir = OUTPUT_DIR / task_id
    if not task_dir.exists():
        raise HTTPException(status_code=404, detail="任务目录不存在")

    # 查找匹配的文件（支持 mp3/wav）
    for ext in [".mp3", ".wav"]:
        file_path = task_dir / f"{track_name}{ext}"
        if file_path.exists():
            return FileResponse(
                path=str(file_path),
                media_type="audio/mpeg" if ext == ".mp3" else "audio/wav",
                filename=f"{track_name}{ext}",
            )

    raise HTTPException(status_code=404, detail=f"音轨 {track_name} 不存在")


# 裁剪输出目录
TRIM_DIR = Path("trims")
TRIM_DIR.mkdir(exist_ok=True)


from fastapi.responses import Response
import uuid


@app.post("/api/trim")
async def trim_audio(
    task_id: str = None,
    track_name: str = None,
    start_time: float = 0.0,
    end_time: float = 0.0,
):
    """
    裁剪指定音轨的音频片段
    参数: task_id, track_name, start_time(秒), end_time(秒)
    返回: 裁剪后的 MP3 文件下载
    """
    if not task_id or not track_name:
        raise HTTPException(status_code=400, detail="缺少 task_id 或 track_name")
    if end_time <= start_time:
        raise HTTPException(status_code=400, detail="结束时间必须大于开始时间")

    # 查找源文件
    task_dir = OUTPUT_DIR / task_id
    src_path = None
    for ext in [".mp3", ".wav"]:
        p = task_dir / f"{track_name}{ext}"
        if p.exists():
            src_path = p
            break

    if not src_path:
        raise HTTPException(status_code=404, detail=f"音轨 {track_name} 文件不存在")

    # 用 ffmpeg 裁剪
    trim_id = uuid.uuid4().hex[:12]
    output_path = TRIM_DIR / f"{trim_id}_{track_name}_trim.mp3"

    duration = end_time - start_time

    try:
        subprocess.run([
            "ffmpeg", "-y",
            "-i", str(src_path),
            "-ss", str(start_time),
            "-t", str(duration),
            "-acodec", "libmp3lame", "-ab", "320k",
            str(output_path),
        ], check=True, capture_output=True)
    except subprocess.CalledProcessError as e:
        raise HTTPException(status_code=500, detail="ffmpeg 裁剪失败: " + str(e))

    if not output_path.exists():
        raise HTTPException(status_code=500, detail="裁剪输出文件未生成")

    return FileResponse(
        path=str(output_path),
        media_type="audio/mpeg",
        filename=f"{track_name}_{format_duration(start_time)}_{format_duration(end_time)}.mp3",
    )


def format_duration(seconds: float) -> str:
    """将秒数格式化为 mm:ss"""
    m = int(seconds) // 60
    s = int(seconds) % 60
    return f"{m:02d}{s:02d}"


@app.post("/api/separate")
async def separate_audio(
    background_tasks: BackgroundTasks,
    file: UploadFile = File(...),
    model: str = "htdemucs",
    stems: str = "2stems",
):
    """
    上传视频/音频文件，启动后台分离任务
    返回 task_id 用于轮询状态
    """
    # 校验参数
    if model not in MODELS:
        raise HTTPException(status_code=400, detail=f"不支持的模式: {model}，可选: {list(MODELS.keys())}")
    if stems not in STEMS_CONFIG:
        raise HTTPException(status_code=400, detail=f"不支持的轨道数: {stems}")

    # 生成任务 ID
    import uuid
    task_id = uuid.uuid4().hex[:12]

    # 保存上传文件
    suffix = Path(file.filename).suffix if file.filename else ".mp4"
    input_path = UPLOAD_DIR / f"{task_id}{suffix}"
    with open(input_path, "wb") as f:
        content = await file.read()
        f.write(content)

    # 初始化任务状态
    tasks[task_id] = {
        "status": "pending",
        "progress": 0,
        "message": "排队中...",
        "model": model,
        "stems": stems,
        "filename": file.filename or "unknown",
        "result": None,
    }

    # 后台执行分离
    background_tasks.add_task(run_demucs, task_id, str(input_path), model, stems)

    return JSONResponse({
        "task_id": task_id,
        "status": "pending",
        "message": "任务已提交，请用 task_id 轮询进度",
    })


def run_demucs(task_id: str, input_path: str, model: str, stems: str):
    """Run demucs separation in background"""
    output_base = OUTPUT_DIR / task_id

    try:
        tasks[task_id].update({"status": "processing", "progress": 5, "message": "Extracting audio..."})

        # If video, extract audio first with ffmpeg
        is_video = input_path.lower().endswith((".mp4", ".mov", ".avi", ".mkv", ".flv"))
        audio_for_demucs = input_path

        if is_video:
            wav_path = str(OUTPUT_DIR / task_id / "source.wav")
            output_base.mkdir(parents=True, exist_ok=True)
            subprocess.run([
                "ffmpeg", "-y", "-i", input_path,
                "-vn", "-acodec", "pcm_s16le", "-ar", "44100", "-ac", "2",
                wav_path,
            ], check=True, capture_output=True)
            audio_for_demucs = wav_path
            tasks[task_id].update({"progress": 15, "message": "Audio extracted, loading model..."})
        else:
            output_base.mkdir(parents=True, exist_ok=True)

        # Run demucs
        tasks[task_id].update({"progress": 20, "message": "Running " + model + " separation..."})

        cmd = [
            "python3", "-m", "demucs",
            "-n", model,
            "--mp3", "--mp3-bitrate", "320",
            "-o", str(output_base),
            audio_for_demucs,
        ]

        process = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
        )

        # Read progress in real-time
        # Demucs output may not contain "Processing"/"segment" keywords exactly,
        # so we track: any stdout activity = model is working, slowly advance progress
        line_count = 0
        for line in process.stdout:
            line_count += 1
            stripped = line.strip()
            print("[demucs] " + stripped)
            
            # Advance progress on any output (20 -> 85 range during separation)
            if stripped:
                # Slow ramp: first 30 lines = 20->40%, then gradual to 80%
                new_prog = min(20 + int(line_count * 1.5), 85)
                if new_prog > tasks[task_id]["progress"]:
                    tasks[task_id].update({
                        "progress": new_prog,
                        "message": "Separating... " + str(new_prog) + "% (" + str(line_count) + " lines processed)",
                    })

        process.wait()

        if process.returncode != 0:
            raise Exception("demucs failed with code " + str(process.returncode))

        # Find output files and copy to friendly names
        stem_info = STEMS_CONFIG[stems]
        result_tracks = {}

        # demucs outputs to: {output_base}/{model}/{original_filename}/
        # search recursively for the actual mp3 files
        demucs_out_dir = None
        model_dir = output_base / model

        if model_dir.exists():
            # Strategy 1: find folder containing vocals.mp3
            for candidate in model_dir.rglob("vocals.mp3"):
                demucs_out_dir = candidate.parent
                break
            # Strategy 2: find any mp3 under model dir
            if not demucs_out_dir:
                for candidate in model_dir.rglob("*.mp3"):
                    demucs_out_dir = candidate.parent
                    break

        # Fallback: search entire output dir
        if not demucs_out_dir:
            for mp3_file in output_base.rglob("*.mp3"):
                demucs_out_dir = mp3_file.parent
                break

        if demucs_out_dir:
            print("[demucs] Found outputs in: " + str(demucs_out_dir))
            # List all actual mp3 files demucs produced
            actual_files = {mp3_file.stem: mp3_file for mp3_file in demucs_out_dir.glob("*.mp3")}
            print("[demucs] Actual output tracks: " + str(list(actual_files.keys())))

            # Mapping for when demucs output doesn't match stems config exactly
            # e.g. htdemucs outputs [vocals, drums, bass, other] but user wants 2stems [vocals, accompaniment]
            FALLBACK_MAP = {
                "accompaniment": ["other", "no_vocals", "instrumental"],
                "no_vocals": ["other", "accompaniment", "instrumental"],
                "instrumental": ["other", "accompaniment", "no_vocals"],
            }

            expected_tracks = stem_info["tracks"]
            for track_name in expected_tracks:
                src = None
                # Try direct name first
                direct = demucs_out_dir / (track_name + ".mp3")
                if direct.exists():
                    src = direct
                else:
                    # Try fallback mapping
                    fallback_names = FALLBACK_MAP.get(track_name, [])
                    for fb_name in fallback_names:
                        fb_path = demucs_out_dir / (fb_name + ".mp3")
                        if fb_path.exists():
                            print("[demuc    s] Mapped '" + track_name + "' -> actual file '" + fb_name + ".mp3'")
                            src = fb_path
                            break

                if src:
                    dst = output_base / (track_name + ".mp3")
                    shutil.copy2(str(src), str(dst))
                    result_tracks[track_name] = track_name + ".mp3"
                    print("[demucs] Copied: " + track_name + ".mp3")
                else:
                    print("[demucs] Track not found (and no fallback): " + track_name)

        # Done
        tasks[task_id].update({
            "status": "completed",
            "progress": 100,
            "message": "Done!",
            "result": {
                "tracks": result_tracks,
                "output_dir": str(output_base),
            },
        })

        # 持久化到历史记录
        save_history({
            "task_id": task_id,
            "filename": tasks[task_id].get("filename", "unknown"),
            "model": model,
            "stems": stems,
            "status": "completed",
            "track_count": len(result_tracks),
            "created_at": __import__("datetime").datetime.now().isoformat(),
        })

    except Exception as e:
        tasks[task_id].update({
            "status": "error",
            "progress": -1,
            "message": "Error: " + str(e),
        })


@app.delete("/api/cleanup/{task_id}")
async def cleanup_task(task_id: str):
    """清理任务文件"""
    task_dir = OUTPUT_DIR / task_id
    upload_file = list(UPLOAD_DIR.glob(f"{task_id}.*"))

    count = 0
    if task_dir.exists():
        shutil.rmtree(task_dir)
        count += 1
    for f in upload_file:
        f.unlink()
        count += 1

    if task_id in tasks:
        del tasks[task_id]

    return JSONResponse({"message": f"已清理 {count} 个文件"})


app.mount("/static", StaticFiles(directory="static"), name="static")

if __name__ == "__main__":
    # 确保静态文件目录存在
    static_dir = Path(__file__).parent / "static"
    static_dir.mkdir(exist_ok=True)

    # 启动时从磁盘恢复历史任务到内存
    rebuild_tasks_from_history()
    print(f"[init] 已从历史记录恢复 {len(tasks)} 个任务")

    print("""
╔══════════════════════════════════════════════╗
║   🎵 音频分离工具 - AI 智能分轨              ║
║   http://127.0.0.1:8765                       ║
╚══════════════════════════════════════════════╝
    """)
    uvicorn.run(app, host="127.0.0.1", port=8765)


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)