dsh-whale-live 0.2.0 → 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/index.js CHANGED
@@ -15,6 +15,7 @@ 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';
18
19
  import { createStage } from './lib/stage.js';
19
20
  import { diagnose } from './lib/diagnostics.js';
20
21
  import { createTools } from './lib/tools.js';
@@ -53,9 +54,10 @@ export async function apply(ctx, config = {}) {
53
54
  store.ensure();
54
55
  const danmaku = createDanmaku(settings, ctx.logger);
55
56
  const avatar = createAvatar(settings, ctx.logger);
57
+ const vts = createVts(settings, ctx.logger);
56
58
  const stage = createStage(settings, avatar, ctx.logger);
57
59
  const voice = createVoice(settings, store, ctx.logger, avatar);
58
- const tools = createTools({ ctx, store, danmaku, voice, avatar, stage, diagnose, config: settings });
60
+ const tools = createTools({ ctx, store, danmaku, voice, avatar, vts, stage, diagnose, config: settings });
59
61
 
60
62
  ctx.effect(() => {
61
63
  const disposers = [];
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, stage, diagnose, 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
 
@@ -84,7 +84,7 @@ export function createTools({ ctx, store, danmaku, voice, avatar, stage, diagnos
84
84
  properties: {
85
85
  action: {
86
86
  type: 'string',
87
- enum: ['open', 'status', 'read', 'note', 'listen', 'close', 'propose', 'memory_list', 'commit', 'reject', 'avatar', 'stage', 'diagnose'],
87
+ enum: ['open', 'status', 'read', 'note', 'listen', 'close', 'propose', 'memory_list', 'commit', 'reject', 'avatar', 'vts', 'stage', 'diagnose'],
88
88
  description: '要做什么。',
89
89
  },
90
90
  kind: {
@@ -143,6 +143,22 @@ export function createTools({ ctx, store, danmaku, voice, avatar, stage, diagnos
143
143
  enum: ['status', 'open', 'wait'],
144
144
  description: 'avatar:看状态 / 让 Steam 拉起 VTS / 等 8001 就绪。',
145
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
+ },
146
162
  },
147
163
  },
148
164
  output: TEXT_OUTPUT,
@@ -305,6 +321,27 @@ export function createTools({ ctx, store, danmaku, voice, avatar, stage, diagnos
305
321
  }
306
322
  }
307
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
+
308
345
  if (action === 'stage') {
309
346
  const result = await stage.stage();
310
347
  const lines = result.steps.map((step) => `${step.ok ? '✓' : step.optional ? '·' : '✗'} ${step.text}`);
@@ -377,7 +414,10 @@ export function createTools({ ctx, store, danmaku, voice, avatar, stage, diagnos
377
414
  if (!result.ok) return { text: `没念出来:${result.error}` };
378
415
  if (session) store.appendEvent({ kind: 'say', text });
379
416
  const seconds = Math.round((result.ms ?? 0) / 100) / 10;
380
- 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 ?? '?'} 句)` : ''}` };
381
421
  },
382
422
  };
383
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,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-live",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
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",
@@ -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