feat: 音频改回无损 FLAC(用户决定:壁纸在本地,不计体积)

撤销 issue 02 里的音频转码,把两条 FLAC 放回 dist。

- 恢复方式是**字节级还原**而非重新编码:masters/ 里两份文件的 git 对象哈希与基线
  `d8c7e49:dist/assets/audios/*.flac` 完全一致,即与已发布的 v2 是同一份文件。
  因此本次提交不新增任何 blob 存储——这两份内容早已在仓库历史里。
- `preset.js` 的音源指向改回 `.flac`。`pv37.mp3` 保持原样:它**没有无损源**,
  把有损数据转封装成 FLAC 只会涨体积,而一个 bit 的信息都不会回来。
- 体积基准由「≤35 MB」改为「不超过已发布的 v2(104.98 MB)」,实际 **96.94 MB**,
  比 v2 还小(图片部分的无损 WebP 收益仍在),不引入新的上传或订阅风险。

顺带修掉一个自己引入的 bug:改 `preset.js` 时漏写了文件名里的 `「」`,
浏览器请求 404 → `<audio>` 在 **64 ms** 内报 `MediaError.code = 4`(SRC_NOT_SUPPORTED),
看上去像"CEF 不支持 FLAC",实际是路径不存在。判定方法记进 ADR 0004:
把同一份字节交给 `decodeAudioData`,能解出正确时长就说明解码器没问题,问题在传输或路径。

验证:
- `test-props`:CEF 支持 audio/flac、无解码错误、readyState=4、时长 205.1 s,14 项音源断言全过。
- `test-acceptance` 新增第 ⑤ 节音频格式不变式:xilian 两个音源全为 flac、无 `.ogg`/`.opus`
  残留、`preset.js` 引用的每个音源文件都存在;三种比例 0 报错;预设往返不丢音频设置。
- 分带能量实测:FLAC 高频平滑延伸到 22 kHz(20–22 kHz 处 −50 dB),无有损断崖;
  与 Opus 版逐带相差 0.0–0.3 dB。即这是一个由偏好驱动、而非由测量差异驱动的决定。
This commit is contained in:
Shuery committed 2026-09-19 20:36:58 +08:00
1 parent eb56efbd9e
commit 0ca78ed117
11 files changed
+152 -15

No files matched your search

+40 -2
View File
@@ -1,7 +1,8 @@
// Issue 06 全量验收。
// ① dist 体积与死文件;② project.json 与代码一致;③ 三种比例 0 控制台报错;
// ④ 预设往返不丢用户级音源/音量设置。
import { readdirSync, readFileSync, statSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { basename, extname, join } from "node:path";
import { withPage, sleep } from "../tools/cdp.mjs";
@@ -20,7 +21,10 @@ const walk = (dir) =>
});
const files = walk("dist");
const total = files.reduce((s, f) => s + statSync(f).size, 0);
check(total <= 35 * 1024 * 1024, "总体积 ≤ 35 MB", `${(total / 1048576).toFixed(2)} MB / ${files.length} 个文件`);
// 用户已明确豁免体积(音频走无损、壁纸在本地),基准改为"不超过已通过审核的 v2"。
// 这是真正有意义的界:v2 以 104.98 MB 发布过,所以这个体积不会带来新的上传风险。
const V2_MB = 104.98;
check(total <= V2_MB * 1024 * 1024, `总体积不超过已发布的 v2(${V2_MB} MB)`, `${(total / 1048576).toFixed(2)} MB / ${files.length} 个文件`);
const TEXT = new Set([".html", ".js", ".json", ".txt", ".css", ".atlas"]);
const texts = files.filter((f) => TEXT.has(extname(f)));
@@ -131,5 +135,39 @@ check(volOk, "10 次切换后音量仍为 0.2", [...new Set(rt.rows.map((r) => r
check(rt.rows[rt.rows.length - 1].draws > rt.rows[0].draws, "切换后仍在持续绘制", `${rt.rows[0].draws} → ${rt.rows[rt.rows.length - 1].draws}`);
check(rt.exceptions.length === 0, "往返过程 0 未捕获异常", rt.exceptions.join(" | ") || "无");
// ── ⑤ 音频格式:xilian 两个音源必须是无损 ──
console.log("\n=== ⑤ 音频格式与可达性 ===");
const audioDirs = [
["dist/assets/崩坏:星穹铁道/昔涟立绘/audios", "flac", "昔涟立绘(无损)"],
["dist/assets/崩坏:星穹铁道/「成为昨日的明天」/audios", "mp3", "「成为昨日的明天」"],
];
const codecOf = (f) =>
execFileSync("ffprobe", ["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name", "-of", "default=nw=1:nk=1", f])
.toString()
.trim();
for (const [dir, want, label] of audioDirs) {
const fs2 = readdirSync(dir);
const codecs = fs2.map((f) => `${f}=${codecOf(join(dir, f))}`);
check(
fs2.length > 0 && fs2.every((f) => codecOf(join(dir, f)) === want),
`${label} 音源全为 ${want}`,
codecs.join(", "),
);
}
// 有损的 .ogg 必须已经清干净,否则就是残留
const stale = files.filter((f) => /\.(ogg|opus)$/.test(f));
check(stale.length === 0, "已无残留的有损音源(.ogg/.opus)", stale.length ? stale.join(", ") : "干净");
// preset.js 引用的每个音源都必须真实存在——引用错一个字符就是 404 + 立即静音
const presetFiles = files.filter((f) => basename(f) === "preset.js");
let broken = [];
for (const pf of presetFiles) {
const text = readFileSync(pf, "utf8");
for (const m of text.matchAll(/asset\("(\.\/audios\/[^"]+)"\)/g)) {
const target = join(pf, "..", m[1]);
if (!existsSync(target)) broken.push(`${pf} → ${m[1]}`);
}
}
check(broken.length === 0, "preset.js 引用的音源文件全部存在", broken.length ? broken.join(" | ") : `${presetFiles.length} 份预设,路径全部可达`);
console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
process.exit(failures === 0 ? 0 : 1);
+33 -5
View File
@@ -41,14 +41,42 @@ await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions
};
// 载入即 xilian,默认音源应是「再度和你」
check(name(await src()) === "再度和你.ogg", "初始默认音源 = 昔涟立绘默认", name(await src()));
check(name(await src()) === "「再度和你」.flac", "初始默认音源 = 昔涟立绘默认", name(await src()));
await step("bgm 选「昔涟」", () => bgm("xilian"), "昔涟.ogg");
// 无损是否真的可用,取决于 CEF 能不能解 FLAC——不能只看路径对不对。
// readyState>=1(HAVE_METADATA) 说明容器与编码被接受,duration>0 说明解析出了正确时长。
const healthProbe = `(function(){
var a = document.getElementById("background-music");
return { rs: a.readyState, dur: a.duration, net: a.networkState,
err: a.error ? a.error.code : null, flac: a.canPlayType("audio/flac") };
})()`;
let h = null;
for (let i = 0; i < 40; i++) {
h = await evaluate(healthProbe);
if (h.rs >= 1 || h.err !== null) break;
await sleep(250);
}
check(h.flac === "probably" || h.flac === "maybe", "CEF 支持 audio/flac", h.flac || "(不支持)");
check(h.err === null, "音频无解码错误", h.err === null ? "无" : `MediaError.code=${h.err}`);
check(h.rs >= 1, "FLAC 已解析出元数据(readyState≥1)", `readyState=${h.rs}`);
check(h.dur > 100 && h.dur < 300, "解析出的时长符合源文件", `${h.dur?.toFixed(1)} 秒`);
// 元数据能解析 ≠ 真的在放。等一会儿看播放头有没有前进——这才是"无损可用"的终局证据。
await sleep(1500);
const pb = await evaluate(`(function(){
var a = document.getElementById("background-music");
return { paused: a.paused, t: a.currentTime, loop: a.loop, vol: a.volume };
})()`);
check(!pb.paused, "音频处于播放状态", pb.paused ? "仍暂停" : "播放中");
check(pb.t > 0.5, "播放头已前进(确实在解码播放)", `currentTime=${Number(pb.t).toFixed(2)} s`);
check(pb.loop === true, "循环播放已开启", String(pb.loop));
await step("bgm 选「昔涟」", () => bgm("xilian"), "昔涟.flac");
await step("切到 kv37(选择不属于它)→ 回落", () => preset("kv37"), "pv37.mp3");
await step("bgm 选「版本 PV」(属于 kv37)", () => bgm("pv37"), "pv37.mp3");
await step("切回 xilian(pv37 不属于它)→ 回落", () => preset("xilian"), "再度和你.ogg");
await step("bgm 选「「再度和你」」", () => bgm("zaiduheni"), "再度和你.ogg");
await step("bgm 回到「随预设」", () => bgm("auto"), "再度和你.ogg");
await step("切回 xilian(pv37 不属于它)→ 回落", () => preset("xilian"), "「再度和你」.flac");
await step("bgm 选「「再度和你」」", () => bgm("zaiduheni"), "「再度和你」.flac");
await step("bgm 回到「随预设」", () => bgm("auto"), "「再度和你」.flac");
// 用户自填 URL 优先级最高
await step("bgm 选「昔涟」后填自定义 URL", async () => {
@@ -1,6 +1,6 @@
# 02 体积:音频转码 + 未引用资源归位
Status: resolved
Status: resolved,但**音频转码已被推翻**(见文末「修订」);图片部分保持有效
## 现状
@@ -51,3 +51,34 @@ Status: resolved
**先验证再删除**。中途 `-preset default` 让无损编码退化成有损(产物只剩 0.03–0.54 MB),
而源 PNG 当时已被删除;只因为源都在 git 里(`09ff322`)才得以还原。
另外 `-quality` 在这个 ffmpeg 的 libwebp 封装里被忽略,要用 `-q:v`;`-exact` 选项不存在。
## 修订:音频改回无损(用户决定)
用户要求「音频可使用无损格式,这个壁纸本身就在本地,无需考虑大小」。
于是**撤销本文第 2 步的音频转码**,把两条 FLAC 放回 `dist`。
- 恢复方式是**字节级还原**,不是重新编码:`masters/` 里那两条 FLAC 的 git 对象哈希
与基线 `d8c7e49:dist/assets/audios/*.flac` **完全一致**,即与已发布的 v2 装的是同一份文件。
- `preset.js` 的音源指向随之改回 `.flac`;`pv37.mp3` **没有无损源**,保持原样(也一直是 mp3)。
- 体积基准由「≤35 MB」改为「不超过已发布的 v2(104.98 MB)」。实际 **96.94 MB**——
比 v2 还小(图片那部分的无损 WebP 收益仍在),所以**不引入新的上传/订阅风险**。
- 图片部分**不受影响**:无损 WebP 本来就不牺牲画质,没有取舍可撤。
### 关于"转成无损"的边界
`pv37.mp3` 不能靠转封装变成无损:把有损数据装进 FLAC 只会让 2.3 MB 涨到十几 MB,
而一个 bit 的信息都不会回来。要它无损,只能换一个无损源文件。
### 踩到的坑:文件名里的「」
`「再度和你」.flac` 的**文件名带 `「」`**(v2 就是如此)。改完 `preset.js` 后我漏写了这两个字符,
浏览器请求 `再度和你.flac` → 服务端 404 → `<audio>` 在 **64 ms** 内直接报
`MediaError.code = 4`(SRC_NOT_SUPPORTED)。这个错误码极具误导性:看起来像"CEF 不支持 FLAC",
实际是路径不存在。排查时靠三条证据把方向掰了回来:
1. `decodeAudioData` 直接解同一份字节 → 成功(205.056 s / 48 kHz / 2 声道),证明解码器没问题;
2. `<audio>` 指向正确 URL 与 blob URL → 都正常出 `loadedmetadata`;
3. 页面自身元素的时间线里有一条 **404**,且 `currentSrc` 结尾是 `再度和你.flac`——少了 `「」`。
结论:**`MediaError.code=4` 要先查 404,再怀疑格式支持。** 收尾时已在 `test-acceptance.mjs`
第 ⑤ 节加了不变式——`preset.js` 引用的每个音源必须真实存在——防止再犯。
@@ -24,8 +24,10 @@ Status: resolved(第 4 步为人工步骤,待用户执行)
注:减色到 96/64/48 色收效甚微(604/519/474 KB)——体积由帧间变化量主导,不由调色板主导,
所以走帧采样率这条路更划算。
3. **全量验收**(`node .scratch/test-acceptance.mjs`):**全部通过**
- 总体积 **28.52 MB** / 34 个文件(≤ 35 MB)
- 总体积 **96.94 MB** / 33 个文件——未超已发布的 v2(104.98 MB),音频走无损后仍比 v2 小
- **0 死文件**
- 音频格式不变式:xilian 两个音源均为 `flac`、`pv37.mp3` 保持 mp3、已无 `.ogg`/`.opus` 残留、
`preset.js` 引用的音源路径全部可达
- `project.json` 解析正常、version 3、bgm 存在、preset 取值未变
- 21:9 / 16:9 / 9:16 三种比例:播放器就绪、可见矩形比例与画布一致、**0 控制台报错 / 0 异常**
- 预设往返 10 次:自定义音源与音量 0.2 均未丢失,仍在持续绘制,0 未捕获异常
+3 -3
View File
@@ -6,10 +6,10 @@
- 原样入库的基线是 commit `d8c7e49`(105.1 MB),期间不做"顺手重构"。
- 已发布作品,`project.json` 里 combo 选项的 `value`(`xilian` / `kv37`)**不可改**,否则老用户已保存的预设选择会失效。
- 音源与音量是**用户级覆盖**,切换预设不得清掉;接受有损音频,无损母带留在 dist 之外。
- 音源与音量是**用户级覆盖**,切换预设不得清掉;**音频走无损**(用户决定:壁纸在本地,不计体积)。
- 图片转 WebP 只在 AB 对拍证明肉眼无差别后才做,否则保留 PNG。
- 适配目标:任何比例下主体完整、底部对齐、无空边。
- 验收:0 个死文件;总体积 ≤35 MB;3 种比例干净;0 个 console 报错;预设往返不丢音源设置;连续 20 次切换无内存增长。
- 验收:0 个死文件;总体积不超过已发布的 v2(104.98 MB,实际 96.94 MB);3 种比例干净;0 个 console 报错;预设往返不丢音源设置;连续 20 次切换无内存增长;xilian 两个音源为无损 FLAC 且路径可达。
## 决策记录
@@ -34,7 +34,7 @@
| # | 步骤 | 状态 |
| --- | --- | --- |
| 01 | 资源按 游戏/壁纸 分层,预设拆进各自壁纸目录 | 完成(`09ff322`) |
| 02 | 体积:音频转 Opus + 图片无损 WebP(105.1 → 28.9 MB) | 完成 |
| 02 | 图片无损 WebP(25 → 19.6 MB);音频一度转 Opus,后被用户否决改回无损 | 完成(音频见 issue 02 修订) |
| 03 | 健壮性:初始化状态机、死代码、用户级覆盖语义 | 完成(`4662856`) |
| 04 | 比例适配:立绘层取景跟随背景 cover 缩放 | 完成 |
| 05 | WE 属性扩展:音源 combo、fps 限制 | 完成 |
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -25,13 +25,13 @@ export default {
},
audioOptions: {
source: asset("./audios/再度和你.ogg"),
source: asset("./audios/「再度和你」.flac"),
volume: 0.5,
},
// 本壁纸可选的音源(第 6 步会接进 project.json 的 bgm 属性)
audioChoices: [
{ id: "zaiduheni", name: "「再度和你」", source: asset("./audios/再度和你.ogg") },
{ id: "xilian", name: "昔涟", source: asset("./audios/昔涟.ogg") },
{ id: "zaiduheni", name: "「再度和你」", source: asset("./audios/「再度和你」.flac") },
{ id: "xilian", name: "昔涟", source: asset("./audios/昔涟.flac") },
],
};
+38
View File
@@ -0,0 +1,38 @@
# 音频用无损,不按体积取舍
`dist` 里的背景音乐用无损 FLAC(以及没有无损源时的原始 mp3),
**不以体积为由转成有损编码**。已发布的 v2 装的就是这份无损音频,本次改回是字节级还原。
## 背景
优化过程中一度把两条 FLAC(各约 37 MB)转成 Opus,`dist` 从 105.1 MB 降到 28.9 MB,
并把无损母带移到 `masters/`(gitignore,不入库)。用户随后明确要求:
「音频可使用无损格式,这个壁纸本身就在本地,无需考虑大小」。
## 决定
- 两条 FLAC 放回 `dist`,用 `masters/` 里的原件**直接复制**,不重新编码。
这一点可验证:`masters/` 里两份文件的 git 对象哈希与基线
`d8c7e49:dist/assets/audios/*.flac` 完全一致,即与已发布的 v2 是同一份字节。
- 体积基准从「≤35 MB」改为「**不超过已发布的 v2**(104.98 MB)」。实际 96.94 MB,
比 v2 还小,因此不引入新的上传或订阅风险。
- `pv37.mp3` 保持 mp3:**它没有无损源**。把有损数据转封装成 FLAC 只会让 2.3 MB 涨到十几 MB,
而一个 bit 的信息都不会回来——所以「没有无损源」时,正确做法是保持原样,而不是假装无损。
- 图片部分不撤:无损 WebP 本来就不牺牲画质,没有取舍可撤。
## 代价与前提
- 订阅者要多下载约 68 MB。用户已明确接受。
- 无损音频**只有在源头就是无损时才有意义**。这两条 FLAC 的高频能量一路平滑延伸到 22 kHz
(20–22 kHz 处 −50 dB、22–23.5 kHz 处 −62 dB),没有有损编码的断崖式截止,
分带能量与 Opus 版逐带相差 0.0–0.3 dB——即当初的 Opus 并没有砍掉高频,
差异只在量化噪声层面。
- 结论:这是一个**由用户偏好驱动、而非由测量差异驱动**的决定。记录下来是为了避免后续
有人(包括 agent)再以「体积可以更小」为由把它压回去。
## 附带教训
`MediaError.code = 4`(SRC_NOT_SUPPORTED)**要先查 404,再怀疑格式支持**。
文件名 `「再度和你」.flac` 带 `「」`,改 `preset.js` 时漏写这两个字符就会 404,
而浏览器给出的却是「格式不支持」,极具误导性。判定方法:同一份字节交给 `decodeAudioData`
若能解出正确时长,解码器就没问题,问题在传输或路径。