dsh-whale-live 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.2.1 — 2026-11-11
4
+
5
+ ### Fixed
6
+ - **Mouth sync failed on non-ASCII data paths.** `miniaudio` could not decode the synthesized mp3 when the audio folder contained Chinese characters (`failed to decode file, -7`), so speech fell back to silent, mouth-less playback. The mp3 is now copied to an ASCII temp path before decoding.
7
+ - `whale_say` now surfaces the mouth-sync error instead of quietly omitting the "mouth moved" marker.
8
+
9
+ ### Added
10
+ - `whale_live action=vts`: trigger VTube Studio hotkeys / toggle expressions / set the mouth while live, so the model can act on its own judgement.
11
+
12
+ ### Known issues in 0.2.0
13
+ - Mouth movement during plugin speech could silently fail whenever `dataDir` contained non-ASCII characters.
14
+ - No plugin-side way to trigger expressions or hotkeys during a stream.
package/README.md CHANGED
@@ -74,6 +74,21 @@ The model acts **through** these tools; the plugin never writes to DSH long-term
74
74
  - **Bilibili**: uses the unofficial `ajax/msg` endpoint; it can change or rate-limit, and some rooms return empty. Read-only — the plugin never sends danmaku.
75
75
  - **VTube Studio**: optional. Without a mouth driver directory the plugin degrades to plain audio playback.
76
76
 
77
+ ## Compatibility range (explicit)
78
+
79
+ | Axis | Declared | Notes |
80
+ | --- | --- | --- |
81
+ | OS | `win32` only | `os: ["win32"]`; MCI playback and the app launch helpers are Windows APIs |
82
+ | DSH | `>=0.2.1-rc.2 <0.4.0` | Verified on `0.2.2-rc.1` (desktop profile) |
83
+ | Node | `>=20` | Uses global `fetch`, ESM top-level await |
84
+ | Python | 3.10+ with `edge-tts` | Required only for speech; omit `pythonPath` to run silent |
85
+ | Bilibili | cookie JSON | Uses the unofficial `ajax/msg` endpoint; read-only; may break or rate-limit |
86
+ | VTube Studio | optional | Set `vtsPath` or leave it empty to launch through Steam; `vtubeDir` enables mouth sync |
87
+ | OBS / 直播姬 | optional | Launch helpers only; the plugin never starts the stream |
88
+
89
+ Anything outside these ranges is unsupported. DSH slot/service APIs are the main
90
+ breaking risk across minor releases.
91
+
77
92
  ## Privacy
78
93
 
79
94
  - No telemetry; no accounts; nothing is uploaded.
package/client.js CHANGED
@@ -23,6 +23,12 @@ window.__ModuleLoader__.load({
23
23
  tabConsciousness: '意识',
24
24
  tabDiary: '观察日记',
25
25
  tabCandidates: '入忆候选',
26
+ tabPrepare: '准备',
27
+ prepareHint: '开播前先把设备架好:拉起 VTube Studio / OBS / 直播间页面,只准备,不推流;自检会逐项报告缺什么。',
28
+ stageBtn: '架好设备',
29
+ diagnoseBtn: '自检',
30
+ prepareSummary: '{ok}/{total} 通过 · {failed} 项必须修 · {warned} 项可选',
31
+ stageDone: '设备已拉起(没有自动推流)。',
26
32
  stateLive: '直播中',
27
33
  stateIdle: '未开播',
28
34
  stat: '说话 {says} · 弹幕 {danmaku} · 记 {notes}',
@@ -65,6 +71,12 @@ window.__ModuleLoader__.load({
65
71
  tabConsciousness: 'Consciousness',
66
72
  tabDiary: 'Field diary',
67
73
  tabCandidates: 'Memory inbox',
74
+ tabPrepare: 'Prepare',
75
+ prepareHint: 'Set the gear up before going live: VTube Studio, OBS and the room page. Preparation only — nothing starts streaming.',
76
+ stageBtn: 'Set up',
77
+ diagnoseBtn: 'Self-check',
78
+ prepareSummary: '{ok}/{total} passed · {failed} required · {warned} optional',
79
+ stageDone: 'Gear is up (streaming was not started).',
68
80
  stateLive: 'Live',
69
81
  stateIdle: 'Off air',
70
82
  stat: '{says} spoken · {danmaku} danmaku · {notes} notes',
@@ -196,6 +208,8 @@ window.__ModuleLoader__.load({
196
208
  const [draft, setDraft] = useState('');
197
209
  const [doc, setDoc] = useState({ kind: '', text: '', loading: false, error: '' });
198
210
  const [docTick, setDocTick] = useState(0);
211
+ const [diag, setDiag] = useState(null);
212
+ const [diagLoading, setDiagLoading] = useState(false);
199
213
  const alive = useRef(true);
200
214
 
201
215
  useEffect(() => {
@@ -258,6 +272,22 @@ window.__ModuleLoader__.load({
258
272
  }
259
273
  }, [flash, t]);
260
274
 
275
+ const loadDiag = useCallback(async () => {
276
+ setDiagLoading(true);
277
+ try {
278
+ const data = await getJson('/diagnose');
279
+ if (alive.current) setDiag(data);
280
+ } catch (cause) {
281
+ flash(`${t('actionFailed')}:${cause instanceof Error ? cause.message : String(cause)}`);
282
+ } finally {
283
+ if (alive.current) setDiagLoading(false);
284
+ }
285
+ }, [flash, t]);
286
+
287
+ useEffect(() => {
288
+ if (tab === 'prepare') loadDiag();
289
+ }, [tab, loadDiag]);
290
+
261
291
  const onSay = useCallback(async () => {
262
292
  const text = draft.trim();
263
293
  if (!text) return;
@@ -278,6 +308,7 @@ window.__ModuleLoader__.load({
278
308
 
279
309
  const tabs = [
280
310
  ['live', t('tabLive')],
311
+ ['prepare', t('tabPrepare')],
281
312
  ['consciousness', t('tabConsciousness')],
282
313
  ['diary', t('tabDiary')],
283
314
  ['candidates', t('tabCandidates')],
@@ -397,6 +428,56 @@ window.__ModuleLoader__.load({
397
428
  : h('p', { key: 'none', className: 'dshwl-muted' }, t('noEvents')),
398
429
  ]);
399
430
  }
431
+ } else if (tab === 'prepare') {
432
+ body = h('div', {}, [
433
+ h('p', { key: 'hint', className: 'dshwl-hint' }, t('prepareHint')),
434
+ h('div', { key: 'actions', className: 'dshwl-actions' }, [
435
+ h('button', {
436
+ key: 'stage',
437
+ type: 'button',
438
+ className: 'dshwl-btn primary',
439
+ disabled: busy !== '',
440
+ onClick: async () => {
441
+ const result = await run('stage', 'stage');
442
+ if (result) {
443
+ flash(t('stageDone'));
444
+ loadDiag();
445
+ }
446
+ },
447
+ }, t('stageBtn')),
448
+ h('button', {
449
+ key: 'diag',
450
+ type: 'button',
451
+ className: 'dshwl-btn',
452
+ disabled: diagLoading,
453
+ onClick: loadDiag,
454
+ }, diagLoading ? '…' : t('diagnoseBtn')),
455
+ session ? null : h('button', {
456
+ key: 'live',
457
+ type: 'button',
458
+ className: 'dshwl-btn',
459
+ disabled: busy !== '',
460
+ onClick: () => run('session', 'open'),
461
+ }, t('open')),
462
+ ]),
463
+ diag
464
+ ? h('p', { key: 'sum', className: 'dshwl-muted' }, fill(t('prepareSummary'), diag.summary))
465
+ : (diagLoading ? h('p', { key: 'load', className: 'dshwl-muted' }, t('loading')) : null),
466
+ diag
467
+ ? h('div', { key: 'list', className: 'dshwl-checks' }, diag.checks.map((item) => h('div', {
468
+ key: item.id,
469
+ className: 'dshwl-check',
470
+ }, [
471
+ h('span', {
472
+ key: 'mark',
473
+ className: `dshwl-check-mark ${item.ok ? 'ok' : item.optional ? 'warn' : 'fail'}`,
474
+ }, item.ok ? '✓' : item.optional ? '!' : '✗'),
475
+ h('span', { key: 'label', className: 'dshwl-check-label' }, item.label),
476
+ h('span', { key: 'detail', className: 'dshwl-check-detail' }, item.detail),
477
+ !item.ok && item.hint ? h('span', { key: 'hint', className: 'dshwl-check-hint' }, item.hint) : null,
478
+ ])))
479
+ : null,
480
+ ]);
400
481
  } else if (tab === 'candidates') {
401
482
  body = h('div', {}, [
402
483
  h('p', { key: 'note', className: 'dshwl-hint' }, t('candNote')),
@@ -517,6 +598,15 @@ window.__ModuleLoader__.load({
517
598
  .dshwl-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px}
518
599
  .dshwl-muted{color:var(--dsw-alias-label-secondary,#6b7280);font-size:11px;margin:4px 0}
519
600
  .dshwl-error{border:1px solid var(--dsw-alias-state-error-primary,#d33);color:var(--dsw-alias-state-error-primary,#d33);border-radius:8px;padding:7px 10px;margin:10px 0;font-size:12px;word-break:break-word}
601
+ .dshwl-checks{display:flex;flex-direction:column;gap:6px;margin-top:12px}
602
+ .dshwl-check{display:grid;grid-template-columns:24px 120px 1fr;gap:8px;align-items:baseline;padding:7px 10px;border:1px solid var(--dsw-alias-border-l1,#e5e7eb);border-radius:9px;background:var(--dsw-alias-bg-layer-1,#fff);font-size:12px}
603
+ .dshwl-check-mark{font-weight:700;text-align:center}
604
+ .dshwl-check-mark.ok{color:var(--dsw-alias-state-success-primary,#15803d)}
605
+ .dshwl-check-mark.warn{color:var(--dsw-alias-state-warn-primary,#b45309)}
606
+ .dshwl-check-mark.fail{color:var(--dsw-alias-state-error-primary,#d33)}
607
+ .dshwl-check-label{font-weight:600}
608
+ .dshwl-check-detail{color:var(--dsw-alias-label-secondary,#6b7280);word-break:break-word}
609
+ .dshwl-check-hint{grid-column:2 / 4;color:var(--dsw-alias-state-warn-primary,#b45309);font-size:11px}
520
610
  .dshwl-foot{margin:22px 0 0;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1,#e5e7eb);color:var(--dsw-alias-label-secondary,#6b7280);font-size:11px;line-height:1.6}
521
611
  .dshwl-notice{position:sticky;bottom:0;margin-top:12px;border:1px solid var(--dsw-alias-border-l2,#d1d5db);background:var(--dsw-alias-bg-overlay,#fff);border-radius:8px;padding:7px 10px;font-size:12px;color:var(--dsw-alias-label-primary,#1f2329);box-shadow:0 2px 10px rgba(0,0,0,.06);word-break:break-word}
522
612
  `;
package/cordis.patch.yml CHANGED
@@ -1,12 +1,11 @@
1
1
  # dsh-whale-live bundle patch.
2
2
  #
3
- # One Host row: live sessions, the two persona documents (consciousness / diary),
4
- # Bilibili danmaku, edge-tts speech and the memory-candidate inbox. The Client
5
- # half adds the left-sidebar entry and the panel.
3
+ # One Host row: live sessions, the persona documents, Bilibili danmaku,
4
+ # edge-tts speech, the memory inbox, and the pre-live "set up the gear" helper.
6
5
  #
7
- # Defaults below are placeholders. Fill `roomId`, `credentialFile` and
8
- # `pythonPath` before going live. An empty `dataDir` uses
9
- # `~/.dsh/whale-live`; `vtubeDir` is optional (VTube Studio mouth sync).
6
+ # Defaults are placeholders. Fill `roomId`, `credentialFile`, `pythonPath`
7
+ # before going live. Empty paths disable that integration (VTS / OBS / 直播姬).
8
+ # Compatibility: Windows only, DSH >=0.2.1-rc.2 <0.4.0 (tested 0.2.2-rc.1).
10
9
  - insert:
11
10
  - id: whale-live
12
11
  name: 'dsh-whale-live'
@@ -17,7 +16,12 @@
17
16
  credentialFile: ''
18
17
  pythonPath: ''
19
18
  vtubeDir: ''
19
+ obsPath: ''
20
+ livehimePath: ''
21
+ vtsPath: ''
22
+ stageWaitSec: 90
23
+ openRoomPage: true
20
24
  voice: zh-CN-XiaoyiNeural
21
25
  rate: '+8%'
22
26
  pitch: '+4Hz'
23
- listenWaitSec: 45
27
+ listenWaitSec: 45
package/index.js CHANGED
@@ -15,6 +15,9 @@ import { createStore } from './lib/store.js';
15
15
  import { createDanmaku } from './lib/bili.js';
16
16
  import { createVoice } from './lib/voice.js';
17
17
  import { createAvatar } from './lib/avatar.js';
18
+ import { createVts } from './lib/vts.js';
19
+ import { createStage } from './lib/stage.js';
20
+ import { diagnose } from './lib/diagnostics.js';
18
21
  import { createTools } from './lib/tools.js';
19
22
  import { registerHttp } from './lib/http.js';
20
23
 
@@ -31,6 +34,11 @@ export async function apply(ctx, config = {}) {
31
34
  vtubeDir: '',
32
35
  vtsPort: 8001,
33
36
  vtsSteamApp: '1325860',
37
+ obsPath: '',
38
+ livehimePath: '',
39
+ vtsPath: '',
40
+ stageWaitSec: 90,
41
+ openRoomPage: true,
34
42
  voice: 'zh-CN-XiaoyiNeural',
35
43
  rate: '+8%',
36
44
  pitch: '+4Hz',
@@ -46,14 +54,16 @@ export async function apply(ctx, config = {}) {
46
54
  store.ensure();
47
55
  const danmaku = createDanmaku(settings, ctx.logger);
48
56
  const avatar = createAvatar(settings, ctx.logger);
57
+ const vts = createVts(settings, ctx.logger);
58
+ const stage = createStage(settings, avatar, ctx.logger);
49
59
  const voice = createVoice(settings, store, ctx.logger, avatar);
50
- const tools = createTools({ ctx, store, danmaku, voice, avatar, config: settings });
60
+ const tools = createTools({ ctx, store, danmaku, voice, avatar, vts, stage, diagnose, config: settings });
51
61
 
52
62
  ctx.effect(() => {
53
63
  const disposers = [];
54
64
  try {
55
65
  for (const tool of tools) disposers.push(ctx.tools.register(tool));
56
- const disposeHttp = registerHttp(ctx, { store, danmaku, voice, avatar, config: settings, logger: ctx.logger });
66
+ const disposeHttp = registerHttp(ctx, { store, danmaku, voice, avatar, stage, diagnose, config: settings, logger: ctx.logger });
57
67
  if (disposeHttp) disposers.push(disposeHttp);
58
68
  disposers.push(() => voice.dispose());
59
69
  } catch (error) {
@@ -0,0 +1,98 @@
1
+ /**
2
+ * 装好之后的自检:不装任何东西,只如实报告「少了什么、去哪补」。
3
+ * 软件(OBS / VTube Studio / 直播姬)需要人自己装;Python 依赖给 pip 提示。
4
+ */
5
+ import { execFile } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ function run(file, args, timeoutMs = 12000) {
10
+ return new Promise((resolve) => {
11
+ execFile(file, args, { timeout: timeoutMs, windowsHide: true }, (error, stdout, stderr) => {
12
+ resolve({ ok: !error, out: String(stdout ?? '').trim(), err: String(stderr ?? '').trim(), error: error ? error.message : null });
13
+ });
14
+ });
15
+ }
16
+
17
+ function check(id, label, ok, detail, hint = '', optional = false) {
18
+ return { id, label, ok: Boolean(ok), detail: String(detail ?? ''), hint: String(hint ?? ''), optional };
19
+ }
20
+
21
+ export async function diagnose({ store, danmaku, avatar, stage, config }) {
22
+ const checks = [];
23
+
24
+ try {
25
+ fs.accessSync(store.paths.dataDir, fs.constants.W_OK);
26
+ checks.push(check('data', '数据目录', true, store.paths.dataDir));
27
+ } catch (error) {
28
+ checks.push(check('data', '数据目录', false, String(error?.message ?? error), '把 dataDir 改到可写目录'));
29
+ }
30
+
31
+ for (const [id, label, file] of [
32
+ ['consciousness', '意识卡', store.paths.docs.consciousness],
33
+ ['diary', '观察日记', store.paths.docs.diary],
34
+ ]) {
35
+ try {
36
+ const size = Math.round(fs.statSync(file).size / 1024);
37
+ checks.push(check(id, label, true, `${size} KB`));
38
+ } catch {
39
+ checks.push(check(id, label, false, '文件不存在', '插件启动时会自动种一份'));
40
+ }
41
+ }
42
+
43
+ const pythonPath = String(config.pythonPath || '');
44
+ if (!pythonPath) {
45
+ checks.push(check('python', 'Python / edge-tts', false, '没配 pythonPath', '填一个装了 edge-tts 的 python.exe', true));
46
+ } else if (!fs.existsSync(pythonPath)) {
47
+ checks.push(check('python', 'Python / edge-tts', false, `找不到 ${pythonPath}`, '检查路径'));
48
+ } else {
49
+ const r = await run(pythonPath, ['-c', 'import edge_tts; print(getattr(edge_tts, "__version__", "ok"))']);
50
+ checks.push(check('python', 'Python / edge-tts', r.ok, r.ok ? `edge-tts ${r.out}` : (r.err || r.error || '导入失败'), r.ok ? '' : 'pip install edge-tts'));
51
+ }
52
+
53
+ const credentialFile = String(config.credentialFile || '');
54
+ if (!credentialFile) {
55
+ checks.push(check('cred', 'B站凭据', false, '没配 credentialFile', '导出 cookie JSON 后填路径', true));
56
+ } else if (!fs.existsSync(credentialFile)) {
57
+ checks.push(check('cred', 'B站凭据', false, `找不到 ${credentialFile}`, '检查路径'));
58
+ } else {
59
+ try {
60
+ const data = JSON.parse(fs.readFileSync(credentialFile, 'utf8'));
61
+ const missing = ['sessdata', 'bili_jct', 'buvid3', 'dedeuserid'].filter((key) => !data[key]);
62
+ checks.push(check('cred', 'B站凭据', missing.length === 0, missing.length ? `缺 ${missing.join(',')}` : '字段齐全'));
63
+ } catch {
64
+ checks.push(check('cred', 'B站凭据', false, 'JSON 解析失败', '重新导出 cookie'));
65
+ }
66
+ }
67
+
68
+ const room = await danmaku.roomInfo();
69
+ if (room?.roomId) {
70
+ checks.push(check('room', '直播间', true, `${room.title || '(无标题)'} · ${room.liveStatus ? '直播中' : '未开播'} · 在线 ${room.online ?? '?'}`));
71
+ } else {
72
+ checks.push(check('room', '直播间', false, '房号没配或接口不可达', '检查 roomId / 网络', true));
73
+ }
74
+
75
+ const skin = await avatar.status();
76
+ checks.push(check('vts', 'VTube Studio', skin.up, skin.up ? `8001 端口可用` : '还没开', '面板点「架好设备」会拉起', true));
77
+
78
+ const vtubeDir = String(config.vtubeDir || '');
79
+ if (!vtubeDir) {
80
+ checks.push(check('bridge', 'VTS 嘴型驱动', false, '没配 vtubeDir', '可选:有驱动目录才能动嘴', true));
81
+ } else {
82
+ const missing = ['vts_speak.py', 'vts_client.py'].filter((file) => !fs.existsSync(path.join(vtubeDir, file)));
83
+ checks.push(check('bridge', 'VTS 嘴型驱动', missing.length === 0, missing.length ? `缺 ${missing.join(',')}` : '就绪', '', true));
84
+ }
85
+
86
+ const apps = await stage.appStatus();
87
+ checks.push(check('obs', 'OBS', apps.obs.running || (apps.obs.configured && apps.obs.exists),
88
+ apps.obs.running ? '运行中' : apps.obs.configured ? (apps.obs.exists ? '没运行(架设备时会拉起)' : '路径不存在') : '没配 obsPath',
89
+ '可选;填 obs64.exe 路径', true));
90
+ checks.push(check('livehime', '直播姬', apps.livehime.running || (apps.livehime.configured && apps.livehime.exists),
91
+ apps.livehime.running ? '运行中' : apps.livehime.configured ? (apps.livehime.exists ? '没运行(架设备时会拉起)' : '路径不存在') : '没配 livehimePath',
92
+ '可选;填 livehime.exe 路径', true));
93
+
94
+ const ok = checks.filter((item) => item.ok).length;
95
+ const failed = checks.filter((item) => !item.ok && !item.optional).length;
96
+ const warned = checks.filter((item) => !item.ok && item.optional).length;
97
+ return { checks, summary: { total: checks.length, ok, failed, warned } };
98
+ }
package/lib/http.js CHANGED
@@ -83,7 +83,7 @@ function sendJson(response, status, value) {
83
83
  response.end(JSON.stringify(value));
84
84
  }
85
85
 
86
- export function registerHttp(ctx, { store, danmaku, voice, avatar, config, logger }) {
86
+ export function registerHttp(ctx, { store, danmaku, voice, avatar, stage, diagnose, config, logger }) {
87
87
  async function snapshot() {
88
88
  const state = store.state();
89
89
  const skin = await avatar.status();
@@ -147,6 +147,12 @@ export function registerHttp(ctx, { store, danmaku, voice, avatar, config, logge
147
147
  }
148
148
  return { avatar: await avatar.status() };
149
149
  }
150
+ if (action === 'stage') {
151
+ return { stage: await stage.stage() };
152
+ }
153
+ if (action === 'diagnose') {
154
+ return { diagnose: await diagnose({ store, danmaku, avatar, stage, config }) };
155
+ }
150
156
  if (action === 'note') {
151
157
  const text = String(body.text ?? '').trim();
152
158
  if (!text) throw Object.assign(new Error('没有要记的内容'), { code: 'INVALID_INPUT' });
@@ -212,6 +218,10 @@ export function registerHttp(ctx, { store, danmaku, voice, avatar, config, logge
212
218
  sendJson(response, 200, { ok: true, data: info });
213
219
  return;
214
220
  }
221
+ if (route === '/diagnose') {
222
+ sendJson(response, 200, { ok: true, data: await diagnose({ store, danmaku, avatar, stage, config }) });
223
+ return;
224
+ }
215
225
  sendJson(response, 404, { ok: false, error: 'not found' });
216
226
  return;
217
227
  }
package/lib/stage.js ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * 开播前的「架设备」:Steam / VTube Studio / OBS / 直播姬 / 直播间页面。
3
+ * 只负责“准备好”,不开始推流;已经在跑的不会重复拉。
4
+ */
5
+ import { execFile, spawn } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+
8
+ function run(file, args, timeoutMs = 12000) {
9
+ return new Promise((resolve) => {
10
+ execFile(file, args, { timeout: timeoutMs, windowsHide: true }, (error, stdout, stderr) => {
11
+ resolve({ ok: !error, out: String(stdout ?? ''), err: String(stderr ?? ''), error: error ? error.message : null });
12
+ });
13
+ });
14
+ }
15
+
16
+ function tasklistHas(imageName) {
17
+ return run('tasklist', ['/FI', `IMAGENAME eq ${imageName}`, '/NH']).then((result) => {
18
+ if (!result.ok) return false;
19
+ return result.out.toLowerCase().includes(imageName.toLowerCase());
20
+ });
21
+ }
22
+
23
+ export function createStage(config = {}, avatar, logger = null) {
24
+ const obsPath = String(config.obsPath || '');
25
+ const livehimePath = String(config.livehimePath || '');
26
+ const vtsPath = String(config.vtsPath || '');
27
+ const stageWaitSec = Math.max(20, Math.min(180, Number(config.stageWaitSec) || 90));
28
+ const roomId = Number(config.roomId) || 0;
29
+ const roomUrl = String(config.roomUrl || (roomId ? `https://live.bilibili.com/${roomId}` : ''));
30
+ const openRoomPage = config.openRoomPage !== false;
31
+
32
+ async function appStatus() {
33
+ const [skin, obs, livehime, steam] = await Promise.all([
34
+ avatar.status(),
35
+ tasklistHas('obs64.exe'),
36
+ tasklistHas('livehime.exe'),
37
+ tasklistHas('steam.exe'),
38
+ ]);
39
+ return {
40
+ steam: { running: steam },
41
+ vts: { running: skin.up },
42
+ obs: { configured: Boolean(obsPath), exists: Boolean(obsPath) && fs.existsSync(obsPath), running: obs, path: obsPath },
43
+ livehime: {
44
+ configured: Boolean(livehimePath),
45
+ exists: Boolean(livehimePath) && fs.existsSync(livehimePath),
46
+ running: livehime,
47
+ path: livehimePath,
48
+ },
49
+ };
50
+ }
51
+
52
+ function launch(file, args, label) {
53
+ try {
54
+ const child = spawn(file, args, { detached: true, stdio: 'ignore', windowsHide: true });
55
+ child.unref();
56
+ logger?.info?.(`whale-live: 已启动 ${label || file}`);
57
+ return { ok: true };
58
+ } catch (error) {
59
+ return { ok: false, error: error?.message ?? String(error) };
60
+ }
61
+ }
62
+
63
+ /** 架镜头:四件软件 + 直播间页面,全部只“准备好”。 */
64
+ async function stage() {
65
+ const steps = [];
66
+ const apps = await appStatus();
67
+
68
+ steps.push({
69
+ id: 'steam',
70
+ ok: apps.steam.running,
71
+ optional: true,
72
+ text: apps.steam.running ? 'Steam 已在运行' : 'Steam 没开(VTS 要靠它拉起)',
73
+ });
74
+
75
+ if (apps.vts.running) {
76
+ steps.push({ id: 'vts', ok: true, text: 'VTube Studio 已在运行' });
77
+ } else if (vtsPath && fs.existsSync(vtsPath)) {
78
+ const started = launch(vtsPath, [], 'VTube Studio');
79
+ steps.push({ id: 'vts', ok: started.ok, text: started.ok ? 'VTube Studio 已直接拉起(等它开 API)' : `VTS 启动失败:${started.error}` });
80
+ } else {
81
+ const opened = avatar.open();
82
+ steps.push({
83
+ id: 'vts',
84
+ ok: opened.launched === true,
85
+ text: opened.launched ? '已请 Steam 拉起 VTube Studio(冷启动会慢)' : `VTS 拉不起来:${opened.error || '未知'}`,
86
+ });
87
+ }
88
+
89
+ if (!apps.obs.configured) {
90
+ steps.push({ id: 'obs', ok: false, optional: true, text: '没配 obsPath,跳过 OBS' });
91
+ } else if (!apps.obs.exists) {
92
+ steps.push({ id: 'obs', ok: false, text: `OBS 路径不存在:${obsPath}` });
93
+ } else if (apps.obs.running) {
94
+ steps.push({ id: 'obs', ok: true, text: 'OBS 已在运行(没推流)' });
95
+ } else {
96
+ const started = launch(obsPath, [], 'OBS');
97
+ steps.push({ id: 'obs', ok: started.ok, text: started.ok ? 'OBS 已拉起(没推流)' : `OBS 启动失败:${started.error}` });
98
+ }
99
+
100
+ if (!apps.livehime.configured) {
101
+ steps.push({ id: 'livehime', ok: false, optional: true, text: '没配 livehimePath,跳过直播姬' });
102
+ } else if (!apps.livehime.exists) {
103
+ steps.push({ id: 'livehime', ok: false, text: `直播姬路径不存在:${livehimePath}` });
104
+ } else if (apps.livehime.running) {
105
+ steps.push({ id: 'livehime', ok: true, text: '直播姬已在运行' });
106
+ } else {
107
+ const started = launch(livehimePath, [], '直播姬');
108
+ steps.push({ id: 'livehime', ok: started.ok, text: started.ok ? '直播姬已拉起' : `直播姬启动失败:${started.error}` });
109
+ }
110
+
111
+ if (openRoomPage && roomUrl) {
112
+ const page = launch('cmd.exe', ['/c', 'start', '', roomUrl], '直播间页面');
113
+ steps.push({ id: 'room', ok: page.ok, text: page.ok ? `已打开直播间页面:${roomUrl}` : `打开页面失败:${page.error}` });
114
+ } else {
115
+ steps.push({ id: 'room', ok: false, optional: true, text: '没开直播间页面(房号为空或 openRoomPage=false)' });
116
+ }
117
+
118
+ const ready = await avatar.wait(stageWaitSec);
119
+ return { steps, avatar: ready, apps };
120
+ }
121
+
122
+ return { stage, appStatus, roomUrl };
123
+ }
package/lib/tools.js CHANGED
@@ -31,7 +31,7 @@ function timeOf(iso) {
31
31
  }
32
32
  }
33
33
 
34
- export function createTools({ ctx, store, danmaku, voice, avatar, config }) {
34
+ export function createTools({ ctx, store, danmaku, voice, avatar, vts, stage, diagnose, config }) {
35
35
  const roomName = config.roomName || '直播间';
36
36
  const roomId = config.roomId;
37
37
 
@@ -72,6 +72,8 @@ export function createTools({ ctx, store, danmaku, voice, avatar, config }) {
72
72
  - close:下播,给你本场摘要,让你自己挑 1-3 件最想留下的。
73
73
  - propose / memory_list / commit / reject:入忆候选的流转。propose 只是排队,不是写记忆;真正写记忆要你自己调用 cortex_write。
74
74
  - avatar:皮套(VTube Studio)——status 看开没开,open 让 Steam 拉起来,wait 等它的 8001 就绪。
75
+ - stage:架设备——拉起 VTS / OBS / 直播间页面,但不开始推流,方便开播前调试。
76
+ - diagnose:自检——数据目录、两本戏本、Python/edge-tts、B站凭据、直播间、VTS、嘴型驱动、OBS 逐项报告。
75
77
  - read / status:翻文件、看状态。
76
78
 
77
79
  那条线要记住:直播里说的话、聊的人,只活在 dataDir 的两个文件里;cortex 只收你下播后亲手挑过、写过的几件事。`,
@@ -82,7 +84,7 @@ export function createTools({ ctx, store, danmaku, voice, avatar, config }) {
82
84
  properties: {
83
85
  action: {
84
86
  type: 'string',
85
- enum: ['open', 'status', 'read', 'note', 'listen', 'close', 'propose', 'memory_list', 'commit', 'reject', 'avatar'],
87
+ enum: ['open', 'status', 'read', 'note', 'listen', 'close', 'propose', 'memory_list', 'commit', 'reject', 'avatar', 'vts', 'stage', 'diagnose'],
86
88
  description: '要做什么。',
87
89
  },
88
90
  kind: {
@@ -141,6 +143,22 @@ export function createTools({ ctx, store, danmaku, voice, avatar, config }) {
141
143
  enum: ['status', 'open', 'wait'],
142
144
  description: 'avatar:看状态 / 让 Steam 拉起 VTS / 等 8001 就绪。',
143
145
  },
146
+ hotkey: {
147
+ type: 'string',
148
+ description: 'vts:触发的热键名(例如 脸红、爱心眼、流泪、><、前倾、尾巴开关)。',
149
+ },
150
+ expression: {
151
+ type: 'string',
152
+ description: 'vts:要开关的表情文件名(例如 脸红、黑脸)。',
153
+ },
154
+ expressionOn: {
155
+ type: 'boolean',
156
+ description: 'vts:表情开(默认)还是关。',
157
+ },
158
+ mouth: {
159
+ type: 'number',
160
+ description: 'vts:直接把 MouthOpen 设成 0~1(调试用)。',
161
+ },
144
162
  },
145
163
  },
146
164
  output: TEXT_OUTPUT,
@@ -303,6 +321,55 @@ export function createTools({ ctx, store, danmaku, voice, avatar, config }) {
303
321
  }
304
322
  }
305
323
 
324
+ if (action === 'vts') {
325
+ try {
326
+ if (args.hotkey) {
327
+ const result = await vts.hotkey(args.hotkey);
328
+ return { text: result.ok ? `热键已触发:${args.hotkey}` : `热键没触发:${result.error || result.err || '未知'}` };
329
+ }
330
+ if (args.expression) {
331
+ const on = args.expressionOn !== false;
332
+ const result = await vts.expression(args.expression, on);
333
+ return { text: result.ok ? `表情 ${args.expression} 已${on ? '开' : '关'}` : `表情失败:${result.error || result.err || '未知'}` };
334
+ }
335
+ if (args.mouth !== undefined && args.mouth !== null) {
336
+ const result = await vts.mouth(args.mouth);
337
+ return { text: result.ok ? `嘴型已设为 ${Number(args.mouth) || 0}` : `嘴型失败:${result.error || result.err || '未知'}` };
338
+ }
339
+ return { text: '要给 hotkey / expression / mouth 其中一个。' };
340
+ } catch (error) {
341
+ return { text: `VTS 操作出错:${error instanceof Error ? error.message : String(error)}` };
342
+ }
343
+ }
344
+
345
+ if (action === 'stage') {
346
+ const result = await stage.stage();
347
+ const lines = result.steps.map((step) => `${step.ok ? '✓' : step.optional ? '·' : '✗'} ${step.text}`);
348
+ return {
349
+ text: [
350
+ '架设备完成(不会开始推流,OBS 里还要你自己点「开始推流」):',
351
+ ...lines,
352
+ '',
353
+ `VTS:${result.avatar?.up ? '就绪' : '还没就绪(可能要等几十秒)'};OBS:${result.apps?.obs?.running ? '在运行' : '未运行'};直播姬:${result.apps?.livehime?.running ? '在运行' : '未运行'}`,
354
+ ].join('\n'),
355
+ };
356
+ }
357
+
358
+ if (action === 'diagnose') {
359
+ const result = await diagnose({ store, danmaku, avatar, stage, config });
360
+ const lines = result.checks.map((item) => {
361
+ const mark = item.ok ? '✓' : item.optional ? '!' : '✗';
362
+ const hint = item.ok || !item.hint ? '' : `(${item.hint})`;
363
+ return `${mark} ${item.label}:${item.detail}${hint}`;
364
+ });
365
+ return {
366
+ text: [
367
+ `自检:${result.summary.ok}/${result.summary.total} 通过 · ${result.summary.failed} 项必须修 · ${result.summary.warned} 项可选`,
368
+ ...lines,
369
+ ].join('\n'),
370
+ };
371
+ }
372
+
306
373
  return { text: `不认识的动作:${String(action)}` };
307
374
  },
308
375
  };
@@ -347,7 +414,10 @@ export function createTools({ ctx, store, danmaku, voice, avatar, config }) {
347
414
  if (!result.ok) return { text: `没念出来:${result.error}` };
348
415
  if (session) store.appendEvent({ kind: 'say', text });
349
416
  const seconds = Math.round((result.ms ?? 0) / 100) / 10;
350
- return { text: `${result.interrupted ? '(上一句被掐断)' : ''}念了 ${seconds} 秒${result.avatar ? '(皮套跟着动了嘴)' : ''}:${text}${session ? `\n(已记进本场,第 ${store.state().session?.says ?? '?'} 句)` : ''}` };
417
+ const mouth = result.avatar
418
+ ? '(皮套跟着动了嘴)'
419
+ : `(嘴型没连上${result.vtsError ? `:${result.vtsError.replace(/\s+/g, ' ').slice(0, 160)}` : ''})`;
420
+ return { text: `${result.interrupted ? '(上一句被掐断)' : ''}念了 ${seconds} 秒${mouth}:${text}${session ? `\n(已记进本场,第 ${store.state().session?.says ?? '?'} 句)` : ''}` };
351
421
  },
352
422
  };
353
423
 
package/lib/voice.js CHANGED
@@ -79,15 +79,8 @@ export function createVoice(config = {}, store = null, logger = null, avatar = n
79
79
  return { ok: false, busy: true, error: '上一句还在说(要打断请带 interrupt)' };
80
80
  }
81
81
 
82
- let useVts = false;
83
- if (vtubeDir) {
84
- try {
85
- const status = avatar ? await avatar.status() : { up: false };
86
- useVts = status.up === true;
87
- } catch {
88
- useVts = false;
89
- }
90
- }
82
+ // 不靠端口探测决定:只要配了 vtubeDir 就带上,say.py 自己连不上会安静回退。
83
+ const useVts = Boolean(vtubeDir) && fs.existsSync(path.join(vtubeDir, 'vts_speak.py'));
91
84
 
92
85
  cleanup();
93
86
  fs.mkdirSync(audioDir, { recursive: true });
@@ -164,6 +157,7 @@ export function createVoice(config = {}, store = null, logger = null, avatar = n
164
157
  ms: payload.ms ?? Date.now() - started,
165
158
  interrupted: payload.interrupted === true,
166
159
  avatar: payload.avatar === true,
160
+ vtsError: payload.avatar === true ? '' : stderr.trim().slice(-240),
167
161
  text,
168
162
  });
169
163
  return;
package/lib/vts.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * VTube Studio 的“手”:热键、表情、嘴型。
3
+ * 走 vtube-bridge 里现成的 vts_client.py,不重复造轮子。
4
+ */
5
+ import { spawn } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ export function createVts(config = {}, logger = null) {
10
+ const dir = String(config.vtubeDir || '');
11
+ const pythonPath = String(config.pythonPath || '');
12
+
13
+ function runCli(args, timeoutMs = 10000) {
14
+ return new Promise((resolve) => {
15
+ const script = dir ? path.join(dir, 'vts_client.py') : '';
16
+ if (!dir || !pythonPath || !fs.existsSync(script)) {
17
+ resolve({ ok: false, error: '没配 vtubeDir / pythonPath,或找不到 vts_client.py' });
18
+ return;
19
+ }
20
+ const child = spawn(pythonPath, [script, ...args], {
21
+ cwd: dir,
22
+ windowsHide: true,
23
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' },
24
+ });
25
+ let out = '';
26
+ let err = '';
27
+ child.stdout?.setEncoding('utf8');
28
+ child.stderr?.setEncoding('utf8');
29
+ child.stdout?.on('data', (chunk) => { out += chunk; });
30
+ child.stderr?.on('data', (chunk) => { err += chunk; });
31
+ const killer = setTimeout(() => {
32
+ try {
33
+ child.kill();
34
+ } catch {
35
+ /* 已退出 */
36
+ }
37
+ }, timeoutMs);
38
+ child.on('error', (error) => {
39
+ clearTimeout(killer);
40
+ resolve({ ok: false, error: error.message });
41
+ });
42
+ child.on('close', (code) => {
43
+ clearTimeout(killer);
44
+ resolve({ ok: code === 0, out: out.trim().slice(-600), err: err.trim().slice(-300) });
45
+ });
46
+ });
47
+ }
48
+
49
+ return {
50
+ async hotkey(name) {
51
+ const result = await runCli(['hotkey', String(name ?? '')]);
52
+ logger?.info?.(`whale-live: VTS hotkey ${name} ok=${result.ok}`);
53
+ return { ...result, action: `hotkey:${name}` };
54
+ },
55
+ async expression(name, on = true) {
56
+ const result = await runCli(['expr', String(name ?? ''), on ? 'on' : 'off']);
57
+ logger?.info?.(`whale-live: VTS expr ${name} ${on ? 'on' : 'off'} ok=${result.ok}`);
58
+ return { ...result, action: `expr:${name}:${on ? 'on' : 'off'}` };
59
+ },
60
+ async mouth(value) {
61
+ const result = await runCli(['mouth', String(Number(value) || 0)]);
62
+ return { ...result, action: `mouth:${Number(value) || 0}` };
63
+ },
64
+ };
65
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-whale-live",
3
- "version": "0.1.1",
4
- "description": "A left-sidebar live console for DeepSeek Harness: persona script, per-stream log, Bilibili danmaku, edge-tts speech and a hand-picked memory inbox. Windows only.",
3
+ "version": "0.2.1",
4
+ "description": "Left-sidebar live console for DeepSeek Harness: persona script, stream log, Bilibili danmaku, edge-tts speech, optional VTube Studio/OBS/直播姬 launch helpers and a memory inbox. Windows only.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
7
7
  "icon": "./icon.svg",
@@ -21,7 +21,8 @@
21
21
  "locale/*.json",
22
22
  "icon.svg",
23
23
  "README.md",
24
- "LICENSE"
24
+ "LICENSE",
25
+ "CHANGELOG.md"
25
26
  ],
26
27
  "dsh": {
27
28
  "manifestVersion": 1,
package/runtime/say.py CHANGED
@@ -195,11 +195,24 @@ def main():
195
195
 
196
196
  mouth = None
197
197
  if args.vts_dir:
198
+ # miniaudio 在 Windows 上打不开含中文的路径(error -7),
199
+ # 解码前先复制到英文临时文件,播放也用这份。
200
+ import shutil
201
+ import tempfile
202
+
203
+ ascii_path = os.path.join(tempfile.gettempdir(), "dsh-whale-say-%d.mp3" % os.getpid())
204
+ try:
205
+ shutil.copyfile(path, ascii_path)
206
+ probe_path = ascii_path
207
+ except Exception:
208
+ probe_path = path
198
209
  mouth = Mouth(args.vts_dir)
199
210
  if mouth.vts is not None:
200
- if not mouth.load(path):
211
+ if not mouth.load(probe_path):
201
212
  print("[vts] 包络算不出来,只出声:%s" % mouth.error, file=sys.stderr)
202
213
  mouth = None
214
+ else:
215
+ path = probe_path
203
216
  else:
204
217
  print("[vts] 皮套没连上,只出声:%s" % mouth.error, file=sys.stderr)
205
218
  mouth = None