muse_vtuber — DSH 界面交互(形象)
任务书 §muse_vtuber 的实现:在 DSH GUI 里加一个**「形象」页签**,主位是 LIVE2D 形象,侧边栏自上而下是 视频 / 音频 / LIVE2D 表情菜单;输入框里在「模型选择」与「发送」之间多两个按钮(摄像头 / 麦克风); 语音识别与图像识别的事件流直接把结果插话(steer)进当前会话;模型能通过阿里云 TTS 说话; 设置页配阿里云 key、TTS / ASR / 图像识别 / 音色与设备。
- 版本:3.1.0(3.0.0 是按任务书重写为纯 GUI 插件;3.1.0 接通语音链路并修掉表情误报; 旧 2.0.1 是「外部 stella muse_vtuber 节点」的桥,见文末「迁移」)
- 位置:
PluginDSH/muse_vtuber - 依赖:零第三方依赖(只用
node:内置 + 浏览器 API);assets/lib/里是 LIVE2D 运行库(见THIRD-PARTY-NOTICES)
1. 界面结构(对照任务书功能点)
| 功能点 | 落地 |
|---|---|
| 1 新界面「形象」在「对话」「轨迹」之后 | 注册 conversation.view,id:"muse-avatar"、order:20(对话 0、轨迹 10,排在后面) |
| 2 形象主体 + 侧边栏 | 左侧 .mv-main(LIVE2D 舞台 + 气泡),右侧 .mv-side |
| 3 主位 LIVE2D;侧边栏自上而下 视频 / 音频 / 表情菜单 | VideoPanel → AudioPanel → ExpressionPanel |
| 4 输入框「发送」与「模型选择」之间加摄像头/麦克风按钮 | 注册 conversation.input.right(该槽位渲染在模型选择之后、提交按钮之前) |
| 5 摄像头关 → 视频黑屏;开 → 显示捕获画面 | VideoPanel 里 <video> + MediaStream;关闭时黑底一行「摄像头已关闭」 |
| 6 麦克风关 → 音频直线;开 → 显示波形 | AudioPanel 用 AnalyserNode.getByteTimeDomainData 画波形;关闭时画直线 |
| 7 语音识别 / 图像识别事件流直接激活会话(插话);TTS 让模型说话 | 浏览器断句 → POST /vtuber/api/asr → 阿里云识别 → agent.steer();识图同路。TTS 见 vtuber_say / POST /say |
| 8 设置界面(阿里云 TTS/ASR/图像识别/音色) | 注册 settings.section(@deepseek-ai/dsh-client-ui-settings),读写 config.json,key 写 600 凭据文件 |
| 9 背景是「频谱环」,隐藏或没模型时也有东西可看 | SpectrumRing:纯 2D canvas 背景层,环上 96 根竖条;麦克风开着用麦克风频谱、模型说话用 TTS 频谱、都没有就走缓慢呼吸波形。模型隐藏(visible:false)/ 没有可用模型 / 加载失败 / WebGL 上下文丢失时,环继续画,中间写清原因 —— 不再有白屏 |
浏览器侧(lib/client.js)三个注册点共用模块级单例:摄像头/麦克风流、事件流、LIVE2D 舞台。
所以页签切走再切回来不会重新申请权限、也不会掉流;输入框按钮在「对话」页签里点开摄像头,
切到「形象」就能看到画面。
两条稳定性约定(都是踩过坑才写下的):
- 舞台的 DOM 节点恒定存在(隐藏只切
opacity)。早期版本用「有/没有模型就换一个节点」的方式渲染, 任何一次上层重渲染都可能把挂 canvas 的那个节点换掉 → 表现就是「形象忽然不见了」。 - 摄像头/麦克风失败只回退开关,绝不碰舞台;失败原因按
NotFoundError/NotAllowedError/NotReadableError/OverconstrainedError翻成人话(没设备、权限被拒、被别的程序占用、参数不支持)。 - 布局:
.mv-view的高度按「视图顶部到窗口底部」实测钉死(window.innerHeight - top,resize与ResizeObserver时重算),主位因此永远等于可视高度、模型完整显示;可滚动的只有右侧栏 (.mv-side{overflow-y:auto;min-height:0}),提示文字沉在侧边栏最底(.mv-side-footer{margin-top:auto})。 早期版本靠height:100%,在宿主那个高度是 auto 的槽位里解析不出来 → 网格行被侧边栏内容撑高 → 画布比可视区还高,而模型是底部对齐的,所以「只看到下半身、要往上滑」。
2. 落盘(约定1 / 约定3 / 约定7)
$DSH_HOME/mira_config/muse_vtuber/ # DSH_HOME 缺省 ~/.dsh;不写安装目录、不写 cwd
config.json 配置(schemaVersion 1)。**唯一**会自动创建的文件:不存在时按 patch + 默认原子写一份
state.json 界面实况(摄像头/麦克风/模型/表情/可见性,schemaVersion 1)
creds/dashscope.json 阿里云 API key(schemaVersion 1,权限 600,永不回显、不进日志)
写盘一律「临时文件(pid+随机后缀)→ fsync → rename → fsync 目录」(lib/kit.mjs 的 writeJsonAtomic),
断电不会留下半截 JSON。热生效:config.json 的 mtime 变了,下一次读取就重新加载(约定9),
不需要重启;换代码仍然要重启宿主。
3. 配置表(默认值全部在此)
路径:$DSH_HOME/mira_config/muse_vtuber/config.json;设置页保存就写这里。cordis patch 里那一行只是基线。
| 键 | 默认 | 说明 |
|---|---|---|
enabled |
true |
false = 不注册工具与路由 |
live2d.modelsDir |
"" |
留空 = 依次找 mira_config/muse_vtuber/model → mira_config/mira_live2d/model(共用 mira_live2d 那套模型)。手填绝对路径优先 |
live2d.model |
"" |
要显示的模型(目录名或 *.model3.json)。留空 = 不显示模型(只剩频谱环,这是默认);设置页选完保存会立即切换,配置值只作"下次的默认" |
live2d.width / height |
420 / 560 |
初始画布尺寸(CSS 像素),实际会随容器缩放 |
camera.enabled |
true |
关掉后「摄像头」按钮与 vtuber_look 明确报 config/missing |
camera.deviceId |
"" |
留空 = 浏览器默认设备 |
camera.width / height |
640 / 480 |
getUserMedia 的 ideal 请求值 |
mic.enabled |
true |
关掉后「麦克风」按钮、vtuber_listen、自动识别都不可用 |
mic.sampleRate |
16000 |
送识别的目标采样率(浏览器侧线性重采样后编码 WAV) |
mic.vad.threshold |
0.02 |
断句电平阈值(RMS),越大越不灵敏 |
mic.vad.silenceMs |
650 |
停顿多久算一句说完 |
mic.vad.minSpeechMs |
320 |
太短的杂音不送识别 |
mic.vad.maxMs |
15000 |
一句最长时长,超了强制切段 |
tts.enabled |
true |
关掉后 vtuber_say 报 config/missing |
tts.provider |
aliyun |
aliyun(默认,实测可用)= POST {base}/services/audio/tts/SpeechSynthesizer,body {model, input:{text, voice, format:'wav', sample_rate}} → 拿 output.audio.url 再下载;openai = POST {base}/audio/speech 直接回音频字节 |
tts.base |
https://token-plan.maas.qianwenaiapi.com/api/v1 |
原生口用 …/api/v1;provider=openai 时改成 …/compatible-mode/v1(或 https://dashscope.aliyuncs.com/…) |
tts.model |
qwen-audio-3.0-tts-plus |
同族还有 qwen-audio-3.1-tts-plus/-flash、qwen-audio-3.0-tts-flash |
tts.voice |
longanhuan_v3.6 |
Qwen-Audio-TTS 的音色是 longan* 系列(不是 CosyVoice 的 Cherry/Serena),清单见官方「Qwen-Audio-TTS 音色列表」 |
tts.maxChars |
600 |
单次最长字数(按码点截断,不劈 emoji),截断会回报 truncated:true |
tts.timeoutMs |
30000 |
原生口含一次 URL 下载 |
asr.enabled |
true |
|
asr.provider |
aliyun |
aliyun(默认,实测可用)= POST {base}/services/aigc/multimodal-generation/generation,content 是 [{type:'input_audio', input_audio:{data:'data:audio/wav;base64,…'}}] + parameters:{format, sample_rate, language_hints}(format 必填);openai = POST {base}/audio/transcriptions,multipart 传 WAV。base 必须跟着 provider 选前缀 |
asr.base |
https://token-plan.maas.qianwenaiapi.com/api/v1 |
与 tts.base 同一个 host 的 api/v1(provider=openai 时改成 …/compatible-mode/v1) |
asr.model |
qwen-audio-3.0-asr-flash |
原生口吃 data:audio/wav;base64,...;兼容口吃 multipart 的 WAV |
asr.language |
zh |
原生口作为 language_hints:[lang]、兼容口作为 multipart 字段 |
vision.enabled |
true |
|
vision.provider |
dsh |
dsh(默认)= 把这一帧作为图片放进工具结果,交给当前会话的模型自己看(走宿主附件库,和 read_image 同一条路;不调第三方、不额外花钱);aliyun = 服务端调视觉模型返回一段文字。autoIntervalMs > 0 的自动识图始终走 aliyun(那条路不经过模型) |
vision.base |
https://token-plan.maas.qianwenaiapi.com/api/v1 |
先试原生多模态口,遇 Model not exist/404 自动切到 {host}/compatible-mode/v1/chat/completions(+image_url),并记住结果 |
vision.model |
qwen3.8-flash |
这个网关没有 VL 专模,qwen3.8-flash 能收图(实测 200);换官方 DashScope 用 qwen-vl-max-latest |
vision.prompt |
一句中文描述 | 默认提示词(vtuber_look 可覆盖) |
vision.autoIntervalMs |
0 |
>0 = 摄像头开着时按这个间隔自动抓帧识别并(按 events)插话;0 = 只用工具/手动 |
events.enabled |
true |
识别结果自动进会话 |
events.steer |
true |
true = 插话(steer);false = 只显示在侧边栏 |
events.voiceTemplate |
[感知·语音] {text} |
支持 {kind} {text} {time} {date} {sessionId} |
events.visionTemplate |
[感知·画面] {text} |
同上 |
events.minIntervalMs |
5000 |
两条之间最小间隔 |
events.maxPerMin |
6 |
每分钟上限 |
events.cooldownKeyMs |
30000 |
同一句话的冷却 |
events.maxChars |
400 |
单条事件正文字数上限 |
ui.showHint |
true |
首次进入给操作提示 |
legacyNode.enabled |
false |
旧的外部节点桥(见「迁移」);开的时候把旧配置整体放进 legacyNode.config |
约定2(显式优先):阿里云 API key 是唯一必填项,没有默认、不自动探测。没配时工具返回
{"ok":false,"error":{"code":"creds/missing","message":"…","hint":"在形象界面的「设置 · 形象」里填 key…"}}, 而不是静默留白或改用别的供应商。
4. 工具(10 个,返回 JSON 文本;vtuber_look 例外,见下)
| 工具 | 干什么 | 失败长什么样 |
|---|---|---|
vtuber_status |
只读:状态(含 visible/expression/toggles/motion)、模型清单与缺失资源、TTS/ASR/识图就绪(含 provider/attachmentsReady)、连着的页面数、事件流与计数。排障第一站 |
永不失败(配置坏了也会把 configError 报回来) |
vtuber_say |
合成并让形象说话(浏览器播音频 + 口型跟音量动) | 没页面连着 → delivery/failed(「语音没有地方播」) |
vtuber_expression |
表情三件套:name = 互斥表情(空串恢复默认);toggles = 整体替换叠加开关集合;switch + on = 单个开关。名字必须来自 models.expressions |
名字不在清单里 → expression/unknown + 可用清单,且状态原样不动;当前没显示模型 → model/none |
vtuber_motion |
播放模型动作组(models.motions,如 Idle);不传 = 列清单 |
名字不在清单里 → motion/unknown + 清单;当前没显示模型 → model/none |
vtuber_visible |
显示/隐藏形象主体(隐藏后频谱环仍在;不传 on = 切换) |
— |
vtuber_model |
切模型;不传 = 列清单;clear:true = 不显示模型(只留频谱环) |
找不到 → config/missing + 可用清单 |
vtuber_camera / vtuber_mic |
开/关摄像头、麦克风(不传 on = 切换) |
— |
vtuber_look |
让浏览器抓一帧 → 默认把这一帧交给当前会话的模型自己看(vision.provider=dsh,走附件库,render 返回 [说明文字, image 块],和 read_image 同一约定);provider=aliyun 时改为服务端 qwen-vl 返回文字。steer:true 只在 aliyun 模式有意义 |
摄像头没开 → camera/closed;“没页面” → delivery/failed;宿主没有附件服务 → platform/unsupported;模型不吃图片 → vision/unsupported |
vtuber_listen |
让浏览器录 N 秒 → qwen3-asr 识别 → 返回文字;steer:true 顺手插话 |
麦克风没开 → mic/closed |
vtuber_look / vtuber_listen 需要有页面在场(这两个动作用的是用户自己的摄像头/麦克风),
没有页面时明确报 delivery/failed 并说清“打开形象页签”,不假装成功。
vtuber_expression的校验顺序是「先查清单、再落状态」:模型里没有的名字不会写进state.json。 早期版本先写后查,未知名字会一直留在状态里,客户端每次应用都失败一次 —— 本地实测还会把 pixi 的表达式管理器写坏,表现是「模型忽然不渲染了」。
5. 宿主 HTTP 面(/vtuber/api/*,全部同源校验)
浏览器侧只用这些接口;宿主侧不碰 DOM。
| 方法与路径 | 作用 |
|---|---|
GET /vtuber/api/bootstrap |
一次拿全:配置(脱敏)、状态、模型清单、能力/凭据就绪 |
GET /vtuber/api/capabilities?model= |
表情/动作清单 + 缺失资源 |
GET /vtuber/api/events |
SSE:hello / state / say / command |
POST /vtuber/api/state |
界面状态(摄像头/麦克风/模型/表情)→ 落 state.json → 广播 |
POST /vtuber/api/asr |
{ base64, mime, sessionId, auto } → 识别 →(按配置)steer。auto:true(界面自动事件流)受 events 限流约束;auto:false 默认只回文本,要投递再加 steer:true |
POST /vtuber/api/vision |
{ image: dataURL, prompt?, sessionId, auto } → 识图 →(同上规则)steer |
POST /vtuber/api/say |
{ text, voice? } → 合成 → 广播 say |
GET /vtuber/api/audio/<id> |
TTS 音频(内存缓存 32 条 / 10 分钟) |
POST /vtuber/api/result |
浏览器回执(look / listen 的结果,按 requestId 配对) |
GET/POST /vtuber/api/settings |
设置页读写;GET 永不回 key,只回 keyConfigured |
GET /vtuber/api/health |
存活探针(客户端数、音频缓存、配置错误) |
GET /vtuber/assets/*、/vtuber/models/* |
运行库与模型静态文件 |
同源守卫:Sec-Fetch-Site: cross-site 或 Origin 与 Host 不同源一律 403 ——
这些接口能改状态、烧 TTS 额度、往会话里插话,不能让任意网页盲调。
6. 事件流怎么“直接激活会话”
语音(默认开,只要麦克风打开)
麦克风 → AudioContext(16k) → ①波形用的 AnalyserNode
→ ②ScriptProcessor 取 PCM → 本地 VAD 断句
VAD:RMS > mic.vad.threshold(0.02) 判说话;保留最近 300ms 预录(不吞首字);
静音 mic.vad.silenceMs(650ms) 或累计 mic.vad.maxMs(15s) → 切一段;
短于 mic.vad.minSpeechMs(320ms) → 不送(当杂音丢掉)
→ 重采样到 mic.sampleRate(16000) → 16bit 单声道 WAV → base64(一次一句,不是整段流)
→ POST /vtuber/api/asr { base64, mime:'audio/wav', sessionId, auto:true, durationMs, sampleRate }
→ 宿主调 asr(默认 aliyun 原生口:POST {asr.base}/services/aigc/multimodal-generation/generation,
content [{type:'input_audio', input_audio:{data:'data:audio/wav;base64,…'}}] +
parameters {format:'wav', sample_rate, language_hints:['zh']} + 头 X-DashScope-SSE:disable;
`asr.provider=openai` 时改走 {asr.base}/audio/transcriptions,multipart 传 WAV)
→ 文本截到 events.maxChars(400) → 套 events.voiceTemplate → 限流 → agent.steer(...)
图像
vtuber_look(默认 vision.provider="dsh"):
抓帧 → dataURL → attachments.saveImage() → 工具结果里带 image 字段
→ render 返回 [<source>/<question>/<content> 说明文字, {type:'image', attachment}]
→ **当前会话的模型自己看这张图**(和内置 read_image 完全同一条路,不调第三方)
前置检查:llm.resolveModelInfo(provider, model) 的 inputModalities 必须含 image,
否则直接报 vision/unsupported(换多模态模型,或把 provider 改成 aliyun)
vtuber_look(vision.provider="aliyun"):
抓帧 → POST 服务端 → 阿里云 qwen-vl → 一段文字回来(steer:true 才插话)
自动识图(vision.autoIntervalMs > 0,默认 0=关):
每 N ms 抓一帧 → POST /vtuber/api/vision → 阿里云 qwen-vl → 套 events.visionTemplate → 限流 → steer
(这条路不经过模型,所以即使 provider=dsh 也仍然需要阿里云 key)
限流(语音和图像共用一套闸):events.minIntervalMs(5000) / events.maxPerMin(6) /
events.cooldownKeyMs(30000,同一句话冷却)。命中不是静默丢弃,侧边栏那行会写
(min-interval,未插话),vtuber_status.counters 里有 percept.* 计数。
其它要点
- 为什么是 steer 不是 followup:followup 要等这一整轮结束才轮到它 —— 那就不叫“它听到了”。
sessionId取当前页签的会话;没有 sessionId 就不投递并报config/missing(不猜用哪个会话)。- 消息
source.kind必须是生产者自己的名字(会话格式 v4 的准入规则),这里是muse_vtuber。 - 抓帧不依赖当前页签:
Capture自己挂一个隐藏<video>抓帧,所以在「对话」页签里开着摄像头, 自动识图 /vtuber_look也照样能抓到(早期版本用页面里那个预览元素,切走页签就抓不到)。 - 断句全在浏览器本地做(不上传音频流),阿里云只收到「一句一段的 WAV」—— 省流量也省额度。
两者都需要 API key,没配就报
creds/missing。
7. 凭据纪律(约定8)
- key 只在
creds/dashscope.json(600);配置里没有apiKey这个键,也不接受从环境变量读。 - 设置页 POST 只回
keyConfigured: true/false,输入框留空保存 = 不改。 - 报错文案统一过
redact():把 key 本体和Bearer xxx都抹成***,再截断 400 字。 - 权限不是 600 时打一行 warn(只报路径与权限位,不碰内容)。
8. 模型与资源缺失(功能点9)
models.js 会解析 model3.json 的 FileReferences,逐个检查 Moc / Textures / Physics / Pose /
Expressions[].File / Motions[].File,缺什么就说清是哪个文件、属于什么类型:
vtuber_status → models.missing: [{ file: "SylviaMuse.4096/texture_02.png", why: "贴图" }]
侧边栏表情菜单 → 缺资源:…(放进 <模型目录>/<模型名>/)
宿主日志 → [muse_vtuber] 模型资源缺失 3 项(模型:SylviaMuse):…
表情分两类(与 mira_live2d 同一套约定):开关-* / 动作-* 可同时打开(叠加层),
其余(表情-*)互斥。叠加是把“当前互斥表情 + 所有打开的开关”合成一个 ExpressionMotion,
经 expressionManager 的官方队列应用 —— 每帧从干净基线重算,不会逐帧累积漂移。
9. 迁移:旧 2.0.1(外部节点桥)→ 3.0.0 / 3.1.0
旧版是「DSH ↔ stella muse_vtuber 节点」的桥:MCP 拿工具、/dsh 出口拿事件,工具名 muse_*(14 个)。
3.0.0 按任务书重写成纯 GUI 插件,节点桥不是任务书范围。处理:
- 旧实现原样保留在
lib/legacy/node-bridge.js,默认不加载; - 要恢复旧能力:把旧
cordis.patch.yml那一行的node/mcp/perceive/speak/live/target/sourceShape/debug整体搬进config.legacyNode.config,再把legacyNode.enabled设为true→ 它会在形象页签照常加载的同时注册回muse_*工具(失败只 warn,不影响形象页签); - 旧的
_*.mjs探针脚本(_probe_*/_tune_*/_watch_*/_test_*)、selftest-source-shape.mjs(只测旧入口的来源形状探测)与locale/*.json(旧工具展示名) 随旧入口一起删除;旧实现的全部内容在lib/legacy/node-bridge.js里,SVN 历史可查。
没有需要“删了重配”的项:新配置里所有旧键都不再读取,插件按默认值起,设置页里点几下就配好。
10. 已知取舍(写在这里而不藏在代码里)
- 断句在浏览器侧做(RMS + 静音时长 + 300ms 预录),不引云端实时 ASR。好处是零依赖、延迟低;
代价是嘈杂环境需要调
mic.vad.threshold。 - TTS 音频只放内存(32 条 / 10 分钟,超出丢最旧),不落盘 —— 避免在
mira_config里堆音频文件。 - 识别与识图都吃 base64,单请求体上限 16 MiB;超长音频会被 VAD 切成多句。
vtuber_look/vtuber_listen必须有页面在场(要用户的摄像头/麦克风),没页面就明确失败。- 自动识图默认关(
vision.autoIntervalMs: 0):定时抓帧会持续消耗额度,要开就显式开。 - 模型目录默认复用 mira_live2d 的那套(存在才用),因此本插件不需要再复制一份几个 GB 的模型。
- LIVE2D 运行库(pixi / cubism4 / live2dcubismcore)是复制进来的,不是跨插件 import:
插件以
link:装入时跨包相对 import 会指向包外。版权见assets/lib/THIRD-PARTY-NOTICES.md。 - 表情叠加用
expressionManager._setExpression()(下划线接口):这是 mira_live2d 已在生产用的路径, 但上游改名就会失效 —— 失效时的表现是“开关点了没反应”,不会崩。
11. 验收
node --check lib\client.js # 浏览器模块只做语法自检
node test\smoke.mjs # → ==== ALL PASS(86 项断言)==== 退出 0
node test\http-e2e.mjs # → ==== ALL PASS(12 项端到端断言)==== 退出 0(真起 http + 真 SSE)
node ..\重构插件\tools\check-zero-dep.mjs
node ..\重构插件\tools\check-kit.mjs
手工验收(装好后):
| # | 步骤 | 期望 |
|---|---|---|
| 1 | 打开会议话界面 | 顶部页签顺序:对话、轨迹、形象 |
| 2 | 点「形象」 | 主位出现 LIVE2D;右侧自上而下 视频(黑屏+「摄像头已关闭」)/ 音频(一条直线)/ 表情 |
| 3 | 点输入框里的「摄像头」 | 侧边栏视频区出现画面;再点一次回到黑屏 |
| 4 | 点「麦克风」,说一句话 | 音频区波形随声音动;停顿后侧边栏出现「听到:…(已插话)」;会话里多一条 [感知·语音] 用户消息(正在跑的话是插到最近一步) |
| 5 | 对模型说「你看看我现在什么样」 | 模型调 vtuber_look → 返回画面描述(摄像头要开着) |
| 6 | 设置 · 形象 → 填阿里云 key → 保存 | 提示「API key 已写入 600 凭据文件」;vtuber_status 的 credentials.configured=true |
| 7 | 让模型说句话(vtuber_say) |
形象出声,口型跟音量动,气泡显示文字 |
| 8 | 把 config.json 的 schemaVersion 改成 99 再重载 |
工具报 config/schema(含迁移提示),不静默清空配置 |
12. 开发约定
- 只依赖
node:内置与浏览器 API;lib/kit.mjs是重构插件/kit/kit.mjs的逐字节副本(check-kit.mjs校验)。 - 所有工具返回
{ ok:true, … }或{ ok:false, error:{ code, message, hint } }。 - 单实例保护:
apply里claimInstance('muse_vtuber'),第二个实例直接报plugin/instance。 - 退场顺序:摘工具 → 卸路由 → 停 SSE 与等待中的请求 → 落状态 → 放认领(
ctx.effect与装配失败走同一条路径)。 - 关键状态变更打一行 info,错误打 warn 并计数(
vtuber_status.counters可见)。
13. 安装记录(示例:Windows,profile = web)
本节是通用安装要点,不含任何具体机器路径;下文路径一律用占位符替换:
<PLUGIN>= 插件源码目录,%DSH_HOME%= 你的 DSH 数据目录(缺省C:\Users\<you>\.dsh)。
| 项 | 值 |
|---|---|
| profile 依赖 | 包里 name 必须是 muse_vtuber,profile 里用 "muse_vtuber": "link:<PLUGIN>/muse_vtuber" |
| 实际入口 | <PLUGIN>/muse_vtuber/lib/index.js |
| patch 条目 | include:muse_vtuber(id/name 都是 muse_vtuber),config 里只有 enabled + live2d.modelsDir 等少量键 |
| 模型目录 | 建议放在 %DSH_HOME%\mira_config\muse_vtuber\live2d(也可复用一个已有的模型目录,如 mira_live2d 的 model/) |
| 配置/状态/凭据 | %DSH_HOME%\mira_config\muse_vtuber\{config.json,state.json,creds\dashscope.json} |
升级前建议先备份 %DSH_HOME%\profiles\web\ 下的 cordis.patch.yml、package.json、pnpm-lock.yaml;
回滚就是把这三份拷回去。
两个「改代码不生效」的坑(都实测过)
- 宿主半:Loader 把模块缓存在「解析后的真实路径」上,
plugin_manager set_plugin关开只会重放 缓存里的旧模块;dsh-hmr又明确跳过node_modules/下的路径。换代码必须重启dsh web。 (中途试过用别名 specifier 绕开缓存:宿主半确实能换新代码,但见下一条,前端半会直接消失。) - 前端半:
dsh-client-modules解析dsh.client时,从模块真实路径往上找 package.json, 且要求name严格等于 loader 条目的 specifier(nearestPackage→name === expectedPackageName)。 别名装了muse_vtuber_ui、包里name却是muse_vtuber→ 前端半被静默跳过 (现象:页签没有、设置里也没有「形象」,控制台甚至不报错)。 所以安装名必须与包名一致;这一版最终就是规范名muse_vtuber。
已知缺口
- 阿里云 API key 没配时:
vtuber_status.credentials.configured=false,vtuber_say/ 语音识别 / 图像识别都会报creds/missing。到「设置 · 形象」里填一次即可(写 600 凭据文件)。 - 卸载/回滚后要重启
dsh web才生效(同「坑 1」)。
14. 多设备 / 多页签同时打开一个会话会怎样
接入方式:每个页面一条 SSE(GET /vtuber/api/events),宿主只维护一份状态和一个语义广播。
vtuber_status.events.clients 就是当前连着的页面数。
| 行为 | 现在的语义 | 备注 |
|---|---|---|
| 模型 / 表情 / 可见性 | 全局一份,任何一端改,所有端同步 | 这是想要的:形象是"一个" |
| 摄像头 / 麦克风开关 | 也是全局一份(写 state.json),但只有发起改动的那个页面会去开自己的硬件(POST /state 带 clientId,宿主把 by 记成 client:<id>,其它页面只同步开关外观) |
这条需要宿主重启后才生效;在此之前旧逻辑是「谁看到广播谁就去开」,两台设备会互相抢同一颗摄像头 |
语音合成(vtuber_say / 模型说话) |
所有端同时出声(音频 URL 广播给所有页面) | 只想一端出声就在那端静音或关掉页面 |
| 语音识别 / 自动识图 | 每个页面各自采集、各自带自己的 sessionId 往同一会话插话;限流是全局的(minIntervalMs / maxPerMin / 同句冷却) |
两台设备同时说话会互相压制,且会话里会出现两条独立事件 |
vtuber_look / vtuber_listen(命令往返) |
广播给所有页面,先回执的那一端生效;后回执的 requestId 已失效(宿主返回 command/unknown,忽略即可) |
所以"从哪台设备抓画面"不确定 —— 要确定性就得按 clientId 定向,见下 |
| 状态文件 | 一份,最后写入者胜 | 关掉一个页面不会回滚另一端的状态 |
想做到「每台设备独立摄像头/麦克风」(可选下一步):把 state.devices 做成
{ [clientId]: { cameraOn, micOn } },look/listen 增加 clientId 参数只发给指定页面,
开关按钮按自己的 clientId 落自己的那一格。工作量约半天,state.json 要升 schemaVersion 2(带迁移)。
现在没做,因为默认语义("一个形象、一份状态")更符合看板娘的直觉。
No comments yet. Be the first to write one.