Files
SpineWallpaper/.scratch/test-props.mjs
T
Shuery 0ca78ed117 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。即这是一个由偏好驱动、而非由测量差异驱动的决定。
2026-09-19 20:36:58 +08:00

166 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Issue 05 验收:bgm 音源 combo 与 fps 限流。
//
// A 段验音源优先级:用户自填 URL > bgm 选择(仅当本壁纸声明过)> 本壁纸默认音源;
// 且 bgm 选择跨壁纸时"音频回落、选择保留"。
// B 段验 fps:WE 不替壁纸限流,得自己门控。关键是限流只该丢帧,
// 不该改变动画相位推进速度——用同一段真实时间里 trackTime 前进了多少来判定。
import { withPage, sleep } from "../tools/cdp.mjs";
const BASE = "http://127.0.0.1:8190/";
const name = (src) => (src ? decodeURIComponent(String(src).split("/").pop()) : "(空)");
let failures = 0;
const check = (ok, msg, detail) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${detail}` : ""}`);
};
// ───────────────────────── A 段:音源 ─────────────────────────
console.log("=== A 段:bgm 音源选择与回落 ===");
await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: BASE + "?__freeze=1" });
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
await sleep(150);
}
await sleep(600);
const src = () => evaluate('document.getElementById("background-music").src');
const preset = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: ${JSON.stringify(v)} } })`);
const bgm = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ bgm: { value: ${JSON.stringify(v)} } })`);
const file = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ audio_file: { value: ${JSON.stringify(v)} } })`);
const seen = [];
const step = async (label, action, expected) => {
await action();
await sleep(350);
const got = name(await src());
seen.push(`${label}=${got}`);
check(got === expected, label, `期望 ${expected},实得 ${got}`);
};
// 载入即 xilian,默认音源应是「再度和你」
check(name(await src()) === "「再度和你」.flac", "初始默认音源 = 昔涟立绘默认", name(await src()));
// 无损是否真的可用,取决于 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"), "「再度和你」.flac");
await step("bgm 选「「再度和你」」", () => bgm("zaiduheni"), "「再度和你」.flac");
await step("bgm 回到「随预设」", () => bgm("auto"), "「再度和你」.flac");
// 用户自填 URL 优先级最高
await step("bgm 选「昔涟」后填自定义 URL", async () => {
await bgm("xilian");
await file("https://example.com/custom-track.mp3");
}, "custom-track.mp3");
await step("切壁纸不得清掉自定义 URL", () => preset("kv37"), "custom-track.mp3");
await step("清空自定义 URL → 回到预设默认", () => file(""), "pv37.mp3");
console.log(` 音源轨迹: ${seen.join(" | ")}`);
console.log(` 未捕获异常: ${exceptions.length ? exceptions.join(" | ") : "无 ✓"}`);
if (exceptions.length) failures++;
});
// ───────────────────────── B 段:fps ─────────────────────────
console.log("\n=== B 段:fps 限流 ===");
await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => {
// 空 __props 只用来触发测试驱动注入(不带它就不会注入),既不冻结也不改属性
await send("Page.navigate", { url: BASE + "?__props=%7B%7D" });
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
await sleep(150);
}
await sleep(1200); // 让动画先跑起来,避开启动瞬态
const probe = `(function(){
var p = window.__player;
var e = p.animationState.getCurrent(0);
return { trackTime: e.trackTime, duration: e.animation.duration,
draws: window.__test.drawCalls, t: performance.now() };
})()`;
const sample = async (seconds) => {
const a = await evaluate(probe);
await sleep(seconds * 1000);
const b = await evaluate(probe);
const wall = (b.t - a.t) / 1000;
let phase = b.trackTime - a.trackTime;
if (phase < 0) phase += b.duration; // 动画正好绕回
return { wall, draws: b.draws - a.draws, fps: (b.draws - a.draws) / wall, phaseRate: phase / wall };
};
const setFps = async (v) => {
await evaluate(`window.wallpaperPropertyListener.applyGeneralProperties({ fps: ${v} })`);
await sleep(400);
};
const results = [];
const run = async (label, target) => {
const r = await sample(2.0);
const row = { label, target, ...r };
results.push(row);
console.log(
` ${label.padEnd(16)} 实测 ${r.fps.toFixed(1)} fps` +
(target ? `(目标 ${target})` : "(不限流)") +
` 动画相位推进 ${r.phaseRate.toFixed(3)} 倍速`,
);
return row;
};
const base = await run("不限流", 0);
await setFps(30);
const f30 = await run("限 30 fps", 30);
await setFps(20);
const f20 = await run("限 20 fps", 20);
await setFps(0);
const back = await run("恢复不限流", 0);
check(Math.abs(f30.fps - 30) / 30 < 0.25, "限 30 fps 生效", `实测 ${f30.fps.toFixed(1)}`);
check(Math.abs(f20.fps - 20) / 20 < 0.25, "限 20 fps 生效", `实测 ${f20.fps.toFixed(1)}`);
check(f30.fps < base.fps * 0.75, "限流确实减少了绘制次数", `${base.fps.toFixed(1)} → ${f30.fps.toFixed(1)}`);
check(back.fps > f30.fps * 1.5, "取消限流后帧率恢复", `${f30.fps.toFixed(1)} → ${back.fps.toFixed(1)}`);
for (const r of [f30, f20, back]) {
check(
Math.abs(r.phaseRate - base.phaseRate) / base.phaseRate < 0.1,
`动画相位推进速度未受 ${r.label} 影响`,
`${r.phaseRate.toFixed(3)} vs 基准 ${base.phaseRate.toFixed(3)}`,
);
}
console.log(` 未捕获异常: ${exceptions.length ? exceptions.join(" | ") : "无 ✓"}`);
if (exceptions.length) failures++;
});
console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
process.exit(failures === 0 ? 0 : 1);