Skip to content

About

人力VOCAL 编辑器: MIDI 接管音高 + FL 式滑音 + 自由变调轨 + 同轨自动交叉淡化 (本地 Web 应用, 可打 AppImage)

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

vocal-editor

人力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,装了音质更好)。

打包 AppImage(脚手架就绪,尚未正式出包)

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」轨的 🌀 拖到「人声」轨即建立接管,点播放可听效果。

v6 已加(2026-09-26)

  • 钢琴窗标尺定位:点按/拖动钢琴窗顶部标尺 = 播放头跳到该绝对时刻(与外侧时间线同一坐标系——MIDI 块在 5s 处,钢琴窗点哪、外侧播放头就跳哪,方便人声对轨);顺手修掉"点标尺会误建音符"的老问题(标尺与左侧键位列现在都不再生成音符)

  • 音符时长不再被块长截断:拖右缘可无限拉长,音符越出块尾时 MIDI 块自动加长(画得出 = 显示得出 = 听得到);整组移动越界同理

  • FL 式滑音语义端到端修正:

    • 修 BUG① 客户端 whipNotesFor 丢掉了 slide 标记 → 后端收到的全是普通音符,滑音从未生效
    • 修 BUG② 后端滑音跨度结束后会弹回底下长音的原音高 → 现在滑完保持滑音目标音高直到下一个事件
    • 语义:一个长音键可叠任意多个滑音键;滑音键在自身跨度内从"该刻当前音高"线性滑向自身音高,键越长滑得越慢;无事件覆盖处沿用素材原 F0
    • 渲染键含 slide(RENDER_VER r7→r8,旧缓存自动失效);块上橙色键用连线画出真实滑音轨迹;钢琴窗试听(chip) 改用同一曲线的连续变频,不用等渲染就能听到滑音
  • 修 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.290.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 推导收敛

v5 已加(2026-09-26)

  • Ctrl+C / Ctrl+X / Ctrl+V:复制/剪切/粘贴选中块到播放头(跨轨保留,MIDI 块同样可复制)
  • 自动加轨:把块拖到最后一行之下,自动新建轨道承接(面板顶部也有「+ 轨道」)
  • 横向滚动条:时间线底部可点可拖;轨道面板与时间线垂直滚动同步(不再错位)
  • 文件对话框:导入走系统文件管理器(可多选),保存工程/导出混音走浏览器下载对话框——为桌面端化铺路;m4s/mp4/webm/mp3 全部 ffmpeg 转 wav 导入
  • 块音量包络(REAPER 式):拖块顶边上下 = 音量线;拖左上/右上角 = 淡入/淡出;播放和混音导出都吃这套包络
  • Alt+拖块 = 滑动素材内容:块位置不动,改放的是源文件的哪一段("今天天气真好"→"天天气真好");方向与鼠标一致(往左拖 = 内容往左 = 露出后面那段),且越过源尾/源头按【循环源】回绕 —— 所以没裁剪过的整块也能滑(以前钳在 [0, srcDur-win],整块时上下限都是 0,拖了没反应);回绕后块上画虚线接缝 + 标 [循环源]
  • Shift+拖 = 临时忽略吸附

v4 已加(2026-09-25)

  • 拖放导入:文件直接拖进窗口(或 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)和芯片行溢出错位

v3 已加(2026-09-25)

  • 🧲 磁铁吸附 + 网格刻度选择: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 块」开关

whip 补充语义

  • 断开防误触:单击 🌀 只提示;右键 🌀 = 断开(断开后目标轨恢复原 pitch,重连即恢复接管)
  • 块级绕过:右键素材块 →「不接管 MIDI pitch(自由变调)」——该块脱离路由,= / - 变成纯升降调;再点一次恢复接管(块标 [MIDI接管])
  • BPM 每轨节拍时基(♩):轨面板 ♩ 按钮 = 该轨跟随 BPM(改 BPM 时此轨素材随节拍变速变位);whip 连接时目标轨自动开启;BGM 轨保持 ♩ 关(绝对时间不动)。MIDI 块始终随 BPM 缩放
  • MIDI 块标题显示当前转调([转调-4] 等)
  • 渲染时长强制精确到块长(PSOLA 量化误差不再导致"波形长声音短")

轨道 FX(播放链)

每轨 FX 按钮 → 弹出面板:EQ 三段(低/中/高 shelving+peaking)· 合唱(LFO 调制延迟)· 混响(噪声 IR 卷积)· 激励器(高频支路 WaveShaper 谐波,Aphex 式)。 原生 WebAudio 节点实现(模式参考 Tone.js),FX 状态存入工程;当前作用于播放链,混音导出的 FX 渲染在桌面端阶段补。

UI 清单 / 视觉规范

  • 界面组件/状态/交互/配色尺寸的完整拆解见 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 同构);要更好的音质就用 = / - 的普通升降调(走算法渲染)

About

人力VOCAL 编辑器: MIDI 接管音高 + FL 式滑音 + 自由变调轨 + 同轨自动交叉淡化 (本地 Web 应用, 可打 AppImage)

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages