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:
1 parent
eb56efbd9e
commit
0ca78ed117
11 files changed
+152
-15
No files matched your search
@@ -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
@@ -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 未捕获异常
|
||||
|
||||
@@ -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 限制 | 完成 |
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+3
-3
@@ -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") },
|
||||
],
|
||||
};
|
||||
@@ -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`
|
||||
若能解出正确时长,解码器就没问题,问题在传输或路径。
|
||||
Reference in new issue
Block a user