人力VOCAL 多轨编辑器(Linux 原生,无 Wine)。参考 REAPER 的工作流: 多轨时间线、素材块拖动/裁剪/拉伸/变调、MIDI 参考轨、BPM 网格、走带混音。
语言:Python(WORLD/PSOLA 引擎 + 本地服务)+ JavaScript(canvas 多轨时间线 UI)。
DSP 是"分析 → 改参数 → 重合成"架构,
与 VocalShifter/Melodyne 同构,没有 elastique 那类实时算法的痰音问题。
渲染引擎优先级:Rubber Band R3(装了 rubberband-cli 自动启用,真实人声最佳)> Praat PSOLA(当前默认)> WORLD。
建议安装:sudo apt install rubberband-cli(装完刷新页面自动切换,渲染缓存按引擎区分不会串味)。
多轨时间线(素材块拖动/裁剪/拉伸/变调)· MIDI 参考轨 + 🌀 轨道路由接管(块标 [MIDI接管])· 轨道 FX 与参数包络(黄绿曲线)· MASTER 总线恒贴底部(可折叠,包络车道跟着贴底)
cd vocal-editor
./run.sh # 会先跑依赖自检 (ve_env.py), 再起服务并自动开浏览器
# 或手动: .venv/bin/python server.py → http://127.0.0.1:8765依赖自检(缺什么会直接列出来,不会静默半残):
.venv/bin/python ve_env.py # 打印体检表 (Python/numpy/soundfile/引擎/ffmpeg/rubberband/数据目录)
.venv/bin/python ve_env.py --strict # 自检不过退出码 2 (脚本里用)首次建环境(已建好可跳过):
uv venv .venv --python 3.12
uv pip install --python .venv/bin/python -r requirements.txt
uv pip install --python .venv/bin/python --no-deps praat-parselmouth # 1.x 元数据引用损坏的 googleads坑:parselmouth 1.x 依赖元数据引用损坏的 googleads,必须
--no-deps; pyworld 需要 pkg_resources,setuptools 必须 < 81。 系统命令:ffmpeg必需(导入 mp3/mp4),rubberband可选(apt install rubberband-cli,装了音质更好)。
uv pip install --python .venv/bin/python pyinstaller imageio-ffmpeg # 打包机一次性准备
./build_appimage.sh # → dist/VocalEditor-x86_64.AppImage
./VocalEditor-x86_64.AppImage --check # 目标机先自检, 缺什么直接列出来启动方式(launch.sh 同一套,源码运行 ./run.sh 也一样):
./VocalEditor-x86_64.AppImage # 原生窗口 (GTK+WebKit2) → 退回 chromium --app= → 退回浏览器
./VocalEditor-x86_64.AppImage --chromium # 强制用 Chromium 应用窗口 (声音最稳)
./VocalEditor-x86_64.AppImage --browser # 用默认浏览器
./VocalEditor-x86_64.AppImage --no-window # 只起服务 (放服务器/远程访问)
./VocalEditor-x86_64.AppImage --probe # 看 8765 上是自己实例 / 别的程序 / 空闲- 打进包里的:Python 3.12 + numpy/soundfile/pyworld/parselmouth + 静态 ffmpeg(系统 ffmpeg 链了上百个 .so,换机器必挂)+ rubberband 及其依赖库 + 界面/示例工程。
- 数据目录:打包运行时可写数据自动放到
~/.local/share/vocal-editor/{work,projects}(AppImage 是只读挂载);可用VE_DATA_DIR覆盖。源码运行位置不变(还是仓库里的work/、projects/)。 - glibc 是打包机上的版本(不打 glibc):在很新的发行版上打包,老发行版可能打不开 —— 要兼容老系统就在老的系统/容器里打包。打不开时先跑
--check。
| 操作 | 方式 |
|---|---|
| 播放/暂停(走带) | Space 或工具栏 |
| 建素材 | 导入音频/MIDI(落在播放头、按网格吸附) |
| 移动块 | 拖块(可跨轨,按 BPM 网格吸附) |
| 裁剪 | 拖块左右缘 |
| 拉伸 | Ctrl+拖右缘(WORLD 变速不变调) |
| 变调 | 选中块按 = / -(±1 半音),+ / _ ±12;MIDI 块同样支持(转调后 whip 目标轨自动重渲染,重置变调回零) |
| MIDI 块裁剪/缩放 | 与音频块同套交互:拖左/右缘 = 裁剪(块外音符不显示不发声),Alt+拖右缘 = 缩放(音符时值跟着伸缩);= / - 转调 |
| 多选 | Ctrl+点击 / 右键加选 / 右键拖拽框选;多选后拖动 = 整组一起移动换轨,变调/对齐/删除作用于全部 |
| 切开 | 选中块 + S(在播放头处) |
| 删除 | Delete |
| MIDI 跟音(音高接管) | whip = 轨道→轨道的持续路由(REAPER send 逻辑):连上后该 MIDI 轨在时间轴绝对时刻上定义一条音高曲线,目标轨上任何素材块(现有/新导入/拖上来的)的窗口取这条曲线的一段 —— 按绝对时间对齐(MIDI 块放 5s,音频块也得在 5s 处才对得上);每个 MIDI 块只在自己那块区间内接管(块是离散的:出块就交还素材原音高,两块之间的空隙不会被上一块的尾巴吊住);素材自身的节奏/辅音/换气保留,pitch 全部由 MIDI 接管(曲线覆盖之外沿用素材原音高;拉伸块按 Rate 换算曲线);MIDI 块拉伸后音符跟着缩放;移动素材/编辑 MIDI 自动重渲染(块上显示 [MIDI接管]),断开 whip 恢复原 pitch |
| 自由变调轨(±) | 轨道面板 ± 按钮 / 右键轨道行:该轨所有素材永不被 MIDI 接管(不做音高矫正),= / - 就是普通升降调 —— 只变调、不变速,且零渲染等待(浏览器内相位声码器即时算,按下即刻生效;窗口 > 24s 才交给后端同算法渲染);whip 拖到该轨会被拒绝并提示;BGM/伴奏轨的常用档 |
| 脱离接管的三种粒度 | 整轨 = 右键轨道行(或面板 ±)→「自由变调轨」(= / - 为普通变调,保时长·不变速);单个块 = 右键块→「不接管 MIDI pitch (瞬时变速变调)」(= / - 走 varispeed,音高与时长一起变);整首 = 右键 🌀 断开 |
| 循环区 | Shift+拖标尺,勾「循环」生效 |
| 缩放 | Ctrl+滚轮(以光标为中心),-/+/「适应」 |
| 撤销/重做 | Ctrl+Z / Ctrl+Y |
| 轨道面板 | 改名、音量、左右声道、± 自由变调轨、♩ 跟随BPM、FX、M/S(实时影响播放与混音);右键轨道行 = 轨道菜单(自由变调轨 / 跟随BPM / 断开 whip / 删除轨道) |
| 保存/打开工程 | JSON 非破坏式(素材引用原文件,参数挂在块上);会话自动存在本机(localStorage),刷新不丢;File→「新建空工程」清空 |
| 导出混音 | 按 轨道音量/声像/M/S 混为立体声 wav(变速块按 Rate 重采样,与播放听感一致) |
演示:启动自动加载演示工程(跑调干声×2 + 参考MIDI),把「参考MIDI」轨的 🌀 拖到「人声」轨即建立接管,点播放可听效果。
-
钢琴窗标尺定位:点按/拖动钢琴窗顶部标尺 = 播放头跳到该绝对时刻(与外侧时间线同一坐标系——MIDI 块在 5s 处,钢琴窗点哪、外侧播放头就跳哪,方便人声对轨);顺手修掉"点标尺会误建音符"的老问题(标尺与左侧键位列现在都不再生成音符)
-
音符时长不再被块长截断:拖右缘可无限拉长,音符越出块尾时 MIDI 块自动加长(画得出 = 显示得出 = 听得到);整组移动越界同理
-
FL 式滑音语义端到端修正:
- 修 BUG① 客户端
whipNotesFor丢掉了slide标记 → 后端收到的全是普通音符,滑音从未生效 - 修 BUG② 后端滑音跨度结束后会弹回底下长音的原音高 → 现在滑完保持滑音目标音高直到下一个事件
- 语义:一个长音键可叠任意多个滑音键;滑音键在自身跨度内从"该刻当前音高"线性滑向自身音高,键越长滑得越慢;无事件覆盖处沿用素材原 F0
- 渲染键含 slide(
RENDER_VERr7→r8,旧缓存自动失效);块上橙色键用连线画出真实滑音轨迹;钢琴窗试听(chip) 改用同一曲线的连续变频,不用等渲染就能听到滑音
- 修 BUG① 客户端
-
修 BUG③ 渲染与钢琴窗不一致(滑音横跨开头几键):
whipNotesFor把音符时间硬钳进[0, 块窗口],于是块头之前就已开始、已经滑到一半的滑音键被拽到 0 → 变成"从块头重新滑一遍",听感上开头多出一条横跨前几键的滑音;而钢琴窗 chip 按真实时间算,所以 chip 是对的。现在不再钳制(保留可为负的起点、也不截断终点;后端_cmd_pitch只依赖t ≤ tt的事件,负起点能正确求出"滑到一半"的音高),并补了"整条 MIDI 曲线都在窗口之外 → 不接管、沿用素材原音高"的保护。实测窗口 t=0.02/0.30/0.45s 的渲染 F0 = 62.88/64.71/65.76 半音,与 chip 期望 62.68/64.73/65.78 一致(修复前为 60.42/63.33,明显错位)附带说明:whip 是轨道级路由(REAPER send 语义),该 MIDI 轨上所有块都按时间轴绝对时刻参与,但每个块只在自己那块区间内接管(出块交还原音高)。所以同一条 MIDI 轨上留着的旧测试块,只要与某音频块在时间上重叠,仍会一起参与 —— 用不到的建议删掉
-
防误选文本(Web 端 canvas 通病):整页默认可选中(实测 1176 字符)→
Ctrl+A一按选中整页、双击选中词、画布拖动划过文字也产生选区,接着一拖就变成"拖文本"把拖块顶掉。现在:全局user-select:none(只给输入控件与状态栏/工程名保留可选中)、掐掉页面内原生dragstart(输入框内保留)、非输入焦点时屏蔽Ctrl+A;拖文件导入的高亮提示也只在真的拖文件时出现。实测Ctrl+A/双击后选中字符数 = 0,拖块正常,输入框内选择与空格输入不受影响 -
节拍网格全局拟合 —— "接不上"的真正根因(踩坑留档, 最关键的一条)
- 客观检验: 把强打击乐峰按"拍内相位"做 10 格直方图。老实现(自相关+先验)得到的网格, 直方图是 73 61 61 50 52 67 62 62 63 60 = 完全均匀(集中度 R=0.04, 等于随机) ✗ —— 也就是网格跟音乐没有任何对齐关系, 那么小节线/乐句/标记全是错的, 用户"体感接不上"完全正确。
- 真因: BPM 偏 1%。老实现报 129.199, 真值 127.988; 260 秒漂 5 拍, 网格与音乐脱钩 ✗ (之前的 F1=0.61 是宽容差指标, 掩盖了这个问题 ✗)。
- 改法: 全局拟合 (BPM, 相位)。粗到细搜索, 目标 = "拍内相位直方图 峰/均值" × 轻度先验(压 70 以下半速陷阱)。
- 该指标一刀切开真假: 真歌 3.9~8.1 ✓ / 纯人声 1.85 ✗ / 渲染片段 1.3 ✗ → <2.0 直接拒答
- 必须用全部峰算 (踩过: 先按拟合度筛峰再算, 任何网格都会"看起来对齐" ✗)
- 天然排除半速 (半速时峰裂成 0/0.5 两簇 → 最强格下降 → 比值掉) ✓
- 结果: BPM 127.988 (同一首歌两次独立导入完全一致 ✓), 直方图 154 0 0 0 84 47 0 6 23 147 (峰落在正拍与后半拍 = 音乐正确 ✓✓), 质量 5.38。两首歌(260s/330s)都 ✓, 纯人声素材明确拒答 ✓。
- 非音乐素材不再硬给标记: 质量<2.0 时返回 warning 并清空 bars/phrases/markers, 界面直接说 "检测不到稳定节拍(可能是纯人声/自由速度/无鼓素材), 不做结构分析" ✓
-
重拍(downbeat)相位判定 —— 接串烧的关键(踩坑记录)
- 症状: 两个橙色标记对切后接不上。根因是"4 个拍相位里哪个是小节第 1 拍"判错了 → 标记不落在真正的小节线上。
- 试过并失败的判据(留档): onset/低音流量均值(标记处强度只有平均拍 1.14 倍, 等于随机 ✗)、 全曲底鼓/军鼓绝对值均值(主歌副歌鼓型不同, 一平均就洗掉 ✗)、全带/低音带 chroma 变化(1.29/1.89 倍, 偏弱 ✗)。
- 真正管用的是**"能量上跳"**: 每拍取 军鼓带(150~400Hz)/低频/底鼓 的能量均值, 看它相对上一拍涨了多少 —— 小节第 1 拍整拍最满, 涨幅最大。实测判别力 6.9 倍 / 5.8 倍, 两首歌都指向与鼓型一致的相位 ✓。
- 标记时间吸附到起音的攻击起点(顺着上升沿回退, 不是按阈值瞎退 —— 按阈值会退进静音里, 实测标记落到 onset≈0.008 ✗);
现在"红/橙"标记的起音强度中位 0.29
0.31、最小 0.110.16 (全曲平均只有 0.03) ✓。 - 起音强度 <0.05 的边界硬性封顶黄色 —— 没落在重音上就切不齐, 不该显示成橙/红 ✗。
- 分析结果里的
downbeatStrength(最优/次优相位之分) = 判得准不准: ≥1.6 明确, <1.4 不确定(界面会提示, 此时慎用标记)。
-
变调 = 绝对只变调, 永不变速(踩坑记录, 反复被反馈过)
=/-(半音)任何情况下都不许改c.stretch/c.length。老实现里有两条 varispeed 分支 (c.bypassWhip的块、窗口 >240s 的块) 会做stretch /= 2^(n/12)且length = win*stretch, 于是"一按 = 曲子被拉长/压短"; 更坏的是stretch一旦 ≠1, 自由变调轨那句Math.abs(stretch-1)<1e-4就永久不成立 → 该块永远停在错误速度上, 之后按什么都"还是变速"。- 现在四条路径全部保时长(实测): 自由变调轨短素材(浏览器声码器) 4.300s / 自由变调轨长素材(后端渲染)
40.00s / 老 varispeed 块(bypassWhip) 10.00s 不动 / >240s 窗口明确跳过并提示(不再偷偷变速)。
后端
dsp.process(pitch=n, stretch=1)时长比 1.0000、F0 比 0.7936/1.1895/1.4985 vs 理论 0.7937/1.1892/1.4983。 - 变速只允许来自 Alt+拖动(REAPER 式 rate) 或显式拉伸/跟随 BPM(♩), 这两处是设计如此。
-
结构分析是"按需"的, 不是导入即全算(用户要求): 右键块 →
分析音乐结构才计算; ⚙ 里那个"导入时长素材自动分析"开关默认关。分析跑在进程池里(不占请求线程、不卡 GIL, 不拖慢播放)。 点过一次的素材记进工程analyzed列表 → 刷新后用work/analysis/缓存静默恢复(不用再点一次, 也不重算)。 实测: 260s 歌曲分析 0.7s(命中缓存 54ms); 带 35 个标记时draw()0.73ms/帧, 播放帧间隔 16.7ms, 2.5s 推进 2.51s。 -
音乐结构分析与结构标记(第一阶段已完成)
analysis.py(纯 numpy,不引入 librosa/madmom,符合"确定性、可解释"原则): 谱流量 onset 包络 → 自相关定周期 + Ellis DP 节拍跟踪 → 用"拍点↔onset 互解释度 F1"自动排除 半速/倍速八度错误 → 4/4 downbeat 相位(onset+底鼓证据)→ 小节 → 逐小节 onset 密度/重音 4 向量/ 能量/谱流量/低音/人声带代理 → 边界新颖度 → 乐句(4/8/16 小节先验只作加成,实际由特征决定)→ 段落(只合并相邻乐句)→ transition 分点评分。- 速度:4:20 的歌 0.7s,68s 的歌 0.2s(~400× 实时,后台跑不挡 UI);结果按音频内容 hash + 算法版本缓存到
work/analysis/。 - 实测(三首真实素材):bpm 一致测出 129.2、拍间隔抖动 1.8%、首小节 0.046s、乐句稳定落在 7/8/8 小节。
- 显示:素材块底部 11px 标记带(顶部 10px 是音量/淡化把手,不抢)——暗蓝小节刻度 + 按置信度上色的 transition 标记(红≥0.9 / 橙≥0.7 / 黄≥0.5 / 蓝<0.5)。悬停出详情(Bar N · N-bar phrase · 段落 · 置信度 · 能量/节奏/音色/重音变化 · 本小节密度/能量 · 重音模式)。
- 切分:点标记即切开;右键
分析音乐结构/在最近的标记处切开 (M)/按标记全部切开 (≥黄); 快捷键 M(与 S 同款兜底:没选块就找播放头下的块)。导入 >25s 素材的自动分析默认关(⚙ 可开)—— 建议右键按需分析。 - 置信度是相对本曲最强边界归一化的(绝对分会被各特征上限压住,全曲最强也只有 0.6 → 看不出差别 ✗), 所以它表达的是"在本曲里这个点有多像结构边界",不是跨曲可比的分值。
- 尚未做(需求第二/三阶段):人声 stem 分离(现在是人声带代理,非 Demucs/Whisper)、更精细的 accent、 以及两首素材之间的 compatibility/自动对齐。
-
轨道上下换序 + whip 连线锚点 + 桌面窗口声音
- 轨道行上下拖拽换序:拖行内空白或左侧编号(
01/02…)即可;滑块/按钮/名称输入框/🌀 不抢(拖那些位置还是它们自己的操作)。拖动时显示青色插入指示线。换序后必须重画 whip 连线——连线是按轨道行位置算的,不重画会挂在旧位置;接管语义按 轨道 id 走,不受顺序影响(实测:[t1,t2,t3]把 t3 拖到顶 →[t3,t1,t2],连线同步变成M 242 78 → 242 166)。 - whip 接管不画连线(弧线横跨画布太碍事):只在目标轨道行右侧点一个绿点,同时该轨道行左侧的 4px 竖条变浅绿(
#86efac),断开立刻恢复原色(实测:连接后 t1 左边框rgb(162,130,111)→rgb(134,239,172),t3 源轨不变;弧线/path 数量 0)。连线只在「拖 🌀 连接的过程中」作为临时提示出现。 - 桌面窗口(GTK + WebKit2)听不到声音:WebKit 默认要求"用户手势"才允许出声(
media-playback-requires-user-gesture),表现就是窗口里点了播放也没声。已在window.py里设为 false 并显式打开 WebAudio / MediaSource / 媒体能力。若某些机器上仍不稳,用./VocalEditor-x86_64.AppImage --chromium(无地址栏的 Chromium 应用窗口,音频链路最稳)。
- 轨道行上下拖拽换序:拖行内空白或左侧编号(
-
同轨自动交叉淡化(Vegas / REAPER 的 auto-crossfade)
- 规则:同一轨道同一时刻只有一路声音。同轨两个片段重叠时,重叠区里前块线性淡出、后块线性淡入(线性 = 相加恒为 1:同一素材叠着放不会鼓包),播放与导出用同一条曲线(导出直接把这两个淡变量交给后端同一条线性斜坡)。
- 实测(同一段人声前后叠 0.767s):旧行为重叠区 2.00x(+6dB,两遍叠着一起放) → 现在 1.00x 全程只有一路;再用反相素材验斜坡:u=0/0.25/0.5/0.75 → 0.99/0.50/0.03/0.43(理论 1/0.5/0/0.5)→ 两条斜坡严格互补。
- 显示:重叠区自动画 X(REAPER 式)+ 一层淡蓝底色;手动设的淡入淡出与自动淡化取更长者,不互相覆盖。
- 同起点(两块都从同一秒开始)时:短的淡出、长的淡入;完全包含(小块在大块里)也归到同一套规则。
-
钢琴窗播放头实时跟随 + 建音符即发声
- 播放头以前画的是
S.playhead(只在 seek/暂停时落值)→ 播放中钢琴窗里的红线是冻住的。改成播放中取curPos(),并加了自动跟随滚动(越过右侧 60px 就把视图滚到 25% 处,与主时间线同一套);实测播放中播放头像素 456→872→(h 自动 948→1901)✓。 - 顺手修掉钢琴窗横向滚动量的单位混乱(
PR.h以前在秒/像素之间混用,底部滚动条的滑块位置是错的),现在统一为像素。 - 建/改音符即发声(三角波短音,60ms 节流):空白处按下立刻响(建的音高)、上下拖改音高边拖边响、点左侧键盘列单独听该键 —— 不用看音名也知道按的是哪个音。实测:建 midi 73 → 554Hz、点键盘 → 415Hz、上下拖 6 行 → 4 次递增音高 ✓。
- 打包脚手架(先没正式出包):
ve_env.py依赖自检(run.sh和打包后--check共用)+build_appimage.sh(PyInstaller 打 Python/依赖 + 静态 ffmpeg + rubberband → AppImage)+requirements.txt。详见「启动」一节。
- 播放头以前画的是
-
自由变调轨 = 零渲染的普通变调;MIDI 块离散化
- 自由变调轨不再走渲染:
=/-只改pitch、保时长、不变速,算法是相位声码器(STFT 时间拉伸 ×r + 严格 r 倍重采样),在浏览器里算(实测 4.27s 素材 80ms、2s 正弦 35ms,算完缓存,播放零延迟);窗口超过 24s 才交给后端dsp.simple_pitch(同一套参数,92x 实时)。试听与导出同算法:导出时该块走源文件 +pitch,后端用同一个函数 → 两边声音一致(普通轨仍然是算法渲染 + 用渲染文件导出)。 客观指标(220Hz 正弦):半音 0/+3/-4/+7/+12 → 误差 ≤0.011 半音、时长精确 2.0000s(只变调不变速✓);旧版颗粒重叠相加的边带抑制只有 ~10dB(听感粗糙)已废弃。 - MIDI 块之间离散:接管曲线现在只在块自己的区间内有效,出块即"释放"回素材原音高(后端新增
kind 2释放指令)。以前是把上一块最后一个音符的音高一路吊到下一块的头,现在两块之间的空隙会是素材原音高(实测:A块 02s→60、空隙 24s→None、B块 4~6s→64);音符也不会越出所属块。 - 钢琴窗刻度改成块内时间(0 = 块头,小节 1 = 块头):每个 MIDI 块都从自己的 0 开始数,与"块是离散的"一致;标题里仍标出块的绝对位置(
块头 3.500s (第 2 小节))。标尺点击仍然是绝对定位(点哪外侧跳哪),吸附锚点与画出的网格线同源(实测块头 3.5s 处建音符 → 块内 0)。
- 自由变调轨不再走渲染:
-
自由变调轨(右键轨道行切换)+ 普通变调不再偷偷变速
- 入口:右键轨道左侧面板那一行 → 菜单第一项「自由变调轨」(面板
±按钮保留为状态指示/快捷键);名字统一成「自由变调轨」,与块级的「瞬时变速变调」区分开(块徽标也从[自由变调]改为[脱离MIDI]) - 修"普通变调其实在变速"的根因:降级判据里有一条
srcDur > 90—— 但渲染开销只取决于块窗口长度,跟源文件多长毫无关系。你的源是 330s,于是任何变调都被当成"超长素材"降级成 varispeed(音高时长一起变)。现在:srcDur判据删除;上限只按窗口分档(接管/变速 45s,纯变调 240s——实测 rubberband ~24x 实时) - 自由变调轨上
=/-一律走普通变调(保时长·不变速,走算法渲染),即使该块被标了「不接管 MIDI pitch」也不会退化成 varispeed —— 这是该轨的定义 - 实测(源文件 330s、窗口 4.267s):自由变调轨 +
=→pitch=1, stretch=1, 长度 4.267 不变+ 有渲染;加上"脱离MIDI"再按=→ 仍stretch=1;对照组(关掉该轨)才变stretch=0.9439
- 入口:右键轨道左侧面板那一行 → 菜单第一项「自由变调轨」(面板
-
修"钢琴窗音符拖不动"+"退出卷帘 MIDI 消失"
- 音符移动只改了
start、没跟着改end→ 横拖一下音符就被压扁(start 跑到 end 后面),视觉上立刻变成 3px 碎片;碎片又必然落进右缘拉伸区(x > nx+nw-6),于是永远拖不动,只剩标尺还能拖播放头。现在移动时end = start + 原时值(实测横拖 140px 后时值 0.5→0.5) - 右缘拉伸区改为只在音符 ≥10px 宽时才存在(取
min(6, nw*0.3))→ 极短音符(4.4px)整块都是"移动"(实测模式move、能拖动) - 钢琴窗里按 Delete/Backspace 会把整个 MIDI 块删掉:开窗时那个块正是"选中"状态,没选音符时代码走到
deleteSel()→ 全屏卷帘挡着看不见,一关窗就发现"MIDI 消失了"。现在钢琴窗内 Delete 只作用于音符(没选音符时只提示,不动块),并加了"正在编辑的块被删/换工程 → 自动关窗"的守卫(不再在幽灵块上继续画) - 会话自动保存:工程(含 MIDI 块/音符/滑音/whip/纯变调轨)自动写进 localStorage(改动后 800ms 合并落盘,内容没变不写),刷新/重开浏览器不丢;状态栏会提示已恢复,File→「新建空工程 (清空)」/「加载演示工程」可切换。(
File→保存工程仍是导出 .json 的正式路径;另:渲染缓存文件被清掉时现在会自动重渲染而不是哑掉)
- 音符移动只改了
-
修两个方向反了的拖拽 + 循环源
- 钢琴窗里把音符往上拖,音高却在往下(
dP = grabP - prPitchAt(y)符号写反;屏幕 y 越小音高越高)→ 改成prPitchAt(y) - grabP,实测上拖两格 60→62 - Alt+拖块往左拖内容却往右(同一个符号问题)→ 现在与鼠标一致,实测左拖 60px 后
offset 0 → 0.4286 - 顺手把"滑动只有裁剪过才有余量"这条限制解决掉:滑动改为循环源回绕(
offset % srcDur),所以整块素材也能滑(实测从 0.4286 右拖后回绕到 3.2673);配套打通三处 —— 播放用BufferSource.loop(实测 active=2 无调度报错)、离线渲染与混音导出走后端take_window()(实测渲染块内 1.45s 与源开头相关系数 +0.984,与源 3.45s 处仅 +0.028)、波形回绕绘制 + 接缝虚线 +[循环源]标签
- 钢琴窗里把音符往上拖,音高却在往下(
-
钢琴窗网格刻度独立可切 + Shift 微调:钢琴窗现在是自己的网格(头部下拉,或在顶部标尺上右键弹出刻度菜单:1拍/1/2/1/4/1/8/1/16/1秒/帧),吸附、画出来的网格线(小节/拍/细分三级,细分太密时自动不画)、以及新音符默认长度都跟着它走(实测 1拍→0.5s、1/8拍→0.0625s、1/16拍→0.03125s @120BPM);首次打开继承主时间线网格,之后独立。拖拽时按住
Shift= 临时忽略吸附(做微调,实测拉到 0.3786s 这种非整拍值),主时间线早就支持这套,现在钢琴窗一致 -
纯变调轨(轨道面板
±):该轨所有素材永不被 MIDI 接管(非音高矫正),=/-就是普通升降调(保时长,走算法渲染)。接线:whipNotesFor早退、createWhip直接拒绝并给提示(不再"拖了没反应")、🌀 拖拽时该轨不高亮、块上标[纯变调轨];开关切换走refreshClipsOf整轨重算(与 whip 失效传播同构) -
代码 review:死代码 / 死逻辑 / 小问题(本轮)
- 删掉已断的死功能:「对齐到 MIDI」——客户端还在 POST
/api/align-clip,但后端早就没这个路由(点了必然"未知接口");连带alignSelToMidi/alignClipToMidiClip/midiClipFor/naturalLen、两处菜单项、不存在的#stretchAlign勾选框与文档里"工具栏勾选框"说法 pitchSelection里nm = true写在var nm声明之前且从未被读(死逻辑)→ 删除;noTune之前只被读、从未被写(半成品)→ 就是上面的纯变调轨- 音频图:块音量与淡入淡出包络原先共用一个 GainNode,
applyMixLive()(拖轨音量/声像/总线)会把包络改回去 → 拆成gEnv(归一化包络)+gClip(块音量),并让拖块音量线接上applyMixLive()(播放中实时可听) scheduleAll的 catch 里引用了 try 内const pbRate(TDZ):一旦异常发生在它初始化之前,错误处理自身会再抛 ReferenceError 并打断整轮调度 → 移到函数顶部声明- 导出混音不再忽略变速:以前把时间轴秒直接当源秒用,自由变调/超长降级块导出后音高与时长都是错的;现在带
rate交给后端线性重采样(与playbackRate同构,实测 rate=2 → 0.5s/880Hz、rate=0.5 → 2s/220Hz) - 零碎:
S.lastMix、whipStart里没人用的draft、hitTest返回的空track、PR.lo/hi(只读未写)、重复的.fxb查询、重复画两遍的音符右缘把手;prDraw内遮蔽全局beat()/isSel()的同名局部变量改名;dsp._fade_edges、server.AUDIO_EXT、未用的import shutil、无调用方的/api/midi、.whipchip/#fxPop .fxon死 CSS 全删;exportMix(dest)未用参数与clipBufferInfo未用的base/重复 rate 推导收敛
- 删掉已断的死功能:「对齐到 MIDI」——客户端还在 POST
- Ctrl+C / Ctrl+X / Ctrl+V:复制/剪切/粘贴选中块到播放头(跨轨保留,MIDI 块同样可复制)
- 自动加轨:把块拖到最后一行之下,自动新建轨道承接(面板顶部也有「+ 轨道」)
- 横向滚动条:时间线底部可点可拖;轨道面板与时间线垂直滚动同步(不再错位)
- 文件对话框:导入走系统文件管理器(可多选),保存工程/导出混音走浏览器下载对话框——为桌面端化铺路;m4s/mp4/webm/mp3 全部 ffmpeg 转 wav 导入
- 块音量包络(REAPER 式):拖块顶边上下 = 音量线;拖左上/右上角 = 淡入/淡出;播放和混音导出都吃这套包络
- Alt+拖块 = 滑动素材内容:块位置不动,改放的是源文件的哪一段("今天天气真好"→"天天气真好");方向与鼠标一致(往左拖 = 内容往左 = 露出后面那段),且越过源尾/源头按【循环源】回绕 —— 所以没裁剪过的整块也能滑(以前钳在
[0, srcDur-win],整块时上下限都是 0,拖了没反应);回绕后块上画虚线接缝 + 标[循环源] - Shift+拖 = 临时忽略吸附
- 拖放导入:文件直接拖进窗口(或 File→导入),ffmpeg 通吃——mp3/m4s/mp4/webm/flac 等一律转 44.1kHz 单声道 wav 再进轨道(视频自动抽音轨);上传走 /api/upload,转换失败会带 ffmpeg stderr 报错
- 统一拖缘逻辑:拖边缘=裁剪;Alt+拖右缘=拉伸(音频/MIDI 一致;MIDI 直接拖右缘=裁短,超出块尾的音符不再显示)
- 顶部菜单 File / Edit / View:导入、MIDI、存/开工程、导出混音、撤销重做、变调、网格刻度全收进菜单
- B / N 循环选区:播放头处按 B=选区头、N=选区尾(自动开循环);勾「循环」后在选区内循环;导出混音基于选区(无选区=整曲)
- whip 视觉修正:连线起点=🌀 按钮(圆环锚)、终点=目标行(绿点+横杠),修掉 SVG 相对视口定位的 76px 偏移(#main 需要 position:relative)和芯片行溢出错位
- 🧲 磁铁吸附 + 网格刻度选择:1/1、1/2、1/4、1/8 拍,1 秒,帧(30fps)
- 🌀 whip(AE 式 pick-whip):含 MIDI 块的轨道,面板上出现 🌀 按钮,按住拖到目标轨道即建立连接(左侧面板行或时间线对应行松手都认;松手位置无效会红字提示;单击 🌀 = 断开)——目标轨上与 MIDI 时间重叠的音频块自动按块位置矫正到 MIDI pitch(WORLD)。绑定在轨道面板显示为绿色连线+芯片,改完素材后右键 MIDI 块 →「应用 whip」重跑
- 右键多选 + 菜单:Ctrl+点击 / 右键加选,右键菜单:变调±、重置变调/拉伸、切开、删除(作用于全部选中块)
- 共振峰移位滑条(后端
formant_semitones已支持) - 块交叉淡化手柄
- whip 的 MIDI 轨上若挂多个块,会按绝对时间全部参与同一条音高曲线(REAPER send 语义);目前靠用户删掉多余的块,后续可加「只取与块重叠的那个 MIDI 块」开关
- 断开防误触:单击 🌀 只提示;右键 🌀 = 断开(断开后目标轨恢复原 pitch,重连即恢复接管)
- 块级绕过:右键素材块 →「不接管 MIDI pitch(自由变调)」——该块脱离路由,= / - 变成纯升降调;再点一次恢复接管(块标 [MIDI接管])
- BPM 每轨节拍时基(♩):轨面板 ♩ 按钮 = 该轨跟随 BPM(改 BPM 时此轨素材随节拍变速变位);whip 连接时目标轨自动开启;BGM 轨保持 ♩ 关(绝对时间不动)。MIDI 块始终随 BPM 缩放
- MIDI 块标题显示当前转调([转调-4] 等)
- 渲染时长强制精确到块长(PSOLA 量化误差不再导致"波形长声音短")
每轨 FX 按钮 → 弹出面板:EQ 三段(低/中/高 shelving+peaking)· 合唱(LFO 调制延迟)· 混响(噪声 IR 卷积)· 激励器(高频支路 WaveShaper 谐波,Aphex 式)。 原生 WebAudio 节点实现(模式参考 Tone.js),FX 状态存入工程;当前作用于播放链,混音导出的 FX 渲染在桌面端阶段补。
- 界面组件/状态/交互/配色尺寸的完整拆解见 UI.md(用于重做视觉时的输入)。
- 视觉皮肤已按 Stitch 交付的
DESIGN.md设计系统(Precision Audio Engineering Interface)重做: 表面分层 + 功能色语义 + Inter/JetBrains Mono + 0–2px 圆角 + 1px 结构边框;结构与交互仍是本项目框架。 落地/未落地清单见 UI.md 第 0 节。
架构研究笔记见 ARCHITECTURE.md(Ardour 时间域 / Tone.js 效果器 / 失效传播 / Rust 路线)。
- Rubber Band R3 已接入(自动检测 rubberband-cli):拉伸/变调单遍完成,无 3x 上限
- 长素材保护:按块窗口长度判断(与源文件多长无关)——接管/变速走 Praat PSOLA 很慢,限 45s;**纯变调(保时长)**限 240s(后端相位声码器 ~92x 实时 / rubberband ~24x);自由变调轨的窗口 ≤ 24s 在浏览器内即时算(不排队不等待),> 24s 交后端同算法;超限才降级为瞬时 varispeed 并提示
- 渲染 = CPU(numpy + Praat PSOLA),多进程并行(按核数),单块典型 30–70ms、5.5s 长块约 0.07s——编辑手感上等同实时
- BPM 修改 = REAPER beats 时基:所有块的位置/时长/Rate 随节拍缩放(真的变速,音高引擎保音高),MIDI 块同步,自动重渲染
- 真·实时监听(边播边修不预渲染)需要流式引擎,留给桌面端化阶段;GPU 用在 AI 变声(RVC 类),PSOLA 路线吃不到
- 音频导入后首次变调/拉伸要等 WORLD 离线渲染(短块几百毫秒,块标题显示"渲染中…")
- 混音要求所有素材同采样率
- 自由变调 / 超长自动降级块在导出时用线性重采样(与播放端
playbackRate同构);要更好的音质就用=/-的普通升降调(走算法渲染)
