给它一个 mp3 加一份歌词,它产出一个零依赖、可双击打开、上限 15 MB 的单文件
index.html(音频、渲染器、分析数据全部内联),和一段逐帧渲染的 1080p60 MP4。
贯穿全部工程决定的主线只有一条:画面必须是时间 t 的纯函数——否则
「网页看到的就是导出视频里的」这句话就不成立。本文把它从素材预处理、分析管线、数据契约、
渲染分层、打包体积账,一直拆到守卫体系与已知缺陷,粒度对齐本仓此前对参考项目的拆解报告:
目标是一个没见过这个仓的 AI,拿着这一份文档能写出行为等价的系统。
这份文档是什么。murRipple(中文名「知漪」)私仓的收官版技术拆解,
写作时基于 main 分支提交 dca6ff2(2026-08-20)。它面向两类读者:
想读懂这个项目技术细节的人,以及要据此复现一个行为等价系统的 AI。第二类读者是硬判据——
所以每一层的输入/输出契约、每个承重常量的取值与理由、以及已知缺陷都在正文里,
不在附录里。一份不写已知缺陷的复现指南,会让复现者把 bug 当成自己的错。
数字纪律。本文遵守本仓介绍站立下的规矩:一个没有出处的数字都不写。
每个承重数字旁边标注出处——murripple/pack.py:22 这样的标记指源码位置,
「实测」指仓内台账(DECISIONS.md / MGMT.md)记录的真实测量或本文写作时当场跑出的数,
「棘轮」指该数值只是被钉住防漂移、不是「这个值是对的」的证据。
全文没有任何准确率数字与性能承诺——本项目没有人量过侦测与听写的准确率,代理指标在这个仓里已被真实结果推翻过多次
README.md「语言」节 · MGMT.md 第七节。
素材纪律。仓内五首歌,只有《Trempe-moi》(音乐由 Suno 生成、歌词由作者本人创作、版权归他) 可以公开点名。其余四首是第三方作品,本文一律以真歌 01–04 指代, 歌名、歌词、目录名一个都不出现——这与公开树的处置口径一致 MGMT.md 第六节 · DECISIONS 2026-08-16。
不覆盖什么。合成曲的音乐质量(M5 第二步,只有耳朵能判、尚未开工);
M6 三维视觉(有方向无 spec);管理流程本身(那是 MGMT.md 第七节的地盘,
本文只收录其中已固化为代码或规格的部分)。
它是一条把「已有的歌」离线蒸馏成确定性数据、再由确定性渲染器重放的管线—— 不是播放器插件,不是实时可视化器。
所有分析(音源分离、节拍、起音、音高、歌词对齐)在构建期一次做完,固化成一份
timeline.json;渲染器不认识 Demucs 或 librosa,只认识这份文档
murripple/schema.py 模块 docstring。这个「先蒸馏、后重放」的结构,
与本仓的视觉标杆(一个在浏览器里现场合成音乐的单文件项目,拆解见
docs/research/2026-08-13-light-loom-teardown.html)恰好互为镜像:
| 参考项目 light-loom | murRipple | |
|---|---|---|
| 音乐来源 | 代码定义 → 合成器现场生成 | 已有音频文件 → 离线分析(另有 compose 合成线,见 §9) |
| 谱面来源 | 纯函数生成,编译期已知 | Demucs 分轨 + 起音检测 → timeline.json |
| 画面数据 | 播放时从 AnalyserNode 实时读 | 预计算 timeline,逐帧可寻址 |
| 导出方式 | 实时录屏 | Playwright 逐帧渲染 → ffmpeg 封装 |
| 确定性 | 音频可复现,画面不可逐帧复现 | 画面必须逐帧确定(守卫见 §11) |
产物两样 MGMT.md 第二节:
index.html——零外链、可双击本地打开、可发给任何人、可挂静态托管;
音频以 base64 data URI 内嵌,上限 15,000,000 字节(§6)。| 位置 | 规模 | 职责 |
|---|---|---|
murripple/ | 7,553 行 Python | 分析管线:分离 / 节拍 / 起音 / 音高 / 段落 / 包络 / 对齐 / 打包(wc -l 实测,含下述子包) |
murripple/ingest/ | 4 个模块 | M4 素材预处理:扫描 / 抽音轨 / 硬字幕 OCR / 听写 |
murripple/compose/ | 6 个模块 | M5 参数化合成(不进公开树,§9) |
murripple/web/ | 5 个模块 + 单页 | W1 本机壳子:只绑 127.0.0.1 的网页(§10) |
renderer/src/ | 4,162 行 JS | 15 个渲染层 + core(时钟/音频/DSP/几何)+ ui(DOM 覆盖层) |
renderer/video/ | 371 行 | render.mjs 逐帧导出 + probe.mjs |
tests/ + renderer/test/ | 52 + 33 份文件 | pytest 1,094 条(本文写作时实跑)/ 渲染层 301 pass(台账 2026-08-16 实跑) |
tools/ | 5 个脚本/守卫 | 公开树生成器与发布守卫(§12) |
| 命令 | 作用 | 关键设计决定(出处见正文对应节) |
|---|---|---|
murripple ingest <dir> | 整理 _in/ → 标准输入 | 整理完就停:OCR 会错字,人必须过一眼行数与内容才准往下(§3) |
murripple ingest <dir> --url | 从链接取回素材再走 ingest | 三级降级链,降级必须大声说(§3.4) |
murripple transcribe <dir> | 本机听写出草稿 | 结构上写不到 lyrics.txt——「必须过人」是结构事实不是承诺(§3.5) |
murripple build <dir> | 分析 → timeline.json + m4a | 歌词门在此处且只在此处;拦在动 Demucs 之前(§4) |
murripple pack <dir> | 打成单文件 index.html | 与 build 拆开:分析约 84 s、打包约 2 s,调视觉不重跑分析(§6) |
murripple run <dir> | build + pack 串起来 | 每步先看产物在不在——全链约一小时,必须可断点续跑(§4) |
murripple compose <dir> | 摇 seed 合成一首(私仓) | 做完就停,同 seed 逐字节复现(§9) |
murripple serve | 本机网页壳子 | 只绑 127.0.0.1;管线一行不改(§10) |
取材层的公共哲学写在 murripple/ingest/__init__.py 的 docstring 里:
_in/ 是用户仅有的原始素材,这一层只读它——不改、不删、不移动;
产物一律写在歌曲目录下、_in/ 之外。以及两条硬规矩
murripple/ingest/scan.py:拿不准就报错,不猜(目录里有两个 mp4 时报错并列出候选——猜错要跑一小时才发现);
决策要讲出来(Plan.notes 是打印给人看的句子,不是调试输出)。
| 看到的 | 怎么办 | 为什么 |
|---|---|---|
| wav/flac 与 mp4 都有 | 音频取 wav/flac,字幕仍从 mp4 来 | 视频音轨已被有损压缩过一次;时间戳只有 mp4 给得出——不是二选一,是各取所长 |
| 现成的歌词 txt | 直接用,不 OCR | OCR 会错字,现成的是权威 |
| 只有 mp4 | 抽音轨 + OCR 硬字幕 | —— |
| 两个 mp4 | 报错,列出候选文件名 | 猜错要跑一小时才发现 |
后缀分档 murripple/ingest/scan.py:无损 .wav/.flac、有损可直用 .m4a/.mp3(两档合起来恰好是
cli.find_source 认的四种——注释明写「往这里加 .ogg 之类之前,先去改 find_source」);
视频 .mp4/.mov/.mkv/.webm/.avi。音频整理(audio.py)的原则是能不转码就不转码——四种可直用后缀
shutil.copy2 原样拷;只有视频真的动手:-vn 抽成 source.mp3,LAME 质量档
MP3_QUALITY = "2"(约 190 kbps VBR,注释:视频音轨本身有损,再高只是放大前一次压缩的产物)。
抽完做时长比对,容差 DURATION_TOLERANCE = 1.0 秒——因为 ffmpeg 对截断/损坏的文件仍然返回 0(源码注释记有实测)。
已有 source.* 就停下,--force 才覆盖,且覆盖时删掉其它扩展名的旧 source——两份并存时
find_source 按固定顺序取第一个,「用户以为换了源其实没换」。
这一步的产出不是「一段文字」,而是「文字 + 出现时刻」——字幕从暗变亮的那一刻就是演唱时刻, 这首歌于是可以完全跳过 WhisperX。全部承重常量与理由 murripple/ingest/subtitle.py:
| 常量 | 值 | 理由(源码注释) |
|---|---|---|
DEFAULT_FPS | 2.0 | 歌词一行至少停留一两秒,2 fps 足够,比逐帧快十五倍 |
BRIGHT_THRESHOLD | 220.0 | 实测已唱行 p95 亮度 237–255、未唱行 176–207,中间空得很开,220 落在正中。用 p95 不用峰值:峰值容易被一个抗锯齿的亮像素顶满 |
BRIGHT_PERCENTILE | 95 | 同上 |
LAYOUT_SAMPLES | 16 | 自动找歌词带的取样帧数——要够多才看得出「哪条带子的文字在变」 |
BAND_TOLERANCE | 0.012 | 两个文字框纵向中心差在此比例(占画面高)内算同一条带子 |
MIN_DISTINCT_TEXTS | 4 | 一条带子至少出现这么多种不同文字才算歌词带。不能只要求「大于一种」:水印被 OCR 一会儿读成 MADEWITHSUNO、一会儿读成 MADEWITH SUNO,两种写法就足以冒充歌词带(实测踩过) |
MIN_FRAMES | 2 | 一行至少连续两帧在「已唱」集合里才算数,少于此多半是 OCR 抖动 |
MAX_LINE_SEC | 8.0 | 一行最多挂屏这么久,超出部分是间奏。样本数:1 首歌 48 行(WhisperX 量得中位 3.54 s、p90 5.01 s、最长 7.42 s,8.0 是在最长值上留余量)。注释直说「目前没有人在管它」;测试 test_max_line_sec_is_a_ratchet 只是棘轮不是证据 |
SIMILARITY | 0.75 | 相邻帧同一行相似度阈。全等比对的话一次抖动就被记成新行 |
比对键 compare_key() 剥掉全部空白与标点——标点是 OCR 最不稳的部分:
实测同一行「X——」在相邻帧里被读成 X / X- / X— / X一 四种,破折号在三字短句里一变就把相似度拉到 0.67。
OCR 后端做成可注入的 Callable(默认 rapidocr_onnxruntime):逻辑部分不装 OCR 依赖就能测,换引擎不改这里。
硬字幕 OCR 会整行整行地漏,而漏掉的行不会有任何提示。实测一首歌人工听写 37 行、OCR 只认出 32 行,
漏 6 行、还把一句吃得只剩一个字——从第 6 行起显示的就系统性错位
DECISIONS 2026-08-13「P0 成因查实」。所以「硬字幕时间戳胜过 WhisperX」只在 OCR 把每一行都认全时成立;
ingest 之后必须人过一眼行数与内容,不是只改错字。这也是 ingest「整理完就停」的全部理由。
另一首歌的实测则是 56 行只错一字、一行没漏 DECISIONS 2026-08-13 链接预处理条——同一条路线,成败取决于素材。
时间戳落在单独的 lyrics.timing.json,不写进 overrides.json:
overrides 的歌词补丁按下标打进「对齐之后」的列表,而对齐会丢行 → 下标错位
DECISIONS 2026-08-13「推翻 M4 计划稿」。校对 lyrics.txt 后文字以它为准、时间戳只对行数——
拆行或并行会让两边对不上,那时报错并退回常规对齐。
「这首歌要不要歌词」——全仓唯一那一处判断。它原来是三处且互相矛盾(cli.run 拒绝、
cli.build 沉默降级、web 壳子拿空白也算缺),三次真跑把矛盾坐实后统一成一个只用标准库的叶子模块,
管线与网页壳子都 import 得起 murripple/lyrics_gate.py docstring。设计上把
事实与政策分开:lyrics_missing() 是事实(空白算缺——一份全是空格的
lyrics.txt 骗得过 exists()),两边共用;blocked_reason() 是管线的政策
(默认拦住,--no-lyrics 或 compose.json 放行——后者等于用户已经说过「这是器乐曲」)。
拦下时的消息把四条出路全部写出来(跑 ingest / 自己写 / transcribe 听一遍 / --no-lyrics)——
「少写一条,那条路对用户就等于不存在」;第四条另起一行带两格缩进,因为 web 的日志分层按行首形状认领,
揉进上一句会把听写那条路的唯一入口提示折进详细区。
纯模块、零接线成本地并入 ingest --url 与 POST /api/job-from-url。
三级降级链 murripple/fetch.py:
| 顺位 | 路径 | 兜的是什么 |
|---|---|---|
| 1 | uv run --with "yt-dlp[default,deno]" --no-project -- yt-dlp …——运行时拉最新版,不锁版本、不进 pyproject.toml、不动 .venv | 站点改版——yt-dlp 的主要失效方式。「钉死的旧版比最新版更容易坏」(调研当天就撞上 403) |
| 2 | sys.executable -m yt_dlp(环境里已装的可选模块) | 断网 / 拉不到 PyPI |
| 3 | 打印真跑过的那条 argv,可直接粘贴手敲 | 前两级都不成,人接手。取回的文件照常落 _in/,ingest 接手 |
关键常量:音频格式 bestaudio[ext=m4a]/bestaudio[acodec^=mp4a]——挑 AAC 免转码是这条路真正的门道,
落成 opus 会被 scan 归进「忽略(用不上)」整趟白跑;视频合并容器 mkv。降级必须大声说自己走了哪一级、原因原文照登;
我们自己的每一行都带 [取回] 前缀,yt-dlp 原文一字不改透传——「哪些话是这个模块说的」是可判定事实。
静默看门狗 QUIET_SECONDS = 15.0 只挂第 1 级,且只声称「有一阵子没有任何输出了」这个量到的事实——
原稿那句「在下载工具」是照着一个没量过的假设写的,冷缓存真跑一次就被推翻(uv 自己会把每个包报出来,
含那个 36.7 MB 的 deno 运行时)fetch.py 订正注释 · DECISIONS 2026-08-14。
取回无条件打印版权提醒,承重句是「你对自己处理和分发的素材负责」——一句只有作者看得懂的提醒不是提醒
DECISIONS 2026-08-15。
产物路径不解析 stdout:实测 --print after_move:filepath 会把 stdout 压成只剩一行路径、进度全没;
改用 --print-to-file 之后它是追加不是覆盖——每一级开跑前必须先删落点文件,
否则这一级失败也会读到上一级的路径、报出一个「成功了」。一趟吐出多份产物(播放列表)时不猜是哪一份,
报 AmbiguousResultError 列出候选 fetch.py · DECISIONS 2026-08-14。
机器认字,人断句。产出是 lyrics.draft.txt,管线一个字都不读它——
「必须过人的确认」是结构事实而不是承诺:这个模块没有任何一条路径写得到 lyrics.txt。
草稿一段一行、不替人断句:实测中文歌 36 行的歌词只吐回 6 段、法语歌 34 行吐回 8 段,
且中文输出一个标点都没有——「按标点断句」这条路根本不存在
transcribe.py docstring · tests/fixtures/whisperx/ 抄件。
听的是混音不听人声轨:人声轨实测明显更准,但要先跑一遍 Demucs,而 build 只认扁平分轨布局、
会原样再分离一遍——那几分钟是纯浪费;管理窗口并拒绝「让 build 认嵌套布局」,因为那会把
「第二次 build 拿旧分轨假装新结果」这个 bug 请回来 DECISIONS 2026-08-15。
草稿不加表头:改名存成 lyrics.txt 时表头会变成第一句歌词。全仓(代码、CLI、网页)
不出现任何听写准确率数字,有守卫扫 %/准确率/字准。
murripple build 的进度输出就是它的结构:[1/5] 分离音源 → [2/5] 读取分轨 →
[3/5] 对齐歌词 → [4/5] 编码音频 → [5/5] 组装 timeline。歌词门拦在动 Demucs 之前——
「忘了放歌词的人不该白烧一小时,这是这道门存在的全部理由」murripple/cli.py::build 注释。
run = build + pack,每步先看产物在不在(全链约一小时:Demucs 约 4 分钟 + Whisper 几分钟 + 导出 33 分钟,
必须可断点续跑)cli.py::run docstring。
Demucs htdemucs(四条 stem:vocals/drums/bass/other),子进程调用不用 Python API——
CLI 接口跨版本稳定得多,测试可整体替身、不必下载 2 GB 模型;sys.executable -m demucs 保证跑在同一个 uv 环境。
两个承重参数:
--shifts 0(必须固定为 0):Demucs 默认每次跑都做一次随机时间平移再平均、且不接受种子——
实测同一首歌两次 build 得到不同分轨,歌词对齐从 48 句掉到 33 句而代码一行没改。分离质量略有损失,换来可复现:
好结果能重现,退化能追查 murripple/separate.py 注释。stems/<model>/<源名>/*.wav——这一点被
stems.find_flat_stems 刻意利用:它只做非递归 glob("*.wav"),于是「跳过 Demucs」的判据是
扁平布局(外部提供的分轨:compose 合成的九条、或手工放的四条),不是「目录在不在」——
后者在任何一次正常 build 之后都成立,会让第二次 build 拿旧分轨假装新结果。凑不齐一整套完整名单(缺一条、多一条、混搭)
一律拒绝:半套分轨比没有更危险 murripple/stems.py docstring。全部纯函数:输入 numpy 数组、输出普通数据,无 IO 无子进程,测试用合成音频即可 murripple/analyze.py docstring。
| 函数 | 做法 | 承重细节 |
|---|---|---|
detect_beats | librosa beat_track → bpm + 拍点;小节线按 4/4 假设,从 onset 强度最大的那一拍起每四拍取一 | librosa 不检测小节线;假设不成立时用 overrides 修正 |
detect_onsets | onset_strength → onset_detect(backtrack=True),强度按本轨峰值归一 | 已知真 bug,见 §13:backtrack 回退到波谷后又用回退后的帧号读强度,力度 v 大多接近 0 |
track_pitch | librosa YIN,范围 C1–C4(32.70–261.63 Hz),只对 bass 轨跑 | frame_length 按 2*sr/fmin 向上取 2 的幂——默认 2048 在 44100 下盖不住 C1(需 2698),低频音高不可靠恰是 bass 最需要的那段 |
detect_sections | chroma_cqt 自相似 + agglomerative 聚类,默认 n = 9 段;每段能量 = 段内 RMS 均值 / 全曲峰值 | 段落名一律空串,由 overrides 手写——「打出一个猜的名字比不打更糟」 |
sections_from_marks | 按给定边界只算能量,不做检测 | 合成曲的段落边界是真值——「有真值就别再猜」,与「硬字幕跳过 WhisperX」同一条道理 |
Demucs 只有四条 stem,人声不占轨道(它驱动判定环),剩下三条按频段拆成六条视觉轨道。
静音粒度仍是 4——底鼓/军鼓/踩镲从同一条鼓轨滤出来,能分开画、不能分开静音
murripple/lanes.py docstring。LANE_SPECS 是全项目的色相权威表
(网页壳子、介绍站的色板都有守卫钉着必须等于它,见 §12):
出处:id/hue/stem/band 见 murripple/lanes.py:19-24;中文/英文声部名的真相源是渲染层
renderer/src/ui/voices.js LABELS(lanes.py 里的 label「底鼓/军鼓/…」只是兜底,画面上出的是「撼岳/裂帛/…」);
心籁 hue 300 由 voices.js 硬编码(人声无 lane)。合成曲另有 arp 165「泠泠」、bell 60「霜铎」,pad/pluck 复用 175/270(§9)。
带通用四阶巴特沃斯 sosfiltfilt,padtype="constant"——默认 "odd" 端点外推对突然起振的信号
会在起点反射出低频伪影,足以让理应带外拒绝的能量在包络第一帧冒头 lanes.py::bandpass 注释。
每条 lane 跑 detect_onsets;只有 bass 跑 track_pitch(单音假设)。
合成曲走 lanes_from_specs 直通:名字、色相、音符表来自真值,不切频段、不猜音符——
「对着自己刚写完的乐谱再猜一遍,是把已知信息丢掉再找回来」;包络仍从音频算,它本来就是音频属性。
RMS(60 Hz 网格,hop = sr/60) → dB(floor −60)→ uint8(0–255)→ base64
一条轨 3 分钟约 10.8 KB。quantize 的 global_peak 取全部轨道 + 人声的共同峰值,
保留各轨相对响度——否则安静的轨和响亮的轨一样亮;个别轨太暗用 overrides 的 per-lane gain 提。
混音包络的峰值故意不计入:mix 是四轨之和、峰值通常高于任何单轨,计入会把六条 lane 按 dB 刻度一并拉暗
murripple/envelope.py · timeline.py 注释。
用户提供歌词原文,所以这是「对齐」而非「识别」。关键设计:不按整句做精确匹配—— Whisper 的分句边界与用户的换行几乎不可能一致。做法是字符级序列比对 murripple/align.py docstring:
MODEL_SIZE="medium"、DEVICE="cpu"、compute_type="int8")转录人声轨并做词级对齐。
选 medium 的理由:唱歌比说话难认得多,small 在真实曲目上错得厉害、导致大量句子对不上;medium 慢三到五倍,
但 build 是一次性的、调视觉时不重跑,值这个时间 align.py:24-28 注释。difflib.SequenceMatcher(autojunk=False)求最长公共子序列,把每行的字符位置映射回词时间戳。DEFAULT_CHAR_SEC = 0.35 只在一个可测速率都没有时用得上,
所以换语言速率会自己跟上)。外推封顶 MAX_EXTRAPOLATION = 1.6 倍——实测一句八字的句读句曾被推到 11 秒并倒插回上一句。_enforce_monotonic 四步:自身非负 → 重叠取中点分界(「重叠说明两句的估计都不确定,
没有理由让先来的全赢」)→ 短句补到 MIN_LINE_SEC = 0.35 但不越过下一句 → 最后一遍无条件强制不重叠
(「不变式必须由一个无条件的收尾保证」——实测前三步之后仍剩 1 处违例)。不按 t0 重排:
LCS 锚点本身单调,歌词顺序是权威,重排曾把第 4 句排到第 5 句后面。--word-level)是按需精修:把一行的字符线性铺在 [t0, t1] 上,够做卡拉OK高亮;
默认句级——句级差 0.2 秒基本无感,词级差 0.2 秒极其显眼。overrides.json 的 lyrics.insert 手工补录(§4.7)。「这首歌是什么语言」全仓只有 align.decide_language() 一处在决定,对齐与听写都问它——
且刻意不写在默认参数上:默认参数在 def 时求值,「唯一那一处」就不存在了
align.py::decide_language docstring。机制:
WINDOW_SEC = 30,这是它的性质不是我们挑的参数)。
而歌的前 30 秒常常没人唱:仓内一首中文真歌第一句在 39.50 秒才进来——人声轨前 30 秒
RMS 0.00094(次低那首的 1/43)被认成 en(0.44),混音前 30 秒 RMS 0.12563(满编制器乐前奏、一点都不轻)被认成 ru
align.py::pick_windows docstring 实测表。SILENCE_FLOOR = 0.25
(低于最响窗口四分之一的窗口不参与投票)挡「对着静音硬猜」;取最响的 VOTE_WINDOWS = 5 个窗口投票取众数
挡「响度正常的器乐前奏」——floor 对后者一点忙都帮不上。Language.unsure:众数不过半即「拿不准」,此时打出票型 + 一条能照着敲的命令(build --language …,
选 build 不选 run 是因为 build 每次真的重做分析、必定有效)。拿不准时不许沉默通过是安全网,
选窗口只是会失效的启发式——两条判据分工写进了裁定 DECISIONS 2026-08-15。meta.language——它是可选字段:硬字幕、--no-lyrics、器乐曲根本没跑过 Whisper,
往那儿写个 "zh" 正是「沉默地猜」;缺席是一句实话。三个常量里只有 30 有依据(Whisper 自身性质);VOTE_WINDOWS=5 与 SILENCE_FLOOR=0.25
是在五首歌上挑的、没做敏感性扫描;unsure 分支在真素材上从未触发过(五首歌两种音源全部全票),
其正确性只由合成用例担保——这些都原样写在 docstring 与台账里,不装成「验过了」
DECISIONS 2026-08-15 残留条 · align.py。
「通用管线打底 + 重点歌精修」里精修的落点:songs/<slug>/overrides.json 在 build 最后一步合并进 timeline,
自动分析不满意就改文件重跑、不动代码。三条设计决定 murripple/overrides.py docstring:
深合并不是替换(浅合并会把整条 lane 换掉、envelope 与 notes 全丢,而产出仍是合法 JSON——只有画面会莫名其妙空掉,最难查);
未知字段当场报错并点名(静默忽略最坏:用户改了半天没反应会以为是渲染层的问题);
缺文件不是错误(绝大多数歌不需要精修)。可改字段:meta.title、sections[i].name/energy(按下标)、
lanes[id].gain/hue/label(按 id 不按下标——下标会随管线改动而变)、
lyrics.offset / lines / insert。
lyrics 三样的先后是承重的:整体 offset 先行;insert(整行补录)按 t0 归位不按下标——
对齐会整句整句地丢,而 lines 只能改已有句子;插入本身在改变数量,再按下标定位是 M4 那一跤的加强版;
lines(单句覆盖,绝对时刻)排最后,下标打的是补齐之后的列表。补录必须三样全给(t0/t1/text)——
缺 t1 渲染层不知道何时暗下去,空 text 是一行看不见的歌词占着时间,比不插更难查。
落点是绝对的,「insert 是一组补录,不是一份要按顺序执行的剧本」——先排序那一行被变异检验证明是死代码后删除
overrides.py::_insert_lines 注释。
build_timeline 只做组装与校验,不做分析。人声在场判定 PRESENCE_THRESHOLD = 0.02
(人声 RMS 高于全曲峰值 2% 才算「在唱」,presence 是逐帧 0/255 二值)。纯器乐回退:
presence 全 0 时判定环改由整体能量(mix 包络)驱动、跳过歌词层、不中断;presence 本身保持全 0,
诚实反映没有人声,供渲染层据此选择表现。stems 字段 = sorted(stem_audio);
section_marks/lane_specs 真值直通的判断用 is not None 不用真值判断——
空列表 [] 会被真值判断当「没给」悄悄落回检测老路,is not None 则让它在 schema 的
minItems: 1 上当场炸掉:同一个误用,从「静默出错的产物」变成「构建时就失败」
timeline.py docstring。
「这是构建时管线与运行时渲染器之间唯一的接口。渲染器不认识 Demucs 或 librosa,只认识这份文档。」
murripple/schema.py docstring。JSON Schema Draft 2020-12,SCHEMA_VERSION = 1,
顶层八个字段全部必填、additionalProperties: false:
| 字段 | 形状 | 语义与承重细节 |
|---|---|---|
meta | title / duration / bpm / codec / schemaVersion 必填;language 可选 | language 只在 WhisperX 真听过时存在——必填等于逼 build_timeline 编一个;缺席是一句实话。四份已交付 timeline 没有这一格且不许回归 |
sections | [{t, name, energy}] | name 自动分段时恒为空串(空名不显示段落大字);energy ∈ [0,1] 驱动配色饱和度 |
beats / downbeats | number[](秒) | 渲染层拍点脉冲 exp(-Δt/τ) 反查用;不取模——真实拍点网格有偏移(实测首拍 1.776 s) |
ring | {envelope, presence},都是 base64 | 判定环数据源:人声包络 + 逐帧「是否在唱」二值;纯器乐时 envelope 换成混音包络、presence 保持全 0 |
stems | 非空、去重的字符串数组 | 本曲实际有哪几条分轨,由数据声明不写死四条——Demucs 反拆只有四条是它的约束,合成曲九条。渲染器解码循环、静音状态、pack 的音频遍历全按它走 |
lanes | [{id, label, hue, stem, gain, notes, envelope}],minItems 1 | notes: [{t, v∈[0,1], pitch: number|null}];envelope 是 base64 的 60 Hz uint8;hue ∈ [0,360] |
lyrics | [{t0, t1, text, words}],words 可为 null 或 [{t0,t1,c}] | 句级为主,词级按需(--word-level) |
schema 之外还有两层校验,各有分工 murripple/schema.py::validate_timeline:
contentEncoding 在 Draft 2020-12 里只是注解关键字,jsonschema 不强制——
非法 base64 照样过 schema。所以 base64.b64decode(..., validate=True) 手动验 ring 与每条 lane 的包络。SchemaError 与 ValidationError(字段形状错误)区分开。uniqueItems 先抛)、
「stems 缺失回退白名单」的兜底在 stems 转为必填后同样死掉——都被变异检验证明后删除。
「不留一段测不到的代码」。序列化格式是承重的:json.dumps(doc, ensure_ascii=False)——默认分隔符、无末尾换行
cli.py · DECISIONS 2026-08-14「迁移的序列化要照 cli.py,不是计划稿写的 separators=(",",":")」。
pack 把这份原文逐字节内联进 index.html,所以任何比对产物的判据都要经过它。
选 AAC 不选 Opus:Opus 体积更小,但 Safari 支持历来不稳,而核心场景是「发个链接谁都能开」。
macOS 上优先 AudioToolbox 的 aac_at(音质好于 ffmpeg 原生 aac,探测一次 lru_cache),
默认码率 64k,-movflags +faststart。每条 stem 一份独立 m4a → base64 data URI 内嵌。
必须走 data URI 而非独立文件:file:// 下 fetch 被 CORS 拦、createMediaElementSource 会因跨域污染而静音,
只有 base64 → ArrayBuffer → decodeAudioData 这条路走得通 murripple/encode.py 注释。
与 build 拆开的理由:分析约 84 秒、打包约 2 秒,调视觉时每改一次都重跑分析没法忍。流程:
npx --yes esbuild src/main.js --bundle --format=iife --global-name=murRippleApp --minify
(注意 renderer 自己的 npm run bundle 不带 minify,那份是给测试 harness 用的)。__TITLE_JSON__(JS 字符串上下文,json.dumps)、
__TITLE__(HTML 上下文,html.escape——两个落点转义方式不同,不能共用占位符:HTML 实体在 script 里不解码,
以反斜杠结尾的曲名会让整段 script 语法错误)、__TIMELINE__(原文)、__AUDIO__、__BUNDLE__。
不能链式 replace——曲名里写 "__BUNDLE__" 就能把整个 bundle 再插一遍。全部数据过
_js_safe 把 </ 转义成 <\/:数据里出现闭合标签会让浏览器提前闭合脚本,
而 JSON 本身完全合法、任何 JSON 校验都发现不了 murripple/pack.py 注释。MB = 1e6 字节(十进制),不是 1024²。三条理由 pack.py:22-33 · MGMT.md 第六节: ① 全项目现存实测数字全部按 1e6 记,改二进制会让三份文档同时变错;② 1e6 更严(15,000,000 vs 15,728,640,差 4.86%), 而九条分轨的余量只有约 9%——上限拿不准就取严的;③ macOS 的 Finder 与 du -h 都按十进制报, 守卫的单位要跟裁决人眼睛看到的单位一致。超限是硬失败不是警告—— 九条分轨实测约 13.6 MB 时余量只剩约 9%,警告会被当噪音略过 DECISIONS 2026-08-13。
| 产物 | 时长 | 体积(字节) | 备注 |
|---|---|---|---|
| 真歌 01 | 270.0 s | 12,169,222 | 四 stem · 六 lane(下同) |
| 真歌 02 | 149.6 s | 6,826,764 | |
| 真歌 03 | 238.8 s | 10,890,651 | |
| 真歌 04 | 295.0 s | 13,363,895 | 体积最紧的一首,距上限约 1.64 MB |
| 《Trempe-moi》 | 238.3 s | 10,886,579 | 公开 demo 与公开仓的示例歌 |
| 合成曲(九条分轨) | 150 s | 14,183,341 | 余量 816,659 B |
出处:前五行为 tests/fixtures/real-songs-baseline.json 锁定值(回归守卫的基线,另配 50 KB 体积漂移棘轮);合成曲为 DECISIONS 2026-08-14 Task 9 实测。
第十条声部装不下——这笔账不依赖任何开销估计 MGMT.md P1.9 · DECISIONS 2026-08-14: 设非音频开销为 O,十条总量 = O + 10×(14,183,341 − O)/9;要 ≤ 15e6,须 O ≥ 6,833,410 B—— 占产物 48.2%,而产物里绝大部分就是九段 base64 音频,不可能。所以任何 O < 6.83 MB 下第十条都溢出。 同理真歌 270 秒 × 9 条会到约 25.9 MB——九条分轨是合成曲专有的,真歌不走这条路 M5v2 spec 第五节。
① 降级由渲染模式驱动,不由帧率驱动;② 时间滞后量是 t 的纯函数或预计算数组,
绝不在 draw 里累加;③ 固定 STEP = 1/120,mulberry32(SEED ^ id) 派生、与调用顺序无关。
另加一条渲染禁令:不用 shadowBlur——各浏览器与硬件实现不一,辉光一律用预渲染的径向渐变精灵
+ globalCompositeOperation = "lighter"(顺带快一个数量级)MGMT.md 第六节 · glow.js。
三铁律的落点举例:星云轨道角与浮尘高度写成闭式(a0 + t*sp、(y0 - t*vy) mod H);
涟漪/冲击弧/光扫/段落大字全部「反查而非累积」——参考实现是 push(...) 逐帧累积再 filter 的,
逐帧导出下跳到第 200 秒直接渲染时数组是空的,涟漪就消失了 ripple.js 注释。
唯一有状态的一层是粒子,靠固定步长 + 种子派生兜住:每次命中的光屑用
mulberry32(0x9e3779b9 ^ ((laneIdx+1)*0x85ebca6b) ^ noteIdx),不用全局序列
core/particles.js。背景层各有独立字面量种子(星野 0x5eed、流星相位 0x5e11、噪点瓦片 11)。
STEP = 1/120;时间用整数步数不用累加——「simT += STEP 会累积浮点误差,
1/120 在二进制里不精确,3 分钟下来会少走整整一步」。advanceOrRewind(targetT, world):目标时刻早于 simT - STEP 就
reset(清空世界、从 0 重放到目标——3 分钟 = 21,600 步纯计算只要几十毫秒),否则只前进。
seek 之后的画面因此与离线渲染一致。quality 由 mode 决定不由实测帧率决定:MODE_OFFLINE 强制 quality=1,
各层读 state.quality 降级(浮尘/流星/颗粒省略、刻度环齿数减半、谱线 66→40 桶、波形 180→90 点、彗尾 5→2 段),
不得自行判断帧率——否则同一个 t 在两种模式下画出不同的帧。AudioContext.currentTime,不用 performance.now()——后者与音频漂移,几分钟能差出小半秒
core/audio.js。main.js 的 LAYERS 数组顺序即叠放顺序,注释明令「不要随手往后追加:歌词必须压在波形之上
(否则波形锯齿切碎字形),光屑必须压在环之上(否则命中的爆发感被环盖住)」。
state(t / palette / beat / timeline / audio / geom / …),常量集中见附录 A3。所有半径以 R 为单位、常量集中在 core/geometry.js,各层不得自己定半径:
R = min(W,H) × 0.225(M2-4 之前是 0.28——「实心结构只占 0.225,靠向外发散的谱线撑出 0.43 的轮廓:
骨架细、轮廓大,这才是纤细灵动的来源」);圆心 cy = H × 0.485;dpr 封顶 2。
半径阶梯:波形 0.55 → 内刻度 0.8 → 判定环 0.9 → 车道弧 1.0 → 外刻度 1.06 → 谱线基 1.02 →
谱线顶 1.95 = 音符出生点 1.95。
配色是两段式:每条 lane 的基色相由 Python 的 LANE_SPECS 定死写进 timeline;
渲染层只做 (hue + palette.hueShift) % 360,其中 hueShift = 段落序号 × HUE_STEP(37)——
37 与 360 互质,段落再多也不会提前撞色;饱和度 = 45 + 段落能量 × 40
core/palette.js。段落名默认为空,段落结构只能靠颜色传达——「这一层比看上去重要」。
非 lane 图层各有固定基偏移(背景/环/歌词 210,光核/刻度 205,涟漪/光扫/谱线/段落字 200,波形 190)。
同一段落内配色恒定不随 t 漂移是辉光精灵缓存有界的承重前提:全曲多轮扫描(含 1/60 s 逐帧两遍)
精灵稳定 199 个、约 6.6 MB、零增长;把 hueShift 改成随 t 漂移,条目数涨到 3,706 且 palette 测试当场红
core/glow.js 量测注释 · DECISIONS 2026-08-14。
layers/lyrics.js 是 M2 的核心视觉决策「歌词即光核」,也是全仓打磨最狠的一层。断行契约:
MAX_CHARS_PER_LINE = 9(导出是给守卫用的):「每一行都放得下」拆成两半验——
lyricsFit.test.mjs 一半在全部真语料上穷举断言没有任何一行超预算(纯函数),
一半进真浏览器画真的字、扫像素量墨迹的横向范围,证明预算真的落在画面里。
后一半故意跑正方形视口:字号按 min(W,H) 定,16:9 下溢出比例只有正方形的 56%,
宽屏那一档恰好躲开问题 lyricsFit.test.mjs · DECISIONS 2026-08-15。WORD_SCRIPT = /[A-Za-zÀ-ʯͰ-ϿЀ-ӿ]/(拉丁/希腊/西里尔)→ 贪心装箱、空白原样保留;
否则句读切分 + 全宽 token 装箱。分水岭的理由:「中文里的空格是句读,删掉不丢信息;
法语/英语里的空格是词的边界,删掉就把 spare part 粘成 sparepart」——这个缺陷在已交付产物里存在过,
直到第一首法语歌把它照出来。宽度模型:CJK/假名/全角算 1、其余算 0.5;超长拉丁单词宁可溢出也不断在词里。splitLine 必须是纯函数(静态宽度模型,不碰 measureText)——
量真实字宽会把断行结果绑到字体与设备上,同一个 t 在两台机器上断出不同的行,破确定性铁律。LEAD_IN = 0.5——淡入必须在 t0 之前完成,唱到那一刻字已经在了
(初版从 t0 开始淡入,t0 时刻 alpha 恰好为 0);TAIL = 0.4 收尾。coreYield——「让位是必要的但不是充分的,字那边还得有压幕」)。createPlayer 把三种状态分开:running(用户意图)/ ended(到头了,按
elapsed() >= duration 判,duration 用 meta.duration 不用 buffer 时长——实测四首歌 16 条
stem 与声明时长差全部毫秒级,最大 12.99 ms)/ playing(真的出声)。播完再播从头开始
(resumeFrom)。各 stem 同一时刻 start(0, at) 保证采样级同步;静音是 20 ms 增益渐变不是 stop——
stop 之后再起会失去同步。增益节点按 Object.keys(buffers) 建,不写死四条。音量与静音分两级 gain——
合在一起的话调过音量再取消静音就回不到原响度 core/audio.js。OfflineAudioContext 重渲:分轨清单由 buffers 自己的键集合决定
(写死 STEMS 会让合成曲九条只混前四条),取最长一轨为长度(解码后差几个样本是常事,取短会截尾),
链路里没有压缩器、没有限幅器——只有 0/1 静音增益 + WAV 写盘时的硬钳位
core/mixdown.js。界面全部是 DOM 覆盖层不进 canvas——「M3 逐帧抓 canvas 导出,画进 canvas 的界面会被烤进每一帧视频; DOM 覆盖层让导出天然干净,零额外代价」ui/hud.js。要点:
lane.stem 推、只看紧邻两行(非连续时画成两段各自封口的短脊——
宁可少声称,不要错声称),不硬编码「kick/snare/hat 是鼓」——素材换了、六轨模型上了都不会开始骗人
ui/voices.js · DECISIONS 2026-08-14。LABELS:撼岳/裂帛/碎玉/渊鸣/流岚/缥缈/泠泠/霜铎 + 心籁;
mid/pad 共用「流岚」、air/pluck 共用「缥缈」是刻意的——两者永不同时出现在一份 timeline 里。
命名按声部在乐曲里的角色取意,不沿用参考项目那一套(那是它的创作内容,守卫见 §12)。
环外小字有 labelFor 兜底(LABELS[id]?.zh ?? lane.label)——侧栏不进 MP4,
环外小字是声部名在视频里出现的唯一位置,丢了兜底九条档的 MP4 会有 5 个声部无名
DECISIONS 2026-08-14。previewFrame 不推进世界,松手 seek 一次性补齐)。实时模式下画面只是 state 的函数:state 不变时重画纯属白烧——实测这条白烧循环占一个核的 111%,暂停时也一样烧;修复后暂停 113–115% → 2–9%、播完闲置 → 7–8%(约 13 倍) DECISIONS 2026-08-15 实测。机制 main.js:
frameKey = t | simT | hoverLane | audioGen | WxH@dpr;相同则跳过绘制。FIELD_ROLE 穷举表把 drawAt 放进 state 的每个字段分类为
t / const / key,首帧与真实 state 的键逐个对、有没分类的当场抛——「能在 t 不变时改变画面的输入」
不靠记性枚举,加字段就被迫分类;测试从这张表推导探针清单,新加 key 字段没写探针会红。invalidate():resize(给 canvas.width 赋值会清空画布,哪怕值没变)与
previewFrame(画的是另一个 t)。「失效通道漏一处 = 画面卡住不动,比多烧 CPU 严重得多」。skipUnchanged = mode === REALTIME 写死——
「MP4 不受影响」是结构性事实不是概率论断;改动前后真导 MP4 比对 sha256 相同
DECISIONS 2026-08-15。node video/render.mjs ../songs/<slug> [--fps 60] [--size 1920x1080] [--from --to] [--crf 17]。
四条设计决定,每条都有原因 renderer/video/render.mjs 头注释:
mode:"offline" 的实例
(window.__EXPORT__ 钩子)。音频用原始音源直接封装零损耗——产物里那份 64k AAC 是为了体积。实测参考:4.5 分钟 · 1080p60 ·
16,200 帧约 33 分钟、163 MB README.md(这也是「不做 mp4 下载按钮」的原因之一:
GitHub 单文件上限 100 MB,且破坏零外链离线 DECISIONS 2026-08-13)。
调参用 --from 40 --to 50 只导一小段。导出级确定性有专门守卫:60 fps 600 帧两次独立运行逐帧哈希一致、
从中途某帧直接开渲必须与从头渲到那里相同(--from 正是这么用的)
renderer/test/export-determinism.test.mjs。
M5:不给音频,摇一个 seed 自己写一首(当前状态:数据契约与分轨已验收,音乐本身尚未过耳朵关——
第二步未开工)。公开树不带它,CLI 对它 try/except ImportError:合成器不在时不注册 compose 子命令——
「注册一条跑不了的命令比没有更糟」cli.py 顶部注释。
| 模块 | 职责 | 要点 |
|---|---|---|
theory.py | 乐理纯数据 | 五声调式两个(宫 0,2,4,7,9 / 羽 0,3,5,7,10——七声的偏音一出来味道就偏西洋流行);和弦进行池 5 套写死不生成(按音阶级数写,宫羽通用);12 个音名 |
motif.py | 动机生成与打分(A3 路线) | 摇 50 条候选 → 四把尺子打分取最高:内部重复 0.35(「像人写的」最关键)、跨度 0.30(理想 1–4 级,均值 ≥7 直接判 0)、落音 0.20(强拍命中和弦音)、轮廓 0.15(唯一最高点且不在首尾)。「生成交给随机,取舍交给规则」。变奏算子五个(transpose / new_tail / ornament / thin / invert,后两个未上生产路径) |
score.py | 唯一对外数据结构 | Score(frozen)↔ compose.json(给人改的文件,ensure_ascii=False + indent=2);九条音轨 lead/bass/pad/pluck/arp/bell/kick/snare/hat;时间一律秒不用 tick;未知/缺失声部当场报错 |
arrange.py | 段落编排 | 起承转合密度 0.30/0.55/0.85/0.50(四个数要穿过下游量化台阶才算数——「合」原写 0.65 与「转」落进同一格,「收」根本没发生);四段旋律全部由同一条动机变奏(任何一段偷偷重摇立刻散架);「起」只有 lead+pad+pluck;每声部一条 PCG64 流,seed ^ 部件序号 派生、序号表固定不变(改了会让所有既有 seed 变曲) |
voices.py | 八个伴奏声部的节奏型 | 音域分层:bass oct3 / pad oct4 / pluck·lead oct5 / arp·bell oct6;拨弦八分网格随机撒点 2–6 个/小节、琶音按序走位(这是两者写法上的分野)、铃只在换和弦时敲根音、鼓组 密度过半加花(阈值都在 0.5) |
synth.py | 纯 numpy 合成器 | SR 44100;九个音色渲染器(正弦谐波笛箫 / 锯齿低通贝斯 / 三失谐锯齿 pad / Karplus-Strong 拨弦 / 双锯齿琶音 / 2-op FM 铃 / 60→40 Hz 扫频底鼓 / 带通噪声军鼓 / 高通噪声踩镲,ADSR 逐值见源码);Schroeder 混响(4 梳 0.78 反馈 + 2 全通 0.7,wet 0.28)只加 lead 与 pad——鼓组干声,画面的节奏感全靠鼓组包络;母带只有固定增益 + 按九条共同峰值归一到 −1 dBFS,不压缩不限幅 |
compose 写四份真值文件,build 逐一直通:扁平 build/stems/*.wav 九条(跳过 Demucs)、
build/sections.json(段落边界真值,跳过自相似矩阵猜测)、build/lanes.json
(轨道真值:id/label/hue/notes,跳过带通切分 + onset 检测——合成曲的音符表直接来自 Score)、
compose.json(过歌词门)。主奏单独进 vocals stem 驱动判定环
(实测与 presence 相关系数 0.925,对鼓只有 0.084),打击声部 pitch 置 null(乐谱里的 0 在 MIDI 上是真实的 C-1,
照搬会把每一记鼓画在最低音位置)。同 seed 逐字节复现;换 seed 重摇会主动作废旧 timeline——
不作废的话 run 会「分析 跳过」然后打包上一个 seed 的成品,一句警告都没有:timeline.json 就是缓存,
缓存的输入变了就该失效 cli.py::compose · MGMT.md 考卷第 3 题。
W1:murripple serve 起一个网页——选文件、贴歌词、点开始。它解决的不是「命令行难用」,
是目录约定:「songs/<slug>/source.mp3 + lyrics.txt 那套约定是给我们自己用的,对外人是纯粹的心智负担」
W1 spec 第一节。立身之本两条:不 import 分析管线
(整个包只用标准库,跑歌全靠 subprocess 调 murripple 命令,有干净子进程守卫钉着;
唯一从管线拿的是 DRAFT_FILENAME 这个文件名常量),管线一行不改
(首版合并时 cli.py +11/−0,其余零改动 DECISIONS 2026-08-14)。
127.0.0.1,没有开关也不该有——这个服务拿用户给的路径起子进程、没有认证,
绑 0.0.0.0 等于把文件系统与子进程交给同一个 WiFi 下的任何人。PORT_SEARCH_SPAN = 20 个(只接 EADDRINUSE,其它错照抛;
连着二十个都满多半是别处出了问题),终端打印实际拿到的端口。brew install ffmpeg 都看不到;
但建任务时硬拒:缺了它跑到第 4 步才炸,前面几分钟白等 web/app.py::_create_job。ThreadingHTTPServer:页面要一边跑任务一边轮询,单线程一个连接没读完就堵死全部。| 端点 | 行为 |
|---|---|
GET / | 逐字节发 static/index.html——不在 Python 里拼(拼的话页面上那些守卫全白守) |
POST /api/job | 建任务。请求体是原始字节(不走 multipart——cgi 已在 3.13 移除,手写解析是坑;代价说在明处:整份读进内存),文件名走 X-Filename 百分号编码(HTTP 头是 latin-1) |
POST /api/job-from-url | 链接路另开端点(不与上传挤一个);不在 POST 里同步取回——那会挂住几分钟且页面一个字都没有 |
POST /api/job/<id>/lyrics | 存歌词;空白拒 400——空文件会骗过 exists() 检查 |
POST /api/job/<id>/start?stage= | 起子进程(run / ingest / transcribe 三档) |
GET /api/job/<id> | 状态:两级进度 + 分层日志 + error;主日志截尾 20 行、详细区不截,被挤出的主日志行也进详细区(截尾这个防护动作不许伤到「降级必须大声说」) |
GET /api/job/<id>/result | 完成后流式发 dist/index.html(十几 MB,不整份读进内存) |
任务落在 songs/web-<时间戳>-<原名>/,与目录约定同一套。文件名消毒只有一处
(safe_stem,96 字节 = 32 个汉字上限——实测 APFS 单段 255 字符、ext4 255 字节,按字节截两边都活;
消毒散开的话删掉一处还有另一处兜着、变异检验会全绿);目录建立用不带 exist_ok 的 mkdir 认撞名——
先 exists 再建在并发下会静默覆盖,「用户只会发现歌变成了另一首」。一次跑一个不做队列;
job_id 只活在进程内 dict 里——刷新页面/重启服务接不上(spec 里那句「刷新能接上」是被收口评审
订正过的假声称);真正成立的是:目录还在,命令行对同一目录跑 run 确实接着跑
web/app.py docstring · DECISIONS 2026-08-14。
stderr=STDOUT 合并:失败消息全走 stderr,不合并的话用户只看到「失败了」而没有原因。
代价说在明处:合并后分不出哪行来自 stderr,spec 那句「stderr 最后 20 行」落成「合并流最后 20 行」。PYTHONUNBUFFERED=1 是承重条件:cli 的 print 不带 flush,管道上 stdout 块缓冲——
一次 run 的全部输出才一两 KB、4 KB 攒不满一个字也不出来,「实时进度」当场变成「跑完一小时一次性刷出来」。
这一条不在计划稿也不在 spec 里,是真跑撞出来的 DECISIONS 2026-08-14。另有两处结构纪律:读取线程的 finally 必须收尾且先关管道再 wait——
直接 wait 的话管道一满子进程永远卡在 write、我们永远卡在 wait,双向死锁连收尸都收不掉;
murripple_command() 不许 .resolve()(§13 详述这个真 bug)。
命令拼装不加 --force:同一目录再点开始要接着跑,不是从 Demucs 再来一小时。
[n/2] 是外层、[n/5] 是内层,两者都顶格打印;
缩进在这份输出里标的是子消息不是层级——「按缩进判断」两次被真实输出推翻(第三方的
warnings.warn( 缩两格、我们的 [1/5] 在第 0 列)。分母既不是 2 也不是 5 的行只进日志不动任何层:
「不知道这是哪一层,就照实说不知道」。外层前进不清空内层、两层不压成一个百分比
(压成一个的话 [5/5] 之后来 [2/2] 就是进度条倒退);None 与 0/2 是两回事(跳过路径下内层该显示「没有内层」)。
真正的六格进度条在前端按 (outer.step − 1 + within) / outer.total 算,无内层细分时按走了一半计。_OURS 约 40 条正则,每条带 cli.py 行号出处):
我们的输出进主日志,第三方噪声(「Bad things might happen unless you revert torch to 1.x」这种会吓到用户的话)
折进「详细输出」。一行都不丢,分错只是位置不对——layers 与 log 平行存、
main + detail 逐行还原得回 log,是恒等式不是承诺。认不出默认进详细区(白名单必然有漏,
放宽默认值的代价是吓人话跳上主日志)。唯一的第三方例外:uv 下载运行时那三行进主日志——
第一次拉 36.7 MB 的那几分钟里,它们是「没卡死、正在下东西」的唯一证据。[取回] 前缀 → phase="fetch"),不是外层第三步——
分母永远是 2,「有时候 2 步有时候 3 步」是要读代码才懂的东西。DEGRADED_MARKERS 14 条(跳过/降级/退回/未找到/未对上/警告/失败/……/听不了:/一个字都没听出来),
命中的行在页面上标色并挂「查看原因 ▾」。最后一条是 2026-08-16 补的:它是「听不了:」的兄弟句——
同一条路、同一个结局,白名单里那条「一行都没认出来」是 OCR 路的另一个字符串、一个字都对不上,
很容易误以为已经盖住了 DECISIONS 2026-08-16。单页零外链(与产物同一条规矩;内联 SVG 不写 xmlns——写了会把 w3.org 的命名空间地址带进文件、零外链守卫当场红)。
视觉定稿「极光玻璃」:F 底 + E 玻璃卡 + 六色进度条;七个声部色相 CSS 变量逐个等于产品真画出来的那套——
2026-08-15 曾照出壳子色板与 lanes.py 不是一套(裂帛 350 被挑成 312,差 38 度是肉眼分得出的两个颜色),
于淼裁定壳子服从产品(反方向要重打四首歌,且浮点不确定性会产出不同 lanes),现由
tests/test_web_palette.py 钉着:真值从 murripple.lanes.LANE_SPECS import、
心籁从 voices.js 正则读,测试里没有第二份色相表;另单立一条「hsl() 里的值必须等于旁边注释自称的那个数」——
一句声称了自己没做到的事的注释,比一个错的数值更坏 DECISIONS 2026-08-15。
产品语言上的几条硬决定:音频路歌词必填(没歌词「开始」是灰的并写着为什么; 勾「先让它在本机听一遍」是用户自己按下去的出口,听完停在校对框等人断句); 网页不给 --no-lyrics——歌词是这个产品的卖点,给「不要歌词」的按钮等于邀请人做一份没有卖点的东西, 命令行留着那条路、那里的用户知道自己在干什么 DECISIONS 2026-08-16 于淼批准; 网页不给语言选择框——侦测存在的意义就是不逼人背 Whisper 语言码,「说出来(页面要)」与 「能推翻(命令行给得了)」是两件事,页面只需要前者 DECISIONS 2026-08-15; 听写等待期页面只报「这一步已经跑了 X」(页面自己量的墙上时间,误差 ≤ 700 ms 轮询间隔)—— 不预测任何事,没有百分比没有「预计还需」,有守卫把 %/预计/还剩一律挡住 DECISIONS 2026-08-16。校对框两条路共用、提醒后半句按路分: OCR 整行整行地漏、听写字会错且根本不断句——「说同一句就有一条在骗人」。
「确定性」在本仓分三层,混着说就会说错 DECISIONS 2026-08-15「订正『产物可复现』」:
| 层 | 保证 | 依据 |
|---|---|---|
| 渲染(产物内) | 逐帧逐字节确定 | 同一 timeline 两次独立运行逐帧哈希一致;乱序渲染 = 顺序渲染;实时 = 离线;从中途帧直渲 = 从头渲到该帧。守卫:determinism / export-determinism / drawskip / sky 等测试 |
| 合成曲(compose) | 同 seed 逐字节复现 | 纯 numpy + PCG64(seed ^ 固定部件序号);连 Karplus-Strong 的激励噪声都按 (pitch, t) 派生 |
| 真歌分析(build) | 结构可复现,数值不可逐字节复现 | 同机同源连跑两遍实测:歌词层逐句全同(text/t0/t1 一个不差)、beats/stems/ring 全同、lanes 音符数相同、时刻偏差 0,只有力度 v 在 1e-4 量级抖(Demucs/torch 浮点不确定性;--shifts 0 已消掉的是「结构级」随机)。比对产物要比结构与歌词,别拿 sha256 当判据 |
由此派生的验收纪律:真歌回归守卫的判据是「timeline 除新增顶层 stems 外逐字段相同 (canonical 序列化后比 sha256)」——「产物逐字节不变」这个判据数学上不可能成立 (pack 把 timeline 原文内联进 index.html,改 renderer 重新 bundle 后 index.html 又变一次) DECISIONS 2026-08-13。
规模:pytest 1,094 条(52 份文件 + tools/ 下 2 份,本文写作时在补齐真歌产物与 align extra 后实跑 1,094 passed / 2 skipped——那 2 个 skip 是 test_web_palette 参数化里壳子色板本来就没有的两个声部,私仓恒有); 渲染层 33 份 .test.mjs、301 pass(node --test + Playwright 三份 harness 页 DECISIONS 2026-08-16 管理窗口实跑)。取向写在 README: 每条断言都要能回答「什么样的错误实现能让它照样绿」——关键守卫都配了破坏实验: 改坏实现、亲眼看它变红、再还原。变异检验分两类不合并记:对实现变异(改坏代码)、对被测数据变异 (把该在的挪走 / 把不该在的塞进去——「守卫放宽后还抓不抓得住该抓的」只能靠后者验) MGMT.md 第七节。几条从血里换来的判据(复现者照抄即可少踩一轮):
--ignore=。exc.value.message 不断 str(exc)——jsonschema 顶层失败会把整份 schema dump 进
str,match= 随之失去分辨力。| 守卫 | 守什么 | 机制要点 |
|---|---|---|
tests/test_no_forbidden_content.py | 参考项目 light-loom 的创作内容(九个声部名 + 曲名,明文十词表)不得出现在会发布的路径 | 扫 murripple/ · renderer/src · renderer/test · renderer/video · tests/ · template.html · README(私仓另加 docs/site);豁免按 (文件, 词) 对不按行号(行号会漂);不扫 gitignored 的 dist/(可能不存在的扫描目标 = 默认通过的分支);git ls-files 为空时锚点断言拦住 |
tests/test_no_private_lyrics.py | 四首私有歌曲的歌词句子不得出现在会发布的路径 | 词表不落盘:运行时从 songs/*/lyrics.txt 现读(排除示例歌),真相源缺失报错不报 0;归一化比对(繁转简复用 align._normalize——Whisper 吐繁体,按原文比一条都扫不到);≥6 字才算判据(短句撞车率高,已知下限写明);反方向正面证据:拿真歌词扎假文本必须被抓、自造语料必须放过 |
tests/test_web_palette.py | 壳子色相 = 产品色相 | 真值 import 自 LANE_SPECS,测试里没有第二份表;另断注释自称的数 = hsl() 里的数 |
tests/test_regression_real_songs.py | 五份已交付产物不许回归 | 基线 fixture 进版本库:整份 timeline 的 canonical sha256 + 七个顶层键各自 sha256(判「变在哪一段」)+ lane id/stem + 产物字节数(50 KB 漂移棘轮)+ 时长;盘上有歌基线里没有会红(排除谓词只认 tmp-/web- 前缀) |
tests/test_site.py | 介绍站(docs/site/,挂 murripple.miao-yu.com) | 演示页减掉 MR-DEMO-PATCH 补丁块后必须逐字节等于产品页(恒等式不是相似度,且验「尺子真的减得动」);预录日志两份抄件互证 + make-frames.py 真跑重算;准确率尺子扫「词 + 数」不扫词本身;零外链白名单三条远程地址 |
公开仓是新建的干净树(一笔英文提交、无开发史),不是把私仓翻公开。生成器用 可判定的谓词排除,不用文件清单——「手编清单的固有失败方式就是漏;规则表新增文件时默认也是『进』, 但判据不看名字看性质」。八条第一遍规则 + 一条第二遍规则:
mgmt:MGMT/DECISIONS/BOOT 三个写死的名字 + docs/ · .claude/ · .superpowers/ · .replica-local/ 前缀。private-material:songs/ 整棵(白名单例外:示例歌的 lyrics.txt 与 source.mp3 两份,
逐份列不整目录带走;source.mp3 是「版本库里没有、但要进公开树」的唯一一处,取不到就 SystemExit——
它曾被公开仓自己的 .gitignore 挡在提交外、推送前最后一刻才逮到:用户 clone 下来 README 教的第一条命令当场失败
DECISIONS 2026-08-16)。compose-engine:murripple/compose/ 整包。compose-dependent-test:AST 判定 import 了 compose 的测试——用 AST 不用子串有实测依据:
grep -c compose 在三份好测试上分别是 7/3/2,按子串排会误伤。needs-real-songs:正则判定「读仓内 songs/ 目录」的测试(刻意不匹配小写 repo / "songs" 的纯路径比较)。private-corpus:断行真值语料三份(real-lyric-rows.json 装着五首歌 235 句完整歌词,
它不在 songs/ 下、不叫 lyrics.txt、不 import compose——前六条谓词全部放行,是 2026-08-15 挖出的 228 处泄漏主体;
代价明写:公开仓因此没有 CJK 断行回归)。reads-mgmt-docs:读仓内 docs/ 的测试(docs/ 整棵被 mgmt 排掉)。release-tooling:tools/ 自身。orphan-fixture:「排掉了消费者、没排掉被消费的数据」——留下来的夹具必须有活着的消费者,
「有人用」只认字符串字面量(注释与 docstring 不算——实测两句纯注释就能让守卫失守)。
这条规则是三次同形状事故(real-lyric-rows / real-songs-baseline / 04-align-unmatched)之后立的可判定规则,不是修那三次。此外:候选来自 git ls-files(会被发布的就是版本库里的东西;有未跟踪文件直接停——
这道防护在真实场景里挡住过一次「两份新 README 还没 add 就生成」);tools/public/ 是 overlay
(三语 README、LICENSE、英文注释版 pyproject、pipeline.svg、shell.png 逐份盖上);
--git-init 存在的理由是违禁词守卫用 git ls-files 取扫描对象、没有版本库会扫到 0 份然后全绿。
验收不信生成目录,要 git clone 一份来验——「端到端验的是生成出来的目录,而用户拿到的是 clone
下来的东西」是 2026-08-16 一天四次「核错了对象」的第四次 DECISIONS 2026-08-16。
发布配套脚本 tools/check_public_residue.py(残余中文与歌词残留扫描)刻意是脚本不是 pytest:
它此刻红得有原因,写成 pytest 会让红色变噪音;其二进制后缀表是 denylist 不是 allowlist——
白名单曾漏掉没有后缀的 LICENSE(公开仓最重要的那份文件从来没被扫过),「白名单漏了没有声音,
denylist 误报有声音——守卫要选有声音的那一边」。
ymustc/murRipple-public:161 份文件 / 7.3 MB(含示例歌 5.6 MB),三语 README
(GitHub 那排标签加不了自定义项,通行做法是三份文件顶端互链),架构图是独立 .svg 相对路径引用——
GitHub 的 Markdown 会把内联 SVG 标签整个剥掉(§13)。从 GitHub 真 clone 验过:
722 passed / 2 skipped,README 教的第一条命令跑通出片 10.9 MB。docs/site/ 挂 murripple.miao-yu.com(无构建无依赖,双击可看),
不进公开仓——它是独立可发布物,带进去等于放一份没人守恒等式的副本。ymustc/murripple-demo:内容是《Trempe-moi》的成品页——
它能公开的全部理由是版权归作者本人。Pages 的 CDN max-age=600,推完十分钟内看到旧版不算部署失败。一份不写已知缺陷的复现指南,会让复现者把 bug 当成自己的错。下表第一行尤其如此: 你照本文复现出的系统若在音符力度上表现「正常」,那说明你没有复现出当前行为—— 当前行为里那个 bug 是真实存在的。
| 缺陷 | 现状与依据 |
|---|---|
| ★ onset 力度恒近零(真 bug) | analyze.detect_onsets 用 backtrack=True 回退后的帧号去读 onset 强度,而回退的定义就是退到波谷——音符力度 v 大多接近 0(kick/snare 实测恰好 0.0),渲染层彗尾透明度(× note.v)因此一直失效。五首歌的 lanes 里大量 v:0.0 与之相符。不当下修的理由:改它会动全部五首歌的 timeline 与回归基线,须单独立项 DECISIONS 2026-08-19 · M5v2 spec。修复方向:强度按 backtrack 之前的峰值帧读取 |
| 合成 lane 的音高微偏移失效 | notes.js 的 PITCH_LO=24 / PITCH_HI=60 按真歌 bass 实测定;合成曲 pluck(60–79)/arp(72–91)/bell(72–81) clamp 后各只剩 1 个取值——恒为统一倾斜。真歌不受影响;不能靠调大 PITCH_HI 修(会动真歌 bass 的角度),安全修法是按 lane 各自跨度归一化 MGMT.md P1.9 ② |
| arp/pad 色相只差 10 度 | 八条合成 lane 色相最小间距 10(arp 165 ↔ pad 175),且两者会同时出现在一份 timeline 里;「相邻至少差 30 度」那句注释在本仓从未成立过(真歌最小 20)。数值保留、加 min gap ≥ 10 棘轮,挪不挪待视觉裁定(60→165 之间有 105 度空档)MGMT.md P4 |
| 韩文等语种断行 bug | 谚文音节 U+AC00–D7AF 既不在 WORD_SCRIPT 也不在 WIDE——韩语用空格分词、字又全宽,会走中文那一路、空格照删。同族:阿拉伯文/希伯来文/泰文。没有素材可验,只报告不动手 DECISIONS 2026-08-15 |
| 对齐字速污染无守卫 | difflib 在全曲字符流上做 LCS,末句尾部字符可能被吸附到几十秒外的重复段落上,测出十倍离群的字速;MAX_EXTRAPOLATION 挡「外推太多」挡不住「速率本身被污染」。可执行的新判据(字速离全曲中位数一个数量级即不信任该外推)已写下、未实现 DECISIONS 2026-08-15 |
| 语言 unsure 分支无真素材背书 | 五首歌两种音源全部全票,该分支只由合成用例担保;VOTE_WINDOWS=5 / SILENCE_FLOOR=0.25 没做敏感性扫描;MAX_LINE_SEC=8.0 单样本棘轮——三处同族,docstring 都照实写了 |
| 拖拽预览无条件重画 | previewFrame 不进跳帧路径,按住不动照样烧(有意留下:拖动是短暂交互)DECISIONS 2026-08-15 残留 |
| 跳帧枚举守卫管字段不管语义 | 若有人让某层通过既有字段读活的可变状态(原地改 timeline),FIELD_ROLE 仍说 const 而守卫不响 DECISIONS 2026-08-15 残留 |
| --no-lyrics 的两处未定义 | 与 overrides.json 的 lyrics.* 同时给会怎样没测也没定(可能打进空列表再越界报错);没真跑过一次带 --no-lyrics 的完整 run 到出片 DECISIONS 2026-08-15 残留 |
| 听写期间页面无中间进度 | WhisperX 没给逐段回调,执行窗口没有造一个假的——页面只报量到的已耗时(见 §10.5) |
| web 壳子无任务续接 | 刷新页面/重启服务接不上(job_id 在进程内存里);最小实现(GET /api/jobs + 「接着上次」入口)记在残留风险,未做 |
| 段落大字与 HUD 重叠的修复未合 | 躺在一棵落后 181+ 提交的未合 worktree 里,捡起来要重做不能直接合 DECISIONS 2026-08-19 |
| SPRITE_CACHE 无淘汰策略 | 量过了、不是问题、没有改:全曲多轮扫描稳定 199 个精灵 / 约 6.6 MB / 零增长;承重前提(段内配色恒定)已有 palette 测试守着 DECISIONS 2026-08-14 |
| 坑 | 真实行为(全部实测) |
|---|---|
| Demucs 默认不可复现 | --shifts 默认 1 = 每次跑做一次随机时间平移且不接受种子;必须显式 --shifts 0。即便如此浮点层面仍有 1e-4 量级抖动(§11) |
| yt-dlp 的 --print 与 --print-to-file | --print after_move:filepath 把 stdout 压成只剩一行路径、进度全没;--print-to-file 是追加不是覆盖——每级开跑前必须清落点文件 |
| GitHub Markdown 剥内联 SVG | 消毒器把 <svg> 标签整个剥掉、只把里面的文字裸露出来(真页面上看到一大段 CSS 源码);架构图必须做成独立 .svg 文件相对路径引用,且此时必须写 xmlns=(与内联在 HTML 里的规矩恰好相反——本文档是独立 HTML,内联 SVG 没问题) |
| Path.resolve() 逃出 venv | .venv/bin/python 是指向解释器安装目录的符号链接,resolve() 跟着跳出 venv,「兄弟目录找命令」随之落空——要的是 resolve 之前的路径(§10.3 的真 bug,已修,守卫要求指着 serve 自己那个 bin 目录——原守卫只有 shutil.which 一句,而测试永远跑在 uv run 下、PATH 里必然有 .venv/bin,它守的那件事恰恰是它测不到的那一种) |
| 管道上的 Python stdout 是块缓冲 | 不设 PYTHONUNBUFFERED=1,「实时进度」= 跑完一次性刷出来;且光靠 stderr=STDOUT 连「按到达顺序」都做不到(实测 stderr 整个跑到 stdout 前面) |
| CLI 输出的缩进不携带结构信息 | 第三方的 warnings.warn( 缩两格、我们的 [1/5] 顶格——分类按内容白名单,不按缩进不按列号(同一假设栽两次,两次都只有真跑才发现) |
| ffmpeg 对截断文件返回 0 | 抽轨后必须做时长比对(容差 1.0 s);空 wav 抛的是连消息都没有的 EOFError、损坏文件抛 audioread.NoBackendError——都不带路径,必须接住翻译成人话 |
| </script> 注入与链式 replace | 内联 JSON 必须把 </ 转义成 <\/(JSON 合法、校验发现不了);多占位符替换必须一次性正则(链式的话数据里写 "__BUNDLE__" 能把 bundle 再插一遍);同一个曲名两个落点转义方式不同(html.escape vs json.dumps) |
| jsonschema 的三件事 | contentEncoding 只是注解不校验(base64 要自己解一次);顶层 required 失败会把整份 schema dump 进 str(exc)(9,218 字符,match= 失去分辨力——断 exc.value.message);uniqueItems 会让后面手写的重名检查变死代码 |
| sosfiltfilt 默认端点外推 | "odd" 外推对突然起振的信号在起点反射低频伪影;用 padtype="constant" |
| librosa YIN 的 frame_length | 默认 2048 在 44100 Hz 下分辨不出 C1(需 2698)——按 2·sr/fmin 向上取 2 的幂 |
| 白噪声过 IIR 的峰值随采样率变 | 同一段 hat 在 22050 下峰值 0.80、44100 下 1.13(削波)、96000 下 1.21——修法是对任意采样率成立的峰值归一,不是在某个采样率下压线 |
| WebAudio ConvolverNode 默认归一化 IR | scipy fftconvolve 不归一化——漏掉这一步,混响峰值 26.84(干声 2.01)、69% 采样被压成平顶(复刻实验里「吱吱啦啦」的真凶,不是混叠)DECISIONS 2026-08-14 |
| .gitignore 带斜杠挡不住符号链接 | songs/**/build/ 只匹配目录,git 把 symlink 当文件——git add -A 当场把 10 个链接提交了进去;要补不带斜杠的形式 |
| 依赖钉死的三处理由 | numpy<2(rapidocr 会顶到 2.x 而 librosa/demucs 还在 1.x ABI 上);torchaudio==2.2.2(必须与 torch 同版本否则 dlopen 失败);transformers==4.48.3(4.52+ 因 CVE-2025-32434 在 torch<2.6 时拒绝加载 .bin 权重,中文对齐模型恰是 .bin)pyproject.toml 注释 |
| Python 3.11 锁定 | Demucs 与 3.13 不兼容;requires-python = ">=3.11,<3.12" |
| opencc 缺席静默退化 | 繁简转换是可选增益,装不上退化为不转换——但 Whisper 在中文歌上会吐繁体,那几句必然全部落空(对齐测试会红,别在没装 align extra 的环境里判断对齐质量) |
「AI 能据此复现」是可操作的判据。一个没见过这个仓的复现者,做完之后逐条自问——每一条在本文都有唯一出处:
写完后按上述判据反问过三处关键契约,均能在文内找到唯一答案:
① timeline 的字段表与校验分层(§5);② 渲染层 15 层的顺序与每层承重常量(§7.3 + 附录 A3);
③ 语言侦测的完整参数与失败模式(§4.6 + §13.1)。同时对全文做过素材扫描:
四首私有歌曲的歌名/目录名 0 命中,出现的唯一曲名是《Trempe-moi》;外链 0 个——全文没有任何会发起网络请求的引用,仓库地址以纯文本给出;仅有的两处 http 字样是两张内联 SVG 的 xmlns 命名空间标识符(与对标拆解报告同一做法,标识符不发请求)。
| id | 中文名 | 英文名 | hue | stem | 来源 | 备注 |
|---|---|---|---|---|---|---|
| (vocals) | 心籁 | SOUL REED | 300 | vocals | 真歌 + 合成曲 | 驱动判定环,不占 lane;面板首行 |
kick | 撼岳 | QUAKING PEAK | 28 | drums | 两者 | 真歌:drums 低通 <120 Hz;合成曲独立 stem |
snare | 裂帛 | RENT SILK | 350 | drums | 两者 | 真歌:带通 200–800 Hz |
hat | 碎玉 | JADE SHARDS | 195 | drums | 两者 | 真歌:高通 >6 kHz |
bass | 渊鸣 | ABYSS TOLL | 225 | bass | 两者 | 唯一有音高跟踪的真歌 lane(YIN,C1–C4) |
mid | 流岚 | DRIFTING HAZE | 175 | other | 真歌 | 带通 200–4000 Hz;与 pad 共用名字与色相(永不共存) |
air | 缥缈 | ETHER | 270 | other | 真歌 | 高通 >4 kHz;与 pluck 共用 |
pad | 流岚 | DRIFTING HAZE | 175 | pad | 合成曲 | 三失谐锯齿,oct 4 |
pluck | 缥缈 | ETHER | 270 | pluck | 合成曲 | Karplus-Strong,oct 5 |
arp | 泠泠 | LIMPID RUN | 165 | arp | 合成曲 | 双锯齿按序走位,oct 6 |
bell | 霜铎 | FROST CHIME | 60 | bell | 合成曲 | 2-op FM,换和弦时敲根音 |
出处:murripple/lanes.py:19-24(真歌六条 hue/stem/band)· murripple/cli.py LANE_HUES/LANE_LABELS(合成曲八条)· renderer/src/ui/voices.js LABELS(中英名真相源)。声部名是本项目的创作内容 (参考项目那套不得使用,守卫扫着)。
| 常量 | 值 | 出处 / 说明 |
|---|---|---|
STEP | 1/120 s | clock.js;整数步数计时防浮点漂移 |
MAX_DPR | 2 | geometry.js;W/H 必须用封顶后的 dpr 算(有守卫) |
R_RATIO | 0.225 | 判定环半径占短边比(M2-4 前是 0.28) |
CY_RATIO | 0.485 | 圆心略高于几何中心 |
| 半径阶梯 | 0.55 / 0.8 / 0.9 / 1.0 / 1.02 / 1.06 / 1.95 | 波形 / 内刻度 / 环 / 车道 / 谱线基 / 外刻度 / 谱线顶 = 音符出生点(单位 R) |
HUE_STEP | 37 | palette.js;与 360 互质 |
ENVELOPE_RATE | 60 Hz | envelope.py ↔ timeline.js 两端配对 |
RING_TAU_MS / LANE_TAU_MS | 250 / 180 | 包络单极点平滑(预计算整条数组,无逐帧状态) |
LEAD_T | 1.7 s | notes.js 音符提前量(M1 spec 定) |
PARTICLES_PER_HIT / LIFE_SEC | 12 / 0.8 | particles.js;稳态约 79 粒,不做对象池 |
PRESENCE_THRESHOLD | 0.02 | timeline.py;人声 RMS > 全曲峰值 2% 算在唱 |
MAX_ARTIFACT_MB | 15(×1e6 B) | pack.py 硬失败 |
| 常量 | 值 | 出处 |
|---|---|---|
MODEL_SIZE / DEVICE / compute_type | medium / cpu / int8 | align.py(small 在唱歌上错得厉害;medium 慢 3–5 倍但 build 一次性) |
WINDOW_SEC / VOTE_WINDOWS / SILENCE_FLOOR | 30 / 5 / 0.25 | align.py(只有 30 有依据——Whisper 自身性质) |
SAMPLE_RATE(侦测) | 16000 | whisperx.load_audio 固定重采样 |
MIN_LINE_SEC / MAX_EXTRAPOLATION / DEFAULT_CHAR_SEC | 0.35 / 1.6 / 0.35 | align.py |
FMIN/FMAX(YIN) | 32.70–261.63 Hz(C1–C4) | analyze.py,bass 单音假设 |
detect_sections n | 9 | analyze.py 默认段数 |
DEFAULT_BITRATE | 64k AAC | encode.py;aac_at 优先 |
MIN_DURATION / LONG_DURATION_WARNING | 5.0 / 600.0 s | cli.py |
| OCR:fps / 亮阈 / 相似度 / 行上限 | 2.0 / 220(p95) / 0.75 / 8.0 s | subtitle.py(8.0 是单样本棘轮) |
MP3_QUALITY / DURATION_TOLERANCE | 2 / 1.0 s | ingest/audio.py |
QUIET_SECONDS | 15.0 s | fetch.py 静默看门狗(只声称量到的事实) |
| web:端口 / 让路 / 轮询 / 截尾 | 8731 / +20 / 700 ms / 20 行 | server.py / index.html / app.py |
MAX_STEM_BYTES | 96 B(32 汉字) | jobs.py(APFS 255 字符 / ext4 255 字节实测) |
| compose:BPM 区间 / 时长 / 密度曲线 | 72–96 / 150 s / 0.30·0.55·0.85·0.50 | cli.py / arrange.py(密度四数要穿过下游量化台阶才算数) |
| synth:SR / 峰值目标 / 混响湿度 | 44100 / −1 dBFS / 0.28(仅 lead+pad) | synth.py(不压缩不限幅) |
本文写作时实跑(补齐真歌产物 + align extra 后);渲染层 301 pass(台账 2026-08-16)
合成曲实测产物体积,余量 816,659 B;第十条声部需非音频开销 ≥ 6,833,410 B(48.2%)——装不下
跳帧修复前后实测;播放中不变且不该变(每帧都是新 t)
全曲 1/60 s 逐帧扫两遍零增长;hueShift 改成随 t 漂移则涨到 3,706
同机同源两次 build:结构全同,只有 v 抖——Demucs/torch 浮点不确定性
Demucs 随机平移导致两次 build 分轨不同、对齐掉句,代码一行没改
听写不替人断句的实测依据(另一首法语歌 34 行 → 8 段;两个实测数,不是分布)
《Trempe-moi》一次 run 实测 121.856 s,出处 docs/site/demo 抄件;分离 14.9 s
4.5 分钟的歌、16,200 帧(README 实测参考)
含示例歌 5.6 MB;clone 后 722 passed / 2 skipped
stems 键 47 B + bundle 333 B,四首一致;音频 base64 解码后逐字节相同
1.5× 调速后 600 秒窗口峰值(守的线是 <1%;腰斩方案实测 1.29% 被否)