dsh-music-studio
jypjypjypjyp/dsh-music-studio
在对话里作曲:模型按内置作曲规范写出乐谱 JSON,play_score 工具校验后渲染成对话内的卡片,含卷帘图、Web Audio 实时播放与 WAV/JSON 导出。
安装
dsh plugin --profile web add github:jypjypjypjyp/dsh-music-studioREADME
@jypjypjypjyp/dsh-music-studio
在对话里作曲:模型按内置作曲规范写乐谱,工具校验后把乐谱交给这段回复末尾的播放卡;卡片可以拎成浮窗,切会话也不断声,浮窗能拖着走、也能从右下角改大小。
短曲(≤16 小节)一次成谱:写完整乐谱 JSON → play_score。
长曲(>16 小节)分格写,像改代码一样:score_new 建骨架(调性 / 和弦循环 / 乐器表 / 段表)→ score_edit 一次只写一格(某段 × 某乐器,拍位相对本段起算)→ score_read 随时看进度 → score_export 合成交给卡片(写齐第 1 段时导出就是样张)。
每格写完立刻体检:不在调内的音、钳位/丢弃的音,以及与已写某格逐音一致(复读开头)会被直接退回重写。
卡片只负责呈现与播放(卷帘图 + 播放条 + 导出),作曲是模型在对话里干的活。一个会话里可以有好几首曲子,浮窗也能同时开好几个 —— 它们是各自独立的播放器(按工具调用 id 分格),一首的暂停不会碰到另一首。浮窗底色是实心的:皮肤(如 harbor)会把 DSH 的层色做成半透明玻璃,浮窗飘在正文上会透字,所以它用「不透明静态色板 + 皮肤层色」两张皮叠出实心,同时保住皮肤给的色调。大小从右下角把手改,改的是这一格浮窗自己的尺寸。卷帘图左边只标音高,哪个颜色是哪条声道由音色对照给出(色块取色与卷帘图同一张表、同一个顺序,所以这张对照不会说假话);浮窗把读数(BPM / 拍号 / 时长)与这条对照一起收在标题行(对照紧跟读数、每项是与读数同款的标签,字号一致),关闭按钮钉在右上角。UI 全部由 @deepseek-ai/dsh-client-ui-primitives 的现成控件搭成。
界面
卡片长在每条回复的末尾、动作行(复制 / 点赞 / 点踩)之前 —— 一轮里写了几首就长几张:
点卡片上的「浮窗」把它拎出来(也可以从卡片再收回去):浮窗可以拖着走、右下角把手改大小、点 ✕ 收起,切会话或切页都不中断播放。
本项目是主仓库里那份单文件音景网页(index.html)的 DSH 插件形态。音频引擎(2012 行纯逻辑、无 DOM 依赖)在主仓库构建时从那个单文件网页抽取;本仓库带着已生成的引擎,因此可以脱离主仓库独立构建与安装。
怎么装
dsh plugin --profile web add github:jypjypjypjyp/dsh-music-studio
dsh plugin --profile web remove @jypjypjypjyp/dsh-music-studio # 卸载
# 装完必须重启服务:插件行是启动时装配进 cordis 树的
dsh web
本地开发则把上面的 github:... 换成插件包路径(dsh plugin --profile web add ./,必须在插件包根目录执行)。
更新到仓库最新提交(实测结论):从 GitHub 装的依赖会被钉到具体提交,pnpm update <包名>(含 --latest)都挪不动它,必须卸了重装:
dsh plugin --profile web remove @jypjypjypjyp/dsh-music-studio
dsh plugin --profile web add github:jypjypjypjyp/dsh-music-studio
改客户端代码不必重启:pnpm watch(tsdown --watch)重写 lib/client.js 后,@deepseek-ai/dsh-client-hmr 会把新包热替换进运行中的页面。
怎么构建
pnpm install
pnpm build # = (有引擎真源就抽取)+ tsdown(宿主半 ESM + 客户端半 CJS 工厂)
pnpm test # = 构建 + 类型检查 + 9 条判据
引擎从哪来
src/engine.js 是生成物,不要直接编辑。它的唯一真源是主仓库的 index.html:引擎写在该文件的 /* ==ENGINE-START== */ 与 /* ==ENGINE-END== */ 之间(2012 行纯逻辑,不含任何 DOM 引用),由 scripts/extract-engine.mjs 抽出并在末尾追加导出语句与一份手写 .d.ts。
- 在主仓库里:
pnpm build每次重新抽取,test/verify-engine-copy.mjs守着「生成物与 index.html 的引擎块逐字节相同」。这条成立时,跑在index.html上的 141 项引擎验收就自动覆盖了插件里的引擎。 - 在本仓库(独立发布版)里:没有
index.html,抽取脚本会大声跳过并继续使用已提交的src/engine.js,判据 1 打印SKIP而不是假装通过。改引擎请回主仓库改。
怎么装 / 卸
见上方「怎么装」。本地开发与从 GitHub 安装共用同一条命令,只换依赖来源。
结构
| 文件 | 职责 |
|---|---|
src/engine.js / engine.d.ts | 生成物:index.html 的引擎 + 导出与类型 |
src/shared/score-info.ts | 乐谱读数:countEvents / fmtDur / usedTimbres / isRenderable |
src/index.ts | 宿主半:常驻提示词段 + 五个乐谱工具 + music-studio skill |
src/tool.ts | 工具定义:play_score(短曲一次成谱)+ score_new / score_read / score_edit / score_export(长篇工作流);校验 / 钳位 / 把规范化乐谱投影进结果 meta |
SKILL.md | 作曲手艺:乐谱格式、32 音色、拍号、律动、音乐性、修订流程 |
src/check.ts | 长篇判据:调性解析、调外音、重复指纹、逐格钳位 |
src/draft.ts | 长篇草稿:骨架校验、进度视图、落格、合成与裁剪 |
src/client/index.tsx | 客户端半:注册两个座位 —— conversation.chat.turnTail(卡片,消息末尾)与 shell.overlay(浮窗层,root 作用域) |
src/client/TurnTail.tsx | 从这一轮的 tool-call 行里认出 play_score / score_export 的乐谱(读结果 meta),一轮几首就渲染几张卡 |
src/client/Card.tsx | 卡片:标题行 + 卷帘图 + 播放条 + 导出 + 浮窗开关 |
src/client/FloatLayer.tsx | 浮窗层:可拖动、可缩放(右下角把手)、可关闭的小播放窗,一层可以有好几个 |
src/client/tracks.ts | 播放实例注册表:按工具调用 id 分格,卡片与浮窗共用同一个播放器;无人使用时才销毁 |
src/client/RollCanvas.tsx | 卷帘图 + 播放头(卡片与浮窗共用一支画笔,免得两处画歪) |
src/client/Timbres.tsx | 声道 ↔ 音色对照(色块 + 音色名):取色与卷帘图同表同序,卡片与浮窗共用 |
src/client/roll.ts | 卷帘图与播放头自绘(搬自 index.html 的 drawRoll) |
src/client/audio.ts | 播放排程(搬自 index.html 的滚动排程,含实测常数) |
src/client/theme.ts | 明暗判定(量 --dsw-alias-bg-base 亮度)+ 音轨配色 |
src/client/card.module.css | 只放布局;视觉值一律取 --dsw-* 令牌 |
样式约束(硬要求,有判据守着)
- 控件一律复用
@deepseek-ai/dsh-client-ui-primitives,不重造。用到的导出名必须在本机运行时的名单里(当前 282 个,test/verify-card-surface.mjs守这条,防版本漂移变成undefined)。 - 自写 CSS 只允许布局属性;一切视觉值必须是
--dsw-*令牌,不许出现字面颜色/字号/阴影。 - 唯一豁免是
theme.ts里那张音轨配色表:它来自 index.html,是用 CIE Lab ΔE76 算过的(两两最小 25.4 / 25.1、相对底色最低对比度 4.65:1 / 3.70:1,见tools/audit-palette.py)。DSH 只有state-*那几个语义色,没有可互相区分的分类色,所以卷帘图的轨道色必须自带。
支持的 DSH 版本
0.2.0-rc.2(当前运行时)与 0.1.5 用同一份构建同时支持。两版的差异集中在客户端 primitives 的
导出名上,最容易踩的是图标:0.1.5 叫 IconPlayOutline16,0.1.7 起把 16 后缀删了、
改成 IconPlayOutline / …Regular / …Medium / …Artwork(0.2.0-rc.2 沿用后者)。
取错名字不会报错,只会拿到 undefined;React 拿到 undefined 的组件类型会在渲染时抛错,
整张卡片当场消失(0.1.7 上播放卡「不见了」就是这么来的)。
槽位口径同样要看版本:conversation.chat.turnTail 在 0.1.5 是 chain、在 0.2.0 是 list,
两者的注册写法不同 —— 所以 devDependencies 里的 @deepseek-ai/dsh-client-* 钉运行时那一版(现为 0.2.0-rc.2)。
规则只有一条:静态导入的名字必须两版都有;只在一版存在的,只能走 src/client/icons.ts 的 pick 表。
pick 按存在性取名(新版写法优先、旧版兜底),兜底顺序在两版上都不会取空。
怎么保证不退化:
| 手段 | 守什么 |
|---|---|
test/verify-card-surface.mjs 5a | 任何 primitives 静态导入的名字必须在「新版 + 0.1.5」两份名单里都在 |
test/verify-card-surface.mjs 5a-2 | pick 表每一组都要在新旧两份名单里各命中一个 |
test/verify-icons-resolve.mjs | 拿两份真实导出名单各造一个桩、跑真实源码:新版世界必须解析到 …Regular、0.1.5 世界必须解析到 …16、两版都没有时必须退化成空组件(不能是 undefined) |
| 两份名单 | docs/dsh-plugin-research/platform-module-exports.json:primitives = 当前运行时(0.2.0-rc.2)、primitivesLegacy = 0.1.5;刷新方法写在 verify-card-surface.mjs 头注释里 |
端到端实测(真实浏览器)已验证:写一段曲子 → 卡片渲染出卷帘图,播放 / 停止 / 循环 / 进度 / 音量 / 下载 WAV / 导出乐谱 JSON 全部可用。
判据一览
| 判据 | 脚本 | 守什么 |
|---|---|---|
| 1 | test/verify-engine-copy.mjs | 引擎与 index.html 逐字节一致(主仓库;独立发布版打印 SKIP) |
| 2 | ../verify-engine.mjs | 现有 141 项引擎验收仍全绿 |
| 3 | test/verify-score-contract.mjs | 乐谱校验三分支:合法通过 / 越界钳位且明说 / 结构错退回 |
| 4 | test/verify-skill-timbres.mjs | SKILL.md 的音色、拍号、律动清单与引擎一致 |
| 5 | test/verify-card-surface.mjs | 只用两版都有的组件;无跨插件值导入;无字面视觉值;图标兼容层两版都命中;两个座位(消息末尾 + 浮窗层)都认领、且不再认领 tool.call.toolview;浮窗底色实心(静态色板垫底)、浮窗大小可调(把手 + resizeTo);声道↔音色对照与卷帘图同序、浮窗的读数与对照同在标题行、关闭按钮在其右端 |
| 6 | test/verify-theme-color.mjs | 明暗判定的颜色解析(DSH 令牌是 8 位带 alpha 的十六进制,只认 3/6 位会永远走兜底) |
| 7 | test/verify-host-load.mjs | 宿主半冷加载:apply 不抛、注册物形状正确、presenter 是纯函数 |
| 8 | test/verify-groove.mjs | 律动真的生效:乐谱写 swing8_2,半拍上的音必须被挪;名字写错必须明说不许静默忽略 |
| 9 | test/verify-icons-resolve.mjs | 图标兼容层在当前运行时 / 0.1.5 两份真实名单上都解析得出,取不到时退化成空组件 |
| 10 | pnpm exec tsc --noEmit | 对着真实的 DSH 类型校验(工具契约、槽位、组件 props) |