murRipple · docs/research · 2026-08-20 · 基于主仓 main dca6ff2

知漪 murRipple 技术拆解

给它一个 mp3 加一份歌词,它产出一个零依赖、可双击打开、上限 15 MB 的单文件 index.html(音频、渲染器、分析数据全部内联),和一段逐帧渲染的 1080p60 MP4。 贯穿全部工程决定的主线只有一条:画面必须是时间 t 的纯函数——否则 「网页看到的就是导出视频里的」这句话就不成立。本文把它从素材预处理、分析管线、数据契约、 渲染分层、打包体积账,一直拆到守卫体系与已知缺陷,粒度对齐本仓此前对参考项目的拆解报告: 目标是一个没见过这个仓的 AI,拿着这一份文档能写出行为等价的系统。

Python 7,553 行 / 渲染层 JS 4,162 行 15 个渲染层 · 1 + N 行声部面板 pytest 1,094 passed / 渲染层 301 pass 产物上限 15 MB(MB = 1e6) STEP = 1/120 s · 全链确定性 许可:私仓保留全部权利 / 公开树 PolyForm NC 1.0.0

0边界声明

这份文档是什么。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 第七节的地盘, 本文只收录其中已固化为代码或规格的部分)。

1一句话定性

核心

它是一条把「已有的歌」离线蒸馏成确定性数据、再由确定性渲染器重放的管线—— 不是播放器插件,不是实时可视化器。

所有分析(音源分离、节拍、起音、音高、歌词对齐)在构建期一次做完,固化成一份 timeline.json;渲染器不认识 Demucs 或 librosa,只认识这份文档 murripple/schema.py 模块 docstring。这个「先蒸馏、后重放」的结构, 与本仓的视觉标杆(一个在浏览器里现场合成音乐的单文件项目,拆解见 docs/research/2026-08-13-light-loom-teardown.html)恰好互为镜像:

参考项目 light-loommurRipple
音乐来源代码定义 → 合成器现场生成已有音频文件 → 离线分析(另有 compose 合成线,见 §9)
谱面来源纯函数生成,编译期已知Demucs 分轨 + 起音检测 → timeline.json
画面数据播放时从 AnalyserNode 实时读预计算 timeline,逐帧可寻址
导出方式实时录屏Playwright 逐帧渲染 → ffmpeg 封装
确定性音频可复现,画面不可逐帧复现画面必须逐帧确定(守卫见 §11)

产物两样 MGMT.md 第二节:

2整体架构与数据流

2.1 物理分块

位置规模职责
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 行 JS15 个渲染层 + 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)

2.2 数据流

取材层 · INGEST 构建层 · BUILD + PACK 呈现层 · RENDER _in/ 原始素材(只读) 有啥放啥:录屏 mp4 / wav / txt… ingest.scan 拿不准就报错,不猜 fetch --url(可选) yt-dlp 三级降级 · 大声报告走哪级 硬字幕 OCR(视频路) 2 fps 抽帧 · p95 亮度 220 分「已唱」 → lyrics.timing.json(演唱时刻) → lyrics.txt 草稿,人必须过目 听写 transcribe(没词可抄时) WhisperX 听混音 → lyrics.draft.txt 写不到 lyrics.txt——断句是人的事 标准输入 source.{mp3,wav,m4a,flac} lyrics.txt(+ 可选 timing / overrides) 歌词门 lyrics_gate 默认拦住 · --no-lyrics / compose.json 放行 [1/5] Demucs 分离(htdemucs · --shifts 0) → stems/ 四条 wav(已有扁平分轨则跳过) [3/5] 并行分析(全部纯函数) 节拍/小节线 · 段落(自相似矩阵 n=9) 六条 lane:带通切分 + onset + bass 音高 包络:RMS 60 Hz → dB → uint8 → base64 歌词对齐:WhisperX medium + 字符级 LCS 语言:最响 5 窗投票,拿不准就说出来 [5/5] build_timeline + overrides 组装 → schema 校验 → 手工精修深合并 → build/timeline.json [4/5] 编码:ffmpeg AAC(aac_at 优先)64 kbps → build/audio/<stem>.m4a(每条独立) pack:一次性注入四样东西 template.html + esbuild bundle(--minify) + timeline 原文 + 音频 data URI ≤ 15,000,000 B 硬失败 · 原子替换落盘 songs/<slug>/dist/index.html 零外链 · file:// 双击即开 运行时状态(皆为 t 的函数) 时钟:整数步数 × STEP(1/120) 音频:base64 → decodeAudioData 时间基准只认 AudioContext.currentTime 粒子 RNG:mulberry32(SEED ^ id) 15 个渲染层(顺序即叠放) background → ripple → sweep → spectrum → dial → lanes → laneLabels → notes → shock → ring → core → waveform → lyrics → particles → sectionTitle 禁 shadowBlur · 辉光=预渲染精灵+lighter 实时跳帧 FIELD_ROLE · 离线永不跳 DOM 覆盖层(不进视频) 走带条 · 声部面板(1 + N 行,竖脊归组) 标题页 · 曲名卡 · WAV 导出 空格暂停 · H 收起 · 点行静音 stem render.mjs(mode:"offline") Playwright 加载产物 · 不点开始按钮 逐帧 toDataURL → ffmpeg · 音频原样封装 → dist/<slug>.mp4(1080p60) 每一步先看产物在不在,在就跳过(murripple run 可断点续跑);没有任何一步调用外部模型 API。
图 1 · 总体数据流。取材层把「有啥放啥」整理成标准输入并强制人工过目;构建层把一切分析固化进 timeline.json;呈现层只消费这份契约。列间只有文件,没有共享内存——每一段都可独立复现。

2.3 命令面

命令作用关键设计决定(出处见正文对应节)
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)

3取材层:ingest / fetch / transcribe

取材层的公共哲学写在 murripple/ingest/__init__.py 的 docstring 里: _in/ 是用户仅有的原始素材,这一层只读它——不改、不删、不移动; 产物一律写在歌曲目录下、_in/ 之外。以及两条硬规矩 murripple/ingest/scan.py:拿不准就报错,不猜(目录里有两个 mp4 时报错并列出候选——猜错要跑一小时才发现); 决策要讲出来(Plan.notes 是打印给人看的句子,不是调试输出)。

3.1 扫描决策表(scan.py)

看到的怎么办为什么
wav/flac 与 mp4 都有音频取 wav/flac,字幕仍从 mp4 来视频音轨已被有损压缩过一次;时间戳只有 mp4 给得出——不是二选一,是各取所长
现成的歌词 txt直接用,不 OCROCR 会错字,现成的是权威
只有 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 按固定顺序取第一个,「用户以为换了源其实没换」。

3.2 硬字幕 OCR(subtitle.py)

这一步的产出不是「一段文字」,而是「文字 + 出现时刻」——字幕从暗变亮的那一刻就是演唱时刻, 这首歌于是可以完全跳过 WhisperX。全部承重常量与理由 murripple/ingest/subtitle.py:

常量值理由(源码注释)
DEFAULT_FPS2.0歌词一行至少停留一两秒,2 fps 足够,比逐帧快十五倍
BRIGHT_THRESHOLD220.0实测已唱行 p95 亮度 237–255、未唱行 176–207,中间空得很开,220 落在正中。用 p95 不用峰值:峰值容易被一个抗锯齿的亮像素顶满
BRIGHT_PERCENTILE95同上
LAYOUT_SAMPLES16自动找歌词带的取样帧数——要够多才看得出「哪条带子的文字在变」
BAND_TOLERANCE0.012两个文字框纵向中心差在此比例(占画面高)内算同一条带子
MIN_DISTINCT_TEXTS4一条带子至少出现这么多种不同文字才算歌词带。不能只要求「大于一种」:水印被 OCR 一会儿读成 MADEWITHSUNO、一会儿读成 MADEWITH SUNO,两种写法就足以冒充歌词带(实测踩过)
MIN_FRAMES2一行至少连续两帧在「已唱」集合里才算数,少于此多半是 OCR 抖动
MAX_LINE_SEC8.0一行最多挂屏这么久,超出部分是间奏。样本数:1 首歌 48 行(WhisperX 量得中位 3.54 s、p90 5.01 s、最长 7.42 s,8.0 是在最长值上留余量)。注释直说「目前没有人在管它」;测试 test_max_line_sec_is_a_ratchet 只是棘轮不是证据
SIMILARITY0.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 后文字以它为准、时间戳只对行数—— 拆行或并行会让两边对不上,那时报错并退回常规对齐。

3.3 歌词门(lyrics_gate.py)

「这首歌要不要歌词」——全仓唯一那一处判断。它原来是三处且互相矛盾(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 的日志分层按行首形状认领, 揉进上一句会把听写那条路的唯一入口提示折进详细区。

3.4 链接取回(fetch.py)

纯模块、零接线成本地并入 ingest --url 与 POST /api/job-from-url。 三级降级链 murripple/fetch.py:

顺位路径兜的是什么
1uv run --with "yt-dlp[default,deno]" --no-project -- yt-dlp …——运行时拉最新版,不锁版本、不进 pyproject.toml、不动 .venv站点改版——yt-dlp 的主要失效方式。「钉死的旧版比最新版更容易坏」(调研当天就撞上 403)
2sys.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。

3.5 听写(ingest/transcribe.py)

机器认字,人断句。产出是 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、网页) 不出现任何听写准确率数字,有守卫扫 %/准确率/字准。

4分析管线:build 的五步

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。

4.1 分离(separate.py)

Demucs htdemucs(四条 stem:vocals/drums/bass/other),子进程调用不用 Python API—— CLI 接口跨版本稳定得多,测试可整体替身、不必下载 2 GB 模型;sys.executable -m demucs 保证跑在同一个 uv 环境。 两个承重参数:

4.2 节拍、起音、音高、段落(analyze.py)

全部纯函数:输入 numpy 数组、输出普通数据,无 IO 无子进程,测试用合成音频即可 murripple/analyze.py docstring。

函数做法承重细节
detect_beatslibrosa beat_track → bpm + 拍点;小节线按 4/4 假设,从 onset 强度最大的那一拍起每四拍取一librosa 不检测小节线;假设不成立时用 overrides 修正
detect_onsetsonset_strength → onset_detect(backtrack=True),强度按本轨峰值归一已知真 bug,见 §13:backtrack 回退到波谷后又用回退后的帧号读强度,力度 v 大多接近 0
track_pitchlibrosa YIN,范围 C1–C4(32.70–261.63 Hz),只对 bass 轨跑frame_length 按 2*sr/fmin 向上取 2 的幂——默认 2048 在 44100 下盖不住 C1(需 2698),低频音高不可靠恰是 bass 最需要的那段
detect_sectionschroma_cqt 自相似 + agglomerative 聚类,默认 n = 9 段;每段能量 = 段内 RMS 均值 / 全曲峰值段落名一律空串,由 overrides 手写——「打出一个猜的名字比不打更糟」
sections_from_marks按给定边界只算能量,不做检测合成曲的段落边界是真值——「有真值就别再猜」,与「硬字幕跳过 WhisperX」同一条道理

4.3 六条视觉轨道(lanes.py)——色相权威在这里

Demucs 只有四条 stem,人声不占轨道(它驱动判定环),剩下三条按频段拆成六条视觉轨道。 静音粒度仍是 4——底鼓/军鼓/踩镲从同一条鼓轨滤出来,能分开画、不能分开静音 murripple/lanes.py docstring。LANE_SPECS 是全项目的色相权威表 (网页壳子、介绍站的色板都有守卫钉着必须等于它,见 §12):

底鼓 · 撼岳
kick · quaking peak
hue 28 · drums · <120 Hz
军鼓 · 裂帛
snare · rent silk
hue 350 · drums · 200–800
踩镲 · 碎玉
hat · jade shards
hue 195 · drums · >6 kHz
低音 · 渊鸣
bass · abyss toll
hue 225 · bass · 全频
中层 · 流岚
mid · drifting haze
hue 175 · other · 200–4k
气层 · 缥缈
air · ether
hue 270 · other · >4 kHz
人声 · 心籁
vocals · soul reed
hue 300 · 判定环,不占轨道

出处: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 直通:名字、色相、音符表来自真值,不切频段、不猜音符—— 「对着自己刚写完的乐谱再猜一遍,是把已知信息丢掉再找回来」;包络仍从音频算,它本来就是音频属性。

4.4 包络(envelope.py)

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 注释。

4.5 歌词对齐(align.py)

用户提供歌词原文,所以这是「对齐」而非「识别」。关键设计:不按整句做精确匹配—— Whisper 的分句边界与用户的换行几乎不可能一致。做法是字符级序列比对 murripple/align.py docstring:

  1. WhisperX(MODEL_SIZE="medium"、DEVICE="cpu"、compute_type="int8")转录人声轨并做词级对齐。 选 medium 的理由:唱歌比说话难认得多,small 在真实曲目上错得厉害、导致大量句子对不上;medium 慢三到五倍, 但 build 是一次性的、调视觉时不重跑,值这个时间 align.py:24-28 注释。
  2. 把词级结果拼成一条归一化字符流(繁转简 · 去标点与各类空白含全角空格 · 转小写;opencc 是可选 extra, 装不上就退化为不转换——但 Whisper 在中文歌上会随段落输出繁体,那几句会全部落空),用户歌词同样拼流, difflib.SequenceMatcher(autojunk=False)求最长公共子序列,把每行的字符位置映射回词时间戳。
  3. 外推:匹配残缺时由命中的首尾字反推整句窗口——假设句内字速均匀,测出每字时长后向两端外推; 只命中一个字时用全曲中位字速兜底(DEFAULT_CHAR_SEC = 0.35 只在一个可测速率都没有时用得上, 所以换语言速率会自己跟上)。外推封顶 MAX_EXTRAPOLATION = 1.6 倍——实测一句八字的句读句曾被推到 11 秒并倒插回上一句。
  4. 单调化 _enforce_monotonic 四步:自身非负 → 重叠取中点分界(「重叠说明两句的估计都不确定, 没有理由让先来的全赢」)→ 短句补到 MIN_LINE_SEC = 0.35 但不越过下一句 → 最后一遍无条件强制不重叠 (「不变式必须由一个无条件的收尾保证」——实测前三步之后仍剩 1 处违例)。不按 t0 重排: LCS 锚点本身单调,歌词顺序是权威,重排曾把第 4 句排到第 5 句后面。
  5. 词级(--word-level)是按需精修:把一行的字符线性铺在 [t0, t1] 上,够做卡拉OK高亮; 默认句级——句级差 0.2 秒基本无感,词级差 0.2 秒极其显眼。
  6. 没对上的行原样报告给用户,由 overrides.json 的 lyrics.insert 手工补录(§4.7)。

4.6 语言侦测:最响 5 窗投票

「这首歌是什么语言」全仓只有 align.decide_language() 一处在决定,对齐与听写都问它—— 且刻意不写在默认参数上:默认参数在 def 时求值,「唯一那一处」就不存在了 align.py::decide_language docstring。机制:

诚实边界

三个常量里只有 30 有依据(Whisper 自身性质);VOTE_WINDOWS=5 与 SILENCE_FLOOR=0.25 是在五首歌上挑的、没做敏感性扫描;unsure 分支在真素材上从未触发过(五首歌两种音源全部全票), 其正确性只由合成用例担保——这些都原样写在 docstring 与台账里,不装成「验过了」 DECISIONS 2026-08-15 残留条 · align.py。

4.7 手工精修(overrides.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 注释。

4.8 组装(timeline.py)

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。

5timeline.json——唯一的契约

「这是构建时管线与运行时渲染器之间唯一的接口。渲染器不认识 Demucs 或 librosa,只认识这份文档。」 murripple/schema.py docstring。JSON Schema Draft 2020-12,SCHEMA_VERSION = 1, 顶层八个字段全部必填、additionalProperties: false:

字段形状语义与承重细节
metatitle / duration / bpm / codec / schemaVersion 必填;language 可选language 只在 WhisperX 真听过时存在——必填等于逼 build_timeline 编一个;缺席是一句实话。四份已交付 timeline 没有这一格且不许回归
sections[{t, name, energy}]name 自动分段时恒为空串(空名不显示段落大字);energy ∈ [0,1] 驱动配色饱和度
beats / downbeatsnumber[](秒)渲染层拍点脉冲 exp(-Δt/τ) 反查用;不取模——真实拍点网格有偏移(实测首拍 1.776 s)
ring{envelope, presence},都是 base64判定环数据源:人声包络 + 逐帧「是否在唱」二值;纯器乐时 envelope 换成混音包络、presence 保持全 0
stems非空、去重的字符串数组本曲实际有哪几条分轨,由数据声明不写死四条——Demucs 反拆只有四条是它的约束,合成曲九条。渲染器解码循环、静音状态、pack 的音频遍历全按它走
lanes[{id, label, hue, stem, gain, notes, envelope}],minItems 1notes: [{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:

可复现要点

序列化格式是承重的:json.dumps(doc, ensure_ascii=False)——默认分隔符、无末尾换行 cli.py · DECISIONS 2026-08-14「迁移的序列化要照 cli.py,不是计划稿写的 separators=(",",":")」。 pack 把这份原文逐字节内联进 index.html,所以任何比对产物的判据都要经过它。

6编码、打包与体积账

6.1 音频编码(encode.py)

选 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 注释。

6.2 打包(pack.py)

与 build 拆开的理由:分析约 84 秒、打包约 2 秒,调视觉时每改一次都重跑分析没法忍。流程:

  1. npx --yes esbuild src/main.js --bundle --format=iife --global-name=murRippleApp --minify (注意 renderer 自己的 npm run bundle 不带 minify,那份是给测试 harness 用的)。
  2. 四样东西一次性正则替换注入模板:__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 注释。
  3. 声明的分轨必须全部到齐:缺 timeline.stems 里任何一条的 m4a 即 PackError——只查「至少有一条」会静默产出缺声部的产物, 面板那一行照常渲染、可点、点了什么也不发生(与 find_flat_stems 防的是同一形状:半套比没有更危险)。
  4. 先写临时文件、量完体积再原子替换:直接写 out 再在超限时删掉,会把上一次成功的产物一并抹掉—— 「用删除来处理『产物太大』是拿已有资产去赌」pack.py · DECISIONS 2026-08-14。

6.3 15 MB 上限与体积账

单位定义(别再议)

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。

产物时长体积(字节)备注
真歌 01270.0 s12,169,222四 stem · 六 lane(下同)
真歌 02149.6 s6,826,764
真歌 03238.8 s10,890,651
真歌 04295.0 s13,363,895体积最紧的一首,距上限约 1.64 MB
《Trempe-moi》238.3 s10,886,579公开 demo 与公开仓的示例歌
合成曲(九条分轨)150 s14,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 第五节。

7渲染层(本节最重要)

7.1 确定性三铁律

M2 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)。

7.2 时钟与世界推进(core/clock.js)

7.3 绘制顺序与图层职责

main.js 的 LAYERS 数组顺序即叠放顺序,注释明令「不要随手往后追加:歌词必须压在波形之上 (否则波形锯齿切碎字形),光屑必须压在环之上(否则命中的爆发感被环盖住)」。

01background 三重色场 · 星云7团 · 星野220颗 · 六星连珠 · 浮尘64 · 流星 · 经线 · 颗粒 · 暗角 02ripple 小节涟漪:downbeat 反查,寿命 1/1.4≈0.714 s,速度 300 px(按短边 720 标定) 03sweep 换段光扫 1.25 s 一整圈,拖尾 12 段;第一段不扫(开场不是「换段」) 04spectrum 辐射谱线 66 桶×左右镜像,42 Hz–13.5 kHz,dB 映射(−78…−18),v² 定长 05dial 双层反向刻度环 96/64 齿,转速 0.021/−0.013 rad/s(一圈五分钟以上) 06lanes 车道弧:条数 = timeline.lanes.length,包络亮度 pow(v, 0.7),线宽 2.4+v·7 07laneLabels 环外声部铭文(宋体逐字画)——视频里没有侧栏,这是声部名唯一进画面的位置 08notes 下落音符 + 彗尾:提前量 1.7 s,从 1.95R 落向车道弧,bass 按音高摆 ±0.16 rad 09shock 命中冲击弧 0.55 s:reach 取 q²(猛地弹出、后段变缓),与匀速涟漪区分 10ring 判定环三层:内芯跟瞬时包络(咬字)· 中晕跟平滑(呼吸)· 外散跟段落能量 11core 中心光核 = 基底 + 两片反旋等离子瓣 + 内芯;底鼓主导脉冲(详见 §7 与附录) 12waveform 径向波形 180 点 @ 0.55R,邻域半径 2 平滑(尖刺一半是采样假象) 13lyrics ★ 歌词即光核:压幕 + 柔边两道 + 字芯 source-over;断行预算 9 汉字宽 14particles 命中光屑:12 粒/击 × 力度,寿命 0.8 s,阻尼 0.163^dt;hat/air 带十字星芒 15sectionTitle 段落大字:淡入 0.5 + 停 2.2 + 淡出 0.8 = 3.5 s;名字为空不显示 ↑ 后画的压在先画的上面。歌词压波形、光屑压环, 顺序由 M2 spec 第 7 节钉死,测试逐层抠掉比像素差。
图 2 · 15 个渲染层,自下而上。每层一个模块(renderer/src/layers/),全部只读 state(t / palette / beat / timeline / audio / geom / …),常量集中见附录 A3。

7.4 几何与配色

所有半径以 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。

7.5 歌词断行:九个汉字宽的预算

layers/lyrics.js 是 M2 的核心视觉决策「歌词即光核」,也是全仓打磨最狠的一层。断行契约:

7.6 音频通路与 WAV 导出

7.7 界面:DOM 覆盖层与声部面板

界面全部是 DOM 覆盖层不进 canvas——「M3 逐帧抓 canvas 导出,画进 canvas 的界面会被烤进每一帧视频; DOM 覆盖层让导出天然干净,零额外代价」ui/hud.js。要点:

7.8 实时跳帧(省电不省确定性)

实时模式下画面只是 state 的函数:state 不变时重画纯属白烧——实测这条白烧循环占一个核的 111%,暂停时也一样烧;修复后暂停 113–115% → 2–9%、播完闲置 → 7–8%(约 13 倍) DECISIONS 2026-08-15 实测。机制 main.js:

8视频导出

node video/render.mjs ../songs/<slug> [--fps 60] [--size 1920x1080] [--from --to] [--crf 17]。 四条设计决定,每条都有原因 renderer/video/render.mjs 头注释:

  1. 加载产物 index.html 本身,不另建渲染路径——另起一条路等于把确定性铁则全作废, 「网页看到的 = 导出视频」就无从谈起。
  2. 不点标题页的开始按钮——那会启动实时循环与逐帧推进打架;直接造 mode:"offline" 的实例 (window.__EXPORT__ 钩子)。
  3. 用 OfflineAudioContext 解码——无头环境没有音频设备,AudioContext 会被挂起; 实测解 270 秒的轨只要 156 ms。
  4. 抓 canvas 不抓整页——DOM 界面不烤进视频;也不用 locator.screenshot(实测 346 ms/帧 vs 读 canvas 89 ms)。

音频用原始音源直接封装零损耗——产物里那份 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。

9合成器 compose(私仓专属)

M5:不给音频,摇一个 seed 自己写一首(当前状态:数据契约与分轨已验收,音乐本身尚未过耳朵关—— 第二步未开工)。公开树不带它,CLI 对它 try/except ImportError:合成器不在时不注册 compose 子命令—— 「注册一条跑不了的命令比没有更糟」cli.py 顶部注释。

9.1 结构

模块职责要点
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,不压缩不限幅

9.2 与主管线的接缝——「有真值就别再猜」

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 题。

10本机壳子 murripple serve

W1:murripple serve 起一个网页——选文件、贴歌词、点开始。它解决的不是「命令行难用」, 是目录约定:「songs/<slug>/source.mp3 + lyrics.txt 那套约定是给我们自己用的,对外人是纯粹的心智负担」 W1 spec 第一节。立身之本两条:不 import 分析管线 (整个包只用标准库,跑歌全靠 subprocess 调 murripple 命令,有干净子进程守卫钉着; 唯一从管线拿的是 DRAFT_FILENAME 这个文件名常量),管线一行不改 (首版合并时 cli.py +11/−0,其余零改动 DECISIONS 2026-08-14)。

10.1 服务骨架(server.py)

10.2 端点与任务模型(app.py / jobs.py)

端点行为
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。

10.3 子进程编排(runner.py)——三件跟现实对上过的事

  1. stderr=STDOUT 合并:失败消息全走 stderr,不合并的话用户只看到「失败了」而没有原因。 代价说在明处:合并后分不出哪行来自 stderr,spec 那句「stderr 最后 20 行」落成「合并流最后 20 行」。
  2. PYTHONUNBUFFERED=1 是承重条件:cli 的 print 不带 flush,管道上 stdout 块缓冲—— 一次 run 的全部输出才一两 KB、4 KB 攒不满一个字也不出来,「实时进度」当场变成「跑完一小时一次性刷出来」。 这一条不在计划稿也不在 spec 里,是真跑撞出来的 DECISIONS 2026-08-14。
  3. 「一行 = 一条记录」的前提是非 tty:tqdm 在非 tty 下不画进度条(实测 12 秒 build 的输出 1,546 字节 17 行 \r 零个)。这是前提不是性质——接伪终端它就塌,且没有测试会红(照实记在 docstring)。

另有两处结构纪律:读取线程的 finally 必须收尾且先关管道再 wait—— 直接 wait 的话管道一满子进程永远卡在 write、我们永远卡在 wait,双向死锁连收尸都收不掉; murripple_command() 不许 .resolve()(§13 详述这个真 bug)。 命令拼装不加 --force:同一目录再点开始要接着跑,不是从 Demucs 再来一小时。

10.4 进度解析(progress.py)——分母判层,白名单认领

10.5 页面(static/index.html)

单页零外链(与产物同一条规矩;内联 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 整行整行地漏、听写字会错且根本不断句——「说同一句就有一条在骗人」。

11确定性与可复现性的边界

「确定性」在本仓分三层,混着说就会说错 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。

12守卫体系与发布面

12.1 测试文化:变异检验

规模: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 第七节。几条从血里换来的判据(复现者照抄即可少踩一轮):

12.2 内容守卫(防泄漏、防挪用)

守卫守什么机制要点
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 真跑重算;准确率尺子扫「词 + 数」不扫词本身;零外链白名单三条远程地址

12.3 公开树生成器(tools/make_public_tree.py)

公开仓是新建的干净树(一笔英文提交、无开发史),不是把私仓翻公开。生成器用 可判定的谓词排除,不用文件清单——「手编清单的固有失败方式就是漏;规则表新增文件时默认也是『进』, 但判据不看名字看性质」。八条第一遍规则 + 一条第二遍规则:

  1. mgmt:MGMT/DECISIONS/BOOT 三个写死的名字 + docs/ · .claude/ · .superpowers/ · .replica-local/ 前缀。
  2. private-material:songs/ 整棵(白名单例外:示例歌的 lyrics.txt 与 source.mp3 两份, 逐份列不整目录带走;source.mp3 是「版本库里没有、但要进公开树」的唯一一处,取不到就 SystemExit—— 它曾被公开仓自己的 .gitignore 挡在提交外、推送前最后一刻才逮到:用户 clone 下来 README 教的第一条命令当场失败 DECISIONS 2026-08-16)。
  3. compose-engine:murripple/compose/ 整包。
  4. compose-dependent-test:AST 判定 import 了 compose 的测试——用 AST 不用子串有实测依据: grep -c compose 在三份好测试上分别是 7/3/2,按子串排会误伤。
  5. needs-real-songs:正则判定「读仓内 songs/ 目录」的测试(刻意不匹配小写 repo / "songs" 的纯路径比较)。
  6. private-corpus:断行真值语料三份(real-lyric-rows.json 装着五首歌 235 句完整歌词, 它不在 songs/ 下、不叫 lyrics.txt、不 import compose——前六条谓词全部放行,是 2026-08-15 挖出的 228 处泄漏主体; 代价明写:公开仓因此没有 CJK 断行回归)。
  7. reads-mgmt-docs:读仓内 docs/ 的测试(docs/ 整棵被 mgmt 排掉)。
  8. release-tooling:tools/ 自身。
  9. 第二遍 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 误报有声音——守卫要选有声音的那一边」。

12.4 许可与发布状态

13已知缺陷与复现者会踩的坑

为什么这一节在正文

一份不写已知缺陷的复现指南,会让复现者把 bug 当成自己的错。下表第一行尤其如此: 你照本文复现出的系统若在音符力度上表现「正常」,那说明你没有复现出当前行为—— 当前行为里那个 bug 是真实存在的。

13.1 已知未修缺陷(截至 dca6ff2)

缺陷现状与依据
★ 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

13.2 复现者会踩的坑(第三方与工具链的真实行为)

坑真实行为(全部实测)
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 默认归一化 IRscipy 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 的环境里判断对齐质量)

14复现自检清单

「AI 能据此复现」是可操作的判据。一个没见过这个仓的复现者,做完之后逐条自问——每一条在本文都有唯一出处:

  1. 契约:timeline.json 八个顶层字段、meta.language 可选的语义、base64 真解校验、 lane.stem ↔ 顶层 stems 交叉校验分开的异常类型、序列化格式(ensure_ascii=False、默认分隔符、无末尾换行)——§5。
  2. 色相权威:六条真歌 lane 的 id/hue/stem/band 逐字等于 §4.3 的表;心籁 300 在渲染层; 合成曲另加 arp 165 / bell 60;任何界面色板必须从这份表派生且有守卫钉着——§4.3、§10.5。
  3. 确定性:STEP=1/120 整数步数时钟、mulberry32(SEED ^ id)、禁 shadowBlur、 一切时变量是 t 的闭式或反查、quality 由 mode 决定、离线永不跳帧——§7;三层可复现性边界及「别拿 sha256 当判据」——§11。
  4. 体积:15,000,000 字节硬失败(MB=1e6 的三条理由)、原子替换落盘、声明分轨全部到齐才准打包——§6。
  5. 断行:9 汉字宽预算、两条规则按「用不用空格分词」二选一、静态宽度模型不 measureText、 LEAD_IN=0.5 在 t0 之前亮满——§7.5。
  6. 语言:全仓唯一决定点、30 秒窗口、最响 5 窗 + floor 0.25 各挡一种失败、众数不过半即 unsure 且必须说出来——§4.6。
  7. 分离与跳过判据:--shifts 0、扁平 vs 嵌套布局区分「外部提供」与「Demucs 产出」、 半套分轨拒绝、compose 换 seed 作废 timeline——§4.1、§9.2。
  8. 包络:60 Hz RMS → dB(−60 floor) → uint8 → base64,global peak 共享且不含 mix——§4.4。
  9. 壳子:只绑环回、PYTHONUNBUFFERED、分母判层、白名单认领、降级标记、 主日志截尾但详细区还原得回全量——§10。
  10. 已知缺陷照单全收:尤其 onset 力度恒近零——行为等价包括等价的 bug——§13.1。
本文自验

写完后按上述判据反问过三处关键契约,均能在文内找到唯一答案: ① timeline 的字段表与校验分层(§5);② 渲染层 15 层的顺序与每层承重常量(§7.3 + 附录 A3); ③ 语言侦测的完整参数与失败模式(§4.6 + §13.1)。同时对全文做过素材扫描: 四首私有歌曲的歌名/目录名 0 命中,出现的唯一曲名是《Trempe-moi》;外链 0 个——全文没有任何会发起网络请求的引用,仓库地址以纯文本给出;仅有的两处 http 字样是两张内联 SVG 的 xmlns 命名空间标识符(与对标拆解报告同一做法,标识符不发请求)。

A附录:参数速查

A1 九声部总表

id中文名英文名huestem来源备注
(vocals)心籁SOUL REED300vocals真歌 + 合成曲驱动判定环,不占 lane;面板首行
kick撼岳QUAKING PEAK28drums两者真歌:drums 低通 <120 Hz;合成曲独立 stem
snare裂帛RENT SILK350drums两者真歌:带通 200–800 Hz
hat碎玉JADE SHARDS195drums两者真歌:高通 >6 kHz
bass渊鸣ABYSS TOLL225bass两者唯一有音高跟踪的真歌 lane(YIN,C1–C4)
mid流岚DRIFTING HAZE175other真歌带通 200–4000 Hz;与 pad 共用名字与色相(永不共存)
air缥缈ETHER270other真歌高通 >4 kHz;与 pluck 共用
pad流岚DRIFTING HAZE175pad合成曲三失谐锯齿,oct 4
pluck缥缈ETHER270pluck合成曲Karplus-Strong,oct 5
arp泠泠LIMPID RUN165arp合成曲双锯齿按序走位,oct 6
bell霜铎FROST CHIME60bell合成曲2-op FM,换和弦时敲根音

出处:murripple/lanes.py:19-24(真歌六条 hue/stem/band)· murripple/cli.py LANE_HUES/LANE_LABELS(合成曲八条)· renderer/src/ui/voices.js LABELS(中英名真相源)。声部名是本项目的创作内容 (参考项目那套不得使用,守卫扫着)。

A2 几何与时钟常数

常量值出处 / 说明
STEP1/120 sclock.js;整数步数计时防浮点漂移
MAX_DPR2geometry.js;W/H 必须用封顶后的 dpr 算(有守卫)
R_RATIO0.225判定环半径占短边比(M2-4 前是 0.28)
CY_RATIO0.485圆心略高于几何中心
半径阶梯0.55 / 0.8 / 0.9 / 1.0 / 1.02 / 1.06 / 1.95波形 / 内刻度 / 环 / 车道 / 谱线基 / 外刻度 / 谱线顶 = 音符出生点(单位 R)
HUE_STEP37palette.js;与 360 互质
ENVELOPE_RATE60 Hzenvelope.py ↔ timeline.js 两端配对
RING_TAU_MS / LANE_TAU_MS250 / 180包络单极点平滑(预计算整条数组,无逐帧状态)
LEAD_T1.7 snotes.js 音符提前量(M1 spec 定)
PARTICLES_PER_HIT / LIFE_SEC12 / 0.8particles.js;稳态约 79 粒,不做对象池
PRESENCE_THRESHOLD0.02timeline.py;人声 RMS > 全曲峰值 2% 算在唱
MAX_ARTIFACT_MB15(×1e6 B)pack.py 硬失败

A3 分析与取材层常数

常量值出处
MODEL_SIZE / DEVICE / compute_typemedium / cpu / int8align.py(small 在唱歌上错得厉害;medium 慢 3–5 倍但 build 一次性)
WINDOW_SEC / VOTE_WINDOWS / SILENCE_FLOOR30 / 5 / 0.25align.py(只有 30 有依据——Whisper 自身性质)
SAMPLE_RATE(侦测)16000whisperx.load_audio 固定重采样
MIN_LINE_SEC / MAX_EXTRAPOLATION / DEFAULT_CHAR_SEC0.35 / 1.6 / 0.35align.py
FMIN/FMAX(YIN)32.70–261.63 Hz(C1–C4)analyze.py,bass 单音假设
detect_sections n9analyze.py 默认段数
DEFAULT_BITRATE64k AACencode.py;aac_at 优先
MIN_DURATION / LONG_DURATION_WARNING5.0 / 600.0 scli.py
OCR:fps / 亮阈 / 相似度 / 行上限2.0 / 220(p95) / 0.75 / 8.0 ssubtitle.py(8.0 是单样本棘轮)
MP3_QUALITY / DURATION_TOLERANCE2 / 1.0 singest/audio.py
QUIET_SECONDS15.0 sfetch.py 静默看门狗(只声称量到的事实)
web:端口 / 让路 / 轮询 / 截尾8731 / +20 / 700 ms / 20 行server.py / index.html / app.py
MAX_STEM_BYTES96 B(32 汉字)jobs.py(APFS 255 字符 / ext4 255 字节实测)
compose:BPM 区间 / 时长 / 密度曲线72–96 / 150 s / 0.30·0.55·0.85·0.50cli.py / arrange.py(密度四数要穿过下游量化台阶才算数)
synth:SR / 峰值目标 / 混响湿度44100 / −1 dBFS / 0.28(仅 lead+pad)synth.py(不压缩不限幅)

A4 关键实测数字(全部有台账出处)

1,094 / 2
pytest passed / skipped

本文写作时实跑(补齐真歌产物 + align extra 后);渲染层 301 pass(台账 2026-08-16)

14,183,341 B
九条分轨 · 150 秒

合成曲实测产物体积,余量 816,659 B;第十条声部需非音频开销 ≥ 6,833,410 B(48.2%)——装不下

111% → 2–9%
暂停时的单核占用

跳帧修复前后实测;播放中不变且不该变(每帧都是新 t)

199 个 / 6.6 MB
辉光精灵缓存稳态

全曲 1/60 s 逐帧扫两遍零增长;hueShift 改成随 t 漂移则涨到 3,706

1e-4
lanes 力度抖动量级

同机同源两次 build:结构全同,只有 v 抖——Demucs/torch 浮点不确定性

48 → 33 句
--shifts 默认值的代价

Demucs 随机平移导致两次 build 分轨不同、对齐掉句,代码一行没改

36 行 → 6 段
Whisper 转录 vs 歌词行

听写不替人断句的实测依据(另一首法语歌 34 行 → 8 段;两个实测数,不是分布)

92.4 s / 75.9%
对齐占 run 总耗时

《Trempe-moi》一次 run 实测 121.856 s,出处 docs/site/demo 抄件;分离 14.9 s

33 分钟 / 163 MB
1080p60 导出

4.5 分钟的歌、16,200 帧(README 实测参考)

161 份 / 7.3 MB
公开树规模

含示例歌 5.6 MB;clone 后 722 passed / 2 skipped

+380 B
M5v2 对四首真歌的全部漂移

stems 键 47 B + bundle 333 B,四首一致;音频 base64 解码后逐字节相同

0.87%
流星「双星齐落」时长占比

1.5× 调速后 600 秒窗口峰值(守的线是 <1%;腰斩方案实测 1.29% 被否)