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 +14 -0
- package/index.js +3 -1
- package/lib/tools.js +43 -3
- package/lib/voice.js +3 -9
- package/lib/vts.js +65 -0
- package/package.json +3 -2
- package/runtime/say.py +14 -1
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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.
|
|
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(
|
|
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
|