Visitar URL original
Let theme packages ship their own sound recordings · tmoonlight/acecode@ef3146e · GitHub
Skip to content

Commit ef3146e

Browse files
committed
Let theme packages ship their own sound recordings
A theme may declare sounds.files and carry sound-<event>.ogg (Ogg Opus, up to 512 KiB each) for any of the eleven sound events. Import, export and installed-version checks cover the recordings like the images, and GET /api/themes/<id>/sounds/<name> serves them as audio/ogg. The page decodes them when the theme activates and plays them instead of the synthesized sounds; events without a recording, or whose recording does not decode, keep the style's synthesized sound.
1 parent c253593 commit ef3146e

16 files changed

Lines changed: 423 additions & 43 deletions

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,7 @@ SidePanel 折叠 UI:`ChatView` 把 `SidePanel` 包到 `<div class="ace-side-pane
665665

666666
- **样式全在 `web/src/styles/themeHud.css`**,每条选择器必须以 `:root[data-theme-chrome="hud"]` 开头(或是只在 HUD 下挂载的 `.ace-hud-*` 动效层类名),动画还必须带 `[data-theme-effects="on"]`;`themeHud.test.js` 解析 CSS 守着这两条。它随应用常驻加载,一条没加作用域的规则就会改到所有主题。**唯一例外是滚动条部件规则,一律用等价的 `html[data-theme-chrome="hud"]` 前缀**:Chromium(Edge 155 / WebView2 实测)在祖先部分含 `:root` 且滚动条部件带 `:vertical` / `:horizontal` / `:hover` 时整条规则不生效,滑块只剩透明底色、整条滚动条消失;换成 `html` 类型选择器即正常,测试同样守着。
667667
- 滚动条与对话导航:滑块是尖头(主体 + 两个整像素起点的三角,半透明层不重叠不留缝);侧栏原先画在背景上的虚线竖轨改为会话列表滚动条的轨道,对话区滚动条轨道是带端帽与刻度的标尺,滑块都骑在轨道正中,不再与装饰线错位重叠。对话左侧的刻度尺在出现问题导航(`.ace-conversation-turn-scrubber`)时经 `:has()` 隐藏,导航自己画菱形 + 竖脊;端帽挂在首末命中区的 `::before` 上并跟随 `--ace-conversation-turn-marker-shift-y` 位移(当前项展开会把相邻标记推开,画在命中区背景上会被菱形盖住)。无头 Edge 截图前去掉 `--hide-scrollbars`,否则看不到滚动条。
668-
- **主题音效**(2026-10-24 用户要求,同样是工坊主题专用):主题包顶层 `"sounds":{"style":"hud"}`(`themePackages.js::themeSoundStyle`),声音全部在 `lib/themeSounds.js` 用 Web Audio 实时合成,**主题包不放音频文件** —— 改包格式要动 C++ 导入、已部署的工坊校验并让旧版拒收。只有声明了音效的主题,TopBar 左上角(搜索右侧)才渲染静音按钮(`data-theme-sound-toggle`,`web_ui.theme_sounds` 默认开,与 theme_effects 同一条落盘 / 首帧注入链路)。门控在模块级:`ThemeSoundLayer`(ThemeProvider 渲染)`configureThemeSounds({style, muted})`,调用点只管 `playThemeSound(name)`,其它主题下全是空操作。触发点:全局 pointerdown / Enter·Space 捕获(`themeSoundForTarget` 按最近的可交互元素分 click / select / toggle,发送按钮、风格选项、静音按钮排除,各自出声)、InputBar 发送 / 排队 / 停止、App 的 WS 事件监听(完成音挂在桌面通知的 `markSeen` 之后,提问 / 权限按 request_id 去重,出错音;**三者都跳过 `msg.replayed`**,否则切进运行中的会话会把回放的旧事件再响一遍)、开机 / 切换音复用 `hudBurstForChange`(静音期间也跟踪,解除静音不补放开机音)。浏览器首次用户操作前不建 AudioContext(否则建出挂起的上下文,之后补成一声迟到的响),恢复超过 200ms 的请求丢弃。音量按峰值 -18 dBFS(点击)到 -9 dBFS(开机)调过:Chromium 的 DynamicsCompressor 自带补偿增益,改压缩参数后整体响度会跟着变,要重新离线渲染量峰值(无头 Edge + `OfflineAudioContext` 跑同一个 `scheduleThemeSound`)。
668+
- **主题音效**(2026-10-24 用户要求,同样是工坊主题专用):主题包顶层 `"sounds":{"style":"hud"}`(`themePackages.js::themeSoundStyle`),`lib/themeSounds.js` 用 Web Audio 为每个事件合成声音。**2026-10-25 起主题包可以自带录音**(用户嫌合成音出戏,拍板把赛博朋克 2077 的 UI 音效放进荒坂主题,粉丝作品、免费给朋友用,缩略图带 CDPR 要求的「非官方粉丝作品」声明):`sounds.files` 声明 `{事件: {bytes, sha256}}`,包根放 `sound-<事件>.ogg`(Ogg Opus,≤512 KiB,事件名只能是 `THEME_SOUND_NAMES` 那 11 个,C++ `theme_validation.cpp::kSoundNames` 与工坊 `WorkshopPackage.SoundNames` 各一份要同步);C++ `theme_sound_files` / `theme_resource_assets` 管完整性、导入导出与安装复检,daemon `GET /api/themes/<id>/sounds/<name>` 出 `audio/ogg`;页面在主题生效时就用 `OfflineAudioContext` 解码(不需要用户手势),有录音的事件放录音(不过压缩器,响度在打包时调好),没有或解码失败的退回合成音,还在解码就等就绪再放、不先响合成音。`files` 只在已知 style 下生效。旧版客户端与旧工坊会拒收带录音的包(ZIP 条目数 / 文件名白名单)。录音提取流程(WolvenKit CLI 拆 `audio_2_soundbanks.archive`、自写 `sfx_container.bnk` HIRC 解析把事件追到 opus 流、单声道归一化后 32 kbps 重编码)的脚本在 `N:\Users\shao\acecode-wt\arasaka-theme-src\sound-source\`。只有声明了音效的主题,TopBar 左上角(搜索右侧)才渲染静音按钮(`data-theme-sound-toggle`,`web_ui.theme_sounds` 默认开,与 theme_effects 同一条落盘 / 首帧注入链路)。门控在模块级:`ThemeSoundLayer`(ThemeProvider 渲染)`configureThemeSounds({style, muted, samples})`(samples 按 `<id>@<version>` 去重,同一版本不重复下载),调用点只管 `playThemeSound(name)`,其它主题下全是空操作。触发点:全局 pointerdown / Enter·Space 捕获(`themeSoundForTarget` 按最近的可交互元素分 click / select / toggle,发送按钮、风格选项、静音按钮排除,各自出声)、InputBar 发送 / 排队 / 停止、App 的 WS 事件监听(完成音挂在桌面通知的 `markSeen` 之后,提问 / 权限按 request_id 去重,出错音;**三者都跳过 `msg.replayed`**,否则切进运行中的会话会把回放的旧事件再响一遍)、开机 / 切换音复用 `hudBurstForChange`(静音期间也跟踪,解除静音不补放开机音)。浏览器首次用户操作前不建 AudioContext(否则建出挂起的上下文,之后补成一声迟到的响),恢复超过 200ms 的请求丢弃。音量按峰值 -18 dBFS(点击)到 -9 dBFS(开机)调过:Chromium 的 DynamicsCompressor 自带补偿增益,改压缩参数后整体响度会跟着变,要重新离线渲染量峰值(无头 Edge + `OfflineAudioContext` 跑同一个 `scheduleThemeSound`)。
669669
- **异形窗口**(2026-10-25,用户要求只影响声明了它的主题):主题包顶层 `"window":{"shape":"chamfer"}`(`themePackages.js::themeWindowShape`)→ ThemeProvider 经 `aceDesktop_setWindowShape` 让 Windows 桌面壳切角。`web_host.cpp` 里全部挂在 `g_window_shape.chamfer` 上,没打开时一行都不走:(1) `SetWindowRgn` 按 `window_shape.cpp::chamfer_region_rects` 切 16 DIP 的角(最大化时去掉区域);(2) 切角时 `frameless_nc_calc` 不留左/右/下的隐形缩放边 —— 实测设区域后 `DWMWA_EXTENDED_FRAME_BOUNDS` 变成整个窗口矩形,留着缩放边贴边分屏会差一圈缝;打开 / 关闭时按客户区收放窗口矩形,可见区域不动;缩放靠两处拼成:页面的 `FramelessResizeHandles` 在 `[data-window-shape="chamfer"]` 下把角放大到 28px(clip-path 只留沿边 6px 与斜边内侧一条),窗口外侧由 `ChamferShadowWindows` 的四块把手窗口接住(alpha 1 像素可点、0 点穿,`chamfer_resize_zone`:外伸与系统隐形缩放边同厚、含切掉的缺口、角沿边延伸 28px,按下转给主窗口 `WM_NCLBUTTONDOWN`)—— 用户反馈只在斜边内侧放小角块改变了拖角习惯;(3) 实测设区域后 Win11 系统阴影整圈消失,`window_shape_shadow_win.cpp` 用四块 `WS_EX_LAYERED | WS_EX_TRANSPARENT` 的归属弹出窗口补画(上下两块向内覆盖切角高度,形内像素全透明;归属窗口恒在主窗口之上,所以必须透明而不是被主窗口挡住);区域与阴影共用 `chamfer_contains_pixel`,斜边处逐像素互补。阴影跟随在 `WM_WINDOWPOSCHANGED` 里做,只移动时用 `DeferWindowPos` 挪位置,尺寸 / DPI / 激活态 / 辉光色变了才重画。Win10 顶边自绘线在切角时不让位(外框由页面画)。HUD 外框四条斜边在切角时改画在切口内侧并覆盖中心落在切线上的那排像素,否则一半被区域裁掉、或在窗口边缘与斜线之间留一像素暗缝(1.5 倍缩放实测)。页面推送器初始状态是「未知」而不是 none:曾初始视为 none,切角主题下重载成普通主题的页面一次桥接都不发,窗口保持切角。
670670
- 切角:无弹层的元素(用户气泡、代码块、首页卡片)直接 `clip-path`;可能挂下拉/悬浮卡片的容器(输入框 `.ace-composer-card`、工具行 `.ace-tool-activity`)把裁切底色放 `::before`,容器本身不裁,否则会把弹层切掉。框线统一画在 `::after` 的渐变层上(`--hud-frame` / `--hud-cut` 覆盖)。
671671
- **背景染色**:`tint` 让三张背景按 CSS `background-blend-mode: color` 染色(保留亮度、换色相),一套图对应全部风格;`themeCssProperties(..., {tint})` 插入同色渐变层并给出 `--ace-*-background-blend`,无 tint 时输出与旧版逐字一致。用户消息气泡里图片之外的底色同样被染,图片顶部纯色与 `user_message_background_color` 亮度相同才无接缝。

‎docs/daemon-api.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ Development `--static-dir` resources continue to be read from disk on each reque
387387
| GET | `/api/themes/<id>` | verified locally installed theme definition |
388388
| POST | `/api/themes/<id>/install` | download a theme after size/hash confirmation |
389389
| GET | `/api/themes/<id>/images/<kind>` | theme thumbnail or installed background |
390+
| GET | `/api/themes/<id>/sounds/<name>` | an installed theme's declared `sound-<name>.ogg` recording (`audio/ogg`, not cached; 404 when the theme does not declare it) |
390391
| POST | `/api/themes/<id>/export` | prepare a custom theme ZIP, optionally saving through the native picker |
391392
| GET | `/api/themes/exports/<job_id>` | read theme export/packing progress |
392393
| POST | `/api/themes/exports/<job_id>/cancel` | cancel the matching theme export job |

‎docs/themes.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949

5050
## 本地主题协议
5151

52-
AI 主题使用 `ai-<slug>` 标识,允许小写 ASCII 字母、数字及短横线,总长度不超过 64;不会覆盖蓝色、橙色或 EVA 内置主题。`theme.json` 沿用 `schema_version: 1`,包含名称、明暗模式、28 项十六进制颜色,以及背景和缩略图的大小与 SHA-256。旧主题包的 `theme.json`、`background.png`、`thumbnail.png` 三个根文件保持不变;可选增加声明过的 `session-background.png` 和 `user-message-background.png`,分别对应 `session_background` 和 `user_message_background` 元数据。主题包不包含可执行 CSS、HTML、SVG 或应用代码;矢量源稿通过 Browser 输出 PNG 后安装。
52+
AI 主题使用 `ai-<slug>` 标识,允许小写 ASCII 字母、数字及短横线,总长度不超过 64;不会覆盖蓝色、橙色或 EVA 内置主题。`theme.json` 沿用 `schema_version: 1`,包含名称、明暗模式、28 项十六进制颜色,以及背景和缩略图的大小与 SHA-256。旧主题包的 `theme.json`、`background.png`、`thumbnail.png` 三个根文件保持不变;可选增加声明过的 `session-background.png` 和 `user-message-background.png`,分别对应 `session_background` 和 `user_message_background` 元数据;还可以带声明过的 `sound-<事件>.ogg` 录音(见下文「主题音效」)。主题包不包含可执行 CSS、HTML、SVG 或应用代码;矢量源稿通过 Browser 输出 PNG 后安装。
5353

5454
可选顶层 `appearance` 接受以下参数;颜色只允许 `#RRGGBB`,通顶只允许 boolean,不透明度只允许 0–1 的有限数值,不接受未声明字段或 CSS:
5555

@@ -104,7 +104,15 @@ AI 主题使用 `ai-<slug>` 标识,允许小写 ASCII 字母、数字及短横
104104

105105
### 主题音效
106106

107-
顶层再加 `"sounds": {"style": "hud"}` 就开启 ACECode 自带的同名音效(`web/src/lib/themeSounds.js`,Web Audio 实时合成,主题包里不放音频文件;旧版与工坊同样当作未知键忽略)。只有声明了音效的主题,标题栏左上角(搜索按钮右侧)才显示静音按钮,开关存 `config.json` 的 `web_ui.theme_sounds`(默认开)。
107+
顶层再加 `"sounds": {"style": "hud"}` 就开启 ACECode 自带的同名音效(`web/src/lib/themeSounds.js`,Web Audio 实时合成;旧版与工坊同样当作未知键忽略)。只有声明了音效的主题,标题栏左上角(搜索按钮右侧)才显示静音按钮,开关存 `config.json` 的 `web_ui.theme_sounds`(默认开)。
108+
109+
主题也可以自带录音,替换部分或全部合成音:
110+
111+
```json
112+
"sounds": {"style": "hud", "files": {"click": {"bytes": 997, "sha256": "…"}, "boot": {"bytes": 7901, "sha256": "…"}}}
113+
```
114+
115+
每个声明的事件在包根目录放一个 `sound-<事件>.ogg`(Ogg Opus,单个不超过 512 KiB),事件名只能是 `click`、`select`、`toggle`、`send`、`queue`、`stop`、`complete`、`alert`、`error`、`boot`、`switch`。录音必须与声明一一对应、字节与 SHA-256 一致,否则整包拒收。daemon 经 `GET /api/themes/<id>/sounds/<事件>` 提供录音,页面在主题生效时就下载解码;没声明录音的事件照常用 `style` 的合成音,录音解码失败的事件也退回合成音,所以 `files` 必须和 `style` 一起出现。录音在打包时就调好响度,播放时不再经过合成音的压缩器。带录音的包需要同样支持录音的 ACECode 与工坊;旧版会把多出来的文件当作未知资源拒收。
108116

109117
| 时机 | 音效 |
110118
|---|---|

‎src/adapters/themes/theme_store.cpp‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,9 @@ std::string theme_base(std::string update_base) {
182182

183183
std::map<std::string, std::string> unpack_archive(zip_t* archive) {
184184
const auto count = archive ? zip_get_num_entries(archive, 0) : 0;
185-
if (count < 3 || count > 5) {
186-
throw ThemeError(422, "THEME_INVALID_PACKAGE", "Theme archive must contain three to five declared resources");
185+
// theme.json, two to four images and up to eleven declared sounds.
186+
if (count < 3 || count > 16) {
187+
throw ThemeError(422, "THEME_INVALID_PACKAGE", "Theme archive must contain only its declared resources");
187188
}
188189
std::map<std::string, std::string> files;
189190
std::size_t total = 0;
@@ -196,11 +197,12 @@ std::map<std::string, std::string> unpack_archive(zip_t* archive) {
196197
throw ThemeError(422, "THEME_INVALID_PACKAGE", "Invalid archive metadata");
197198
}
198199
const std::string name = info.name;
200+
const bool sound = detail::theme_sound_file_name(name);
199201
const auto limit = name == "theme.json" ? 32 * 1024 :
200-
name == "thumbnail.png" ? kMaxPreviewBytes : kMaxPackageBytes;
202+
name == "thumbnail.png" ? kMaxPreviewBytes : sound ? detail::kMaxSoundBytes : kMaxPackageBytes;
201203
const auto type = (attributes >> 16) & 0170000;
202204
if ((name != "theme.json" && name != "background.png" && name != "thumbnail.png" &&
203-
name != "session-background.png" && name != "user-message-background.png") ||
205+
name != "session-background.png" && name != "user-message-background.png" && !sound) ||
204206
files.count(name) || !info.size || info.size > limit ||
205207
((os == ZIP_OPSYS_UNIX || os == ZIP_OPSYS_OS_X) && type && type != 0100000)) {
206208
throw ThemeError(422, "THEME_INVALID_PACKAGE", "Unsupported theme archive entry");
@@ -483,9 +485,9 @@ json ThemeStore::definition(const std::string& id) const {
483485
if (d.at("id") == id && valid_theme_definition(d) &&
484486
d.at("version") == dir.filename().string()) {
485487
bool complete = true;
486-
for (const auto& [kind, name] : theme_image_files(d)) {
488+
for (const auto& [name, asset] : theme_resource_assets(d)) {
487489
if (is_local_theme(id)) checked_path(root_, dir.lexically_relative(root_) / name);
488-
if (!matches_file(dir / name, d.at(kind))) complete = false;
490+
if (!matches_file(dir / name, asset)) complete = false;
489491
}
490492
if (complete) return d;
491493
}
@@ -537,8 +539,8 @@ json ThemeStore::import_archive(const std::string& archive_bytes, const std::str
537539
throw ThemeError(409, "THEME_IMPORT_CHANGED", "Theme ZIP changed; preview it again before importing");
538540
const auto files = unpack_import(archive_bytes);
539541
ThemePackageFiles extra;
540-
for (const auto* name : {"session-background.png", "user-message-background.png"})
541-
if (files.count(name)) extra.emplace(name, files.at(name));
542+
for (const auto& [name, bytes] : files)
543+
if (name != "theme.json" && name != "background.png" && name != "thumbnail.png") extra.emplace(name, bytes);
542544
return install_local(json::parse(files.at("theme.json")), files.at("background.png"), files.at("thumbnail.png"), extra);
543545
}
544546

@@ -622,6 +624,14 @@ std::string ThemeStore::image(const std::string& id, const std::string& kind) {
622624
return read_file(file, kMaxPreviewBytes);
623625
}
624626

627+
std::string ThemeStore::sound(const std::string& id, const std::string& name) {
628+
std::lock_guard<std::recursive_mutex> lock(local_state_->mutex);
629+
const auto sounds = theme_sound_files(definition(id));
630+
const auto found = sounds.find(name);
631+
if (found == sounds.end()) throw ThemeError(404, "THEME_NOT_FOUND", "Theme does not declare this sound");
632+
return read_file(installed_directory(id) / found->second, detail::kMaxSoundBytes);
633+
}
634+
625635
std::shared_ptr<ThemeExportJob> ThemeStore::find_export(const std::string& job_id) const {
626636
std::lock_guard<std::mutex> lock(exports_mu_);
627637
const auto it = exports_.find(job_id);
@@ -723,7 +733,7 @@ void ThemeStore::run_export(const std::shared_ptr<ThemeExportJob>& job) {
723733
const auto directory = installed_directory(id);
724734
std::uintmax_t total = 0;
725735
std::vector<std::string> names = {"theme.json"};
726-
for (const auto& entry : theme_image_files(d)) names.push_back(entry.second);
736+
for (const auto& entry : theme_resource_assets(d)) names.push_back(entry.first);
727737
for (const auto& name : names) {
728738
const auto limit = name == "theme.json" ? 32 * 1024 : kMaxPackageBytes;
729739
auto bytes = read_file(checked_path(root_, directory.lexically_relative(root_) / name), limit);
@@ -980,8 +990,8 @@ void ThemeStore::install(json entry) {
980990
try {
981991
const auto old = read_json(destination / "theme.json");
982992
complete = valid_theme_definition(old) && old.at("id") == id && old.at("version") == version;
983-
if (complete) for (const auto& [kind, name] : theme_image_files(old))
984-
if (!matches_file(destination / name, old.at(kind))) complete = false;
993+
if (complete) for (const auto& [name, asset] : theme_resource_assets(old))
994+
if (!matches_file(destination / name, asset)) complete = false;
985995
} catch (...) {}
986996
if (complete) throw ThemeError(409, "THEME_VERSION_CONFLICT", "Theme version already exists with different content");
987997
// Repair only an incomplete installation using the declared, validated

‎src/adapters/themes/theme_store.hpp‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ bool valid_theme_catalog(const nlohmann::json& catalog);
3434
std::string theme_workshop_base(const std::string& update_base);
3535
// Fixed resource names only; optional images must also be declared in the definition.
3636
std::map<std::string, std::string> theme_image_files(const nlohmann::json& definition);
37+
// Optional Ogg Opus sound effects declared under sounds.files: sound name -> sound-<name>.ogg.
38+
std::map<std::string, std::string> theme_sound_files(const nlohmann::json& definition);
39+
// Every declared resource besides theme.json: file name -> its {bytes, sha256} record.
40+
std::map<std::string, nlohmann::json> theme_resource_assets(const nlohmann::json& definition);
3741

3842
struct ThemeTransport {
3943
std::function<upgrade::HttpTextResult(const std::string&)> fetch;
@@ -57,6 +61,7 @@ class ThemeStore {
5761
nlohmann::json definition(const std::string& id) const;
5862
bool installed(const std::string& id) const;
5963
std::string image(const std::string& id, const std::string& kind);
64+
std::string sound(const std::string& id, const std::string& name);
6065
std::string workshop_thumbnail(const std::string& key);
6166
nlohmann::json start(const std::string& id, const nlohmann::json& consent);
6267
nlohmann::json job() const;

0 commit comments

Comments
 (0)