arona-agent 1.0.0
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/LICENSE +21 -0
- package/README.md +149 -0
- package/assets/blue-archive/arona/spine/arona_spr.atlas.txt +1168 -0
- package/assets/blue-archive/arona/spine/arona_spr.png +0 -0
- package/assets/blue-archive/arona/spine/arona_spr.skel +0 -0
- package/assets/blue-archive/arona/voice.mp3 +0 -0
- package/assets/blue-archive/hoshino/hoshino_spr.atlas.txt +174 -0
- package/assets/blue-archive/hoshino/hoshino_spr.json +15385 -0
- package/assets/blue-archive/hoshino/hoshino_spr.png +0 -0
- package/assets/blue-archive/hoshino/hoshino_spr.skel +0 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.atlas.txt +111 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.png +0 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.skel +0 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.atlas.txt +251 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.png +0 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.skel +0 -0
- package/assets/blue-archive/hoshino/voice.mp3 +0 -0
- package/assets/blue-archive/plana/spine/plana_spr.atlas.txt +1266 -0
- package/assets/blue-archive/plana/spine/plana_spr.png +0 -0
- package/assets/blue-archive/plana/spine/plana_spr.skel +0 -0
- package/assets/blue-archive/plana/voice.mp3 +0 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.atlas.txt +97 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.png +0 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.skel +0 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.atlas.txt +174 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.png +0 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.skel +0 -0
- package/assets/blue-archive/shiroko/shiroko_spr.atlas.txt +167 -0
- package/assets/blue-archive/shiroko/shiroko_spr.json +10702 -0
- package/assets/blue-archive/shiroko/shiroko_spr.png +0 -0
- package/assets/blue-archive/shiroko/shiroko_spr.skel +0 -0
- package/assets/blue-archive/shiroko/voice.mp3 +0 -0
- package/bin/arona.mjs +43 -0
- package/package.json +37 -0
- package/pet/agents.cjs +197 -0
- package/pet/main.cjs +462 -0
- package/pet/preload.cjs +21 -0
- package/pet/renderer/fx.html +30 -0
- package/pet/renderer/fx.js +41 -0
- package/pet/renderer/gallery.html +18 -0
- package/pet/renderer/gallery.js +31 -0
- package/pet/renderer/index.html +18 -0
- package/pet/renderer/renderer.js +382 -0
- package/pet/renderer/spine_layer.js +609 -0
- package/pet/renderer/spinetest.html +24 -0
- package/pet/renderer/spinetest.js +295 -0
- package/pet/renderer/style.css +63 -0
- package/pet/tools/gallery_capture.cjs +267 -0
- package/pet/tools/gen_sway.cjs +170 -0
- package/pet/tools/meshify_region.cjs +105 -0
- package/pet/tools/skel_inspect.cjs +112 -0
- package/pet/tools/skel_to_json.cjs +410 -0
- package/pet/tools/spine_node.cjs +69 -0
- package/pet/tools/visual_test.cjs +262 -0
- package/pet/vendor/spine/spine-canvas.js +8472 -0
- package/pet/vendor/spine/spine-webgl.js +10839 -0
- package/python/__pycache__/_i18n.cpython-313.pyc +0 -0
- package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
- package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-313.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
- package/python/__pycache__/stt.cpython-313.pyc +0 -0
- package/python/__pycache__/stt.cpython-314.pyc +0 -0
- package/python/__pycache__/tts.cpython-313.pyc +0 -0
- package/python/__pycache__/tts.cpython-314.pyc +0 -0
- package/python/__pycache__/tts_stream.cpython-313.pyc +0 -0
- package/python/__pycache__/tts_stream.cpython-314.pyc +0 -0
- package/python/__pycache__/voice_clone.cpython-314.pyc +0 -0
- package/python/_i18n.py +18 -0
- package/python/computer_use.py +190 -0
- package/python/hotkey.py +196 -0
- package/python/stt.py +240 -0
- package/python/tts_say.py +177 -0
- package/python/voice_clone.py +98 -0
- package/requirements.txt +6 -0
- package/src/agent.ts +791 -0
- package/src/agent_registry.ts +109 -0
- package/src/commands.ts +641 -0
- package/src/config.ts +245 -0
- package/src/in_memory_credentials.ts +61 -0
- package/src/index.ts +78 -0
- package/src/locale.ts +131 -0
- package/src/logo.ts +120 -0
- package/src/mcp.ts +192 -0
- package/src/memory.ts +303 -0
- package/src/pet.ts +346 -0
- package/src/renderer.ts +350 -0
- package/src/repl.ts +902 -0
- package/src/setup.ts +371 -0
- package/src/skills.ts +96 -0
- package/src/slash_menu.ts +371 -0
- package/src/slash_registry.ts +53 -0
- package/src/speaker_context.ts +41 -0
- package/src/text_split.ts +79 -0
- package/src/tools/computer_use.ts +142 -0
- package/src/tools/emotion_tool.ts +55 -0
- package/src/tools/keep_silent_tool.ts +26 -0
- package/src/tools/memory_tool.ts +24 -0
- package/src/tools/skill_tools.ts +100 -0
- package/src/tools/tavily_tools.ts +431 -0
- package/src/tools/voice_tools.ts +28 -0
- package/src/tts_stream.ts +260 -0
- package/src/tui_select.ts +146 -0
- package/src/undo.ts +451 -0
- package/src/utils/python.ts +211 -0
- package/src/utils/spawn.ts +56 -0
- package/src/voice.ts +83 -0
- package/src/voice_cli.ts +198 -0
- package/src/voices.ts +135 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { spawn, type SpawnOptions, type ChildProcessWithoutNullStreams } from "child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 跨平台 spawn 封装。
|
|
5
|
+
*
|
|
6
|
+
* Node >= 20.12.2 / 18.20.2 / 21.7.3(2024-04-10 起,CVE-2024-27980 安全修复)引入破坏性变更:
|
|
7
|
+
* Windows 上 spawn() 直接执行 .bat / .cmd 文件、且未设置 `shell: true` 时会直接抛 EINVAL。
|
|
8
|
+
* 这里检测命令是否为批处理文件,命中时自动补 shell,保证 Windows 端不炸、其他平台零影响。
|
|
9
|
+
*
|
|
10
|
+
* 注意:shell 仅在 .bat/.cmd 场景启用,避免对普通 exe 命令引入 shell 解析副作用。
|
|
11
|
+
* 调用方传入的参数均为内部固定值,无外部不可信输入,注入风险可控。
|
|
12
|
+
*/
|
|
13
|
+
function isBatchFile(command: string): boolean {
|
|
14
|
+
// 取命令第一个 token(兼容带引号的完整路径)
|
|
15
|
+
const trimmed = command.trim();
|
|
16
|
+
let first: string;
|
|
17
|
+
if (trimmed.startsWith('"')) {
|
|
18
|
+
const end = trimmed.indexOf('"', 1);
|
|
19
|
+
first = end === -1 ? trimmed.slice(1) : trimmed.slice(1, end);
|
|
20
|
+
} else {
|
|
21
|
+
first = trimmed.split(/\s+/)[0];
|
|
22
|
+
}
|
|
23
|
+
return /\.(bat|cmd)$/i.test(first);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function spawnCompat(
|
|
27
|
+
command: string,
|
|
28
|
+
args: string[] = [],
|
|
29
|
+
options: SpawnOptions = {},
|
|
30
|
+
): ChildProcessWithoutNullStreams {
|
|
31
|
+
if (process.platform === "win32" && isBatchFile(command)) {
|
|
32
|
+
return spawn(command, args, { ...options, shell: true }) as ChildProcessWithoutNullStreams;
|
|
33
|
+
}
|
|
34
|
+
return spawn(command, args, options) as ChildProcessWithoutNullStreams;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 剔除代理环境变量,返回新对象(不改动原 process.env)。
|
|
39
|
+
*
|
|
40
|
+
* Python 侧 websockets/requests 会读 ALL_PROXY/all_proxy 走 SOCKS 代理;用户本机常开 Clash
|
|
41
|
+
* 为 GitHub 等设 SOCKS 代理,但阿里云百炼 DashScope 是国内服务、应直连。若代理变量泄漏进
|
|
42
|
+
* Python 子进程,websockets 16.x 会报 "connecting through a SOCKS proxy requires python-socks"。
|
|
43
|
+
* 故所有 spawn Python 语音脚本(TTS/STT)时都用此函数清洗 env。
|
|
44
|
+
*/
|
|
45
|
+
export function stripProxyEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
46
|
+
const out: NodeJS.ProcessEnv = { ...env };
|
|
47
|
+
for (const key of [
|
|
48
|
+
"ALL_PROXY", "all_proxy",
|
|
49
|
+
"SOCKS_PROXY", "socks_proxy",
|
|
50
|
+
"HTTP_PROXY", "http_proxy",
|
|
51
|
+
"HTTPS_PROXY", "https_proxy",
|
|
52
|
+
]) {
|
|
53
|
+
delete out[key];
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
package/src/voice.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { runPython } from "./utils/python.ts";
|
|
2
|
+
import { config } from "./config.ts";
|
|
3
|
+
import { getMainAgent, type AgentId } from "./agent_registry.ts";
|
|
4
|
+
import { hasVoice } from "./voices.ts";
|
|
5
|
+
|
|
6
|
+
let ttsEnabled = config.noVoice ? false : config.ttsEnabled;
|
|
7
|
+
let sttEnabled = config.noVoice ? false : config.sttEnabled;
|
|
8
|
+
|
|
9
|
+
/** 指定角色是否已有克隆音色(读 voices.json,arona 缺省回退旧 settings.ttsVoice)。 */
|
|
10
|
+
export function hasVoiceFor(agent: AgentId): boolean {
|
|
11
|
+
return hasVoice(agent);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 当前主 Agent 是否已有克隆音色 */
|
|
15
|
+
export function hasCurrentVoice(): boolean {
|
|
16
|
+
return hasVoiceFor(getMainAgent());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 指定角色在当前全局 TTS 开关下是否可朗读(无音色 → 强制静音,不影响 STT)。 */
|
|
20
|
+
export function isTtsEnabledFor(agent: AgentId): boolean {
|
|
21
|
+
if (config.noVoice) return false;
|
|
22
|
+
if (!ttsEnabled) return false;
|
|
23
|
+
if (!hasVoiceFor(agent)) return false;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isTtsEnabled(): boolean {
|
|
28
|
+
return isTtsEnabledFor(getMainAgent());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setTtsEnabled(enabled: boolean): void {
|
|
32
|
+
if (config.noVoice) return;
|
|
33
|
+
ttsEnabled = enabled;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isSttEnabled(): boolean {
|
|
37
|
+
return sttEnabled;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function setSttEnabled(enabled: boolean): void {
|
|
41
|
+
if (config.noVoice) return;
|
|
42
|
+
sttEnabled = enabled;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Strip markdown formatting and code blocks before sending to TTS.
|
|
47
|
+
* (供 tts_stream.ts 实时流式管道复用)
|
|
48
|
+
*/
|
|
49
|
+
export function stripMarkdown(text: string): string {
|
|
50
|
+
return text
|
|
51
|
+
.replace(/```[\s\S]*?```/g, " (code block) ")
|
|
52
|
+
.replace(/`[^`]+`/g, "")
|
|
53
|
+
.replace(/!\[.*?\]\(.*?\)/g, "")
|
|
54
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
55
|
+
.replace(/#{1,6}\s+/g, "")
|
|
56
|
+
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1")
|
|
57
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Listen for speech via Qwen ASR (阿里云百炼), return transcribed text.
|
|
63
|
+
*/
|
|
64
|
+
export async function listen(): Promise<string> {
|
|
65
|
+
if (!config.sttApiKey) {
|
|
66
|
+
console.warn("STT: QWEN_STT_API_KEY not configured");
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const text = await runPython("stt.py", [], undefined, {
|
|
72
|
+
QWEN_WORKSPACE_ID: config.workspaceId,
|
|
73
|
+
QWEN_STT_API_KEY: config.sttApiKey,
|
|
74
|
+
QWEN_STT_MODEL: config.sttModel,
|
|
75
|
+
QWEN_STT_FORMAT: config.sttFormat,
|
|
76
|
+
QWEN_STT_SAMPLE_RATE: String(config.sttSampleRate),
|
|
77
|
+
}, 60000);
|
|
78
|
+
return text;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.warn(`STT error: ${err instanceof Error ? err.message : err}`);
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/voice_cli.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// arona voice add [<角色名>] 命令。
|
|
2
|
+
// - 带角色名:读 settings.json 的百炼 Key,克隆该角色音色;已有音色则询问是否重新克隆。
|
|
3
|
+
// - 不带参数:进入与 setup 相同的多选 TUI,仅列未补全音色的角色。
|
|
4
|
+
// 独立进程入口(由 bin/arona.mjs 在 args[0]==="voice" 时 spawn),不依赖 REPL。
|
|
5
|
+
|
|
6
|
+
import * as readline from "readline";
|
|
7
|
+
import { existsSync, readFileSync } from "fs";
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
import { SETTINGS_FILE, verbose } from "./config.ts";
|
|
10
|
+
import { AGENT_IDS, getAgentLabel, type AgentId } from "./agent_registry.ts";
|
|
11
|
+
import { cloneVoice, hasVoice, setVoiceId } from "./voices.ts";
|
|
12
|
+
import { multiSelect } from "./tui_select.ts";
|
|
13
|
+
import { t } from "./locale.ts";
|
|
14
|
+
|
|
15
|
+
function readSettings(): { ttsApiKey: string; ttsModel: string } {
|
|
16
|
+
let ttsApiKey = "";
|
|
17
|
+
let ttsModel = "qwen-audio-3.0-tts-plus";
|
|
18
|
+
try {
|
|
19
|
+
if (existsSync(SETTINGS_FILE)) {
|
|
20
|
+
const s = JSON.parse(readFileSync(SETTINGS_FILE, "utf-8")) as { ttsApiKey?: unknown; ttsModel?: unknown };
|
|
21
|
+
if (typeof s.ttsApiKey === "string") ttsApiKey = s.ttsApiKey;
|
|
22
|
+
if (typeof s.ttsModel === "string" && s.ttsModel) ttsModel = s.ttsModel;
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// settings.json 损坏/缺失:走下方缺 Key 报错
|
|
26
|
+
}
|
|
27
|
+
return { ttsApiKey, ttsModel };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 演示模式:settings.json#demoMode === true 时,音色克隆静默跳过(等 5s 不写 voices.json)。 */
|
|
31
|
+
function readDemoMode(): boolean {
|
|
32
|
+
try {
|
|
33
|
+
if (existsSync(SETTINGS_FILE)) {
|
|
34
|
+
const s = JSON.parse(readFileSync(SETTINGS_FILE, "utf-8")) as { demoMode?: unknown };
|
|
35
|
+
return s.demoMode === true;
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// 损坏/缺失:按非演示模式处理
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isValidAgentId(id: string): id is AgentId {
|
|
44
|
+
return (AGENT_IDS as readonly string[]).includes(id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function askYesNo(prompt: string): Promise<boolean> {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
50
|
+
// Ctrl+C 视为"不重新克隆",立即结束询问(否则 readline 默认清行继续等输入,卡在 y/N)
|
|
51
|
+
rl.on("SIGINT", () => {
|
|
52
|
+
console.log();
|
|
53
|
+
rl.close();
|
|
54
|
+
resolve(false);
|
|
55
|
+
});
|
|
56
|
+
rl.question(prompt, (answer) => {
|
|
57
|
+
rl.close();
|
|
58
|
+
resolve(/^y/i.test(answer.trim()));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function cloneOne(agent: AgentId, apiKey: string, model: string, simulate: boolean): Promise<boolean> {
|
|
64
|
+
if (simulate) {
|
|
65
|
+
// 演示模式:打印"正在克隆"与"克隆成功",但不调用 voice_clone.py、不写 voices.json、无演示模式提示。
|
|
66
|
+
console.log(chalk.cyan(t(
|
|
67
|
+
`正在克隆 ${getAgentLabel(agent)} 的音色(可能需要 1-2 分钟)...`,
|
|
68
|
+
`Cloning ${getAgentLabel(agent)}'s voice (may take 1-2 minutes)...`,
|
|
69
|
+
)));
|
|
70
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
71
|
+
console.log(chalk.green(t(
|
|
72
|
+
`✓ ${getAgentLabel(agent)} 音色克隆成功`,
|
|
73
|
+
`✓ ${getAgentLabel(agent)} voice cloned`,
|
|
74
|
+
)));
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
console.log(chalk.cyan(t(
|
|
78
|
+
`正在克隆 ${getAgentLabel(agent)} 的音色(可能需要 1-2 分钟)...`,
|
|
79
|
+
`Cloning ${getAgentLabel(agent)}'s voice (may take 1-2 minutes)...`,
|
|
80
|
+
)));
|
|
81
|
+
try {
|
|
82
|
+
const voiceId = await cloneVoice(agent, apiKey, model);
|
|
83
|
+
setVoiceId(agent, voiceId);
|
|
84
|
+
console.log(chalk.green(t(
|
|
85
|
+
verbose
|
|
86
|
+
? `✓ ${getAgentLabel(agent)} 音色克隆成功(voice_id: ${voiceId})`
|
|
87
|
+
: `✓ ${getAgentLabel(agent)} 音色克隆成功`,
|
|
88
|
+
verbose
|
|
89
|
+
? `✓ ${getAgentLabel(agent)} voice cloned (voice_id: ${voiceId})`
|
|
90
|
+
: `✓ ${getAgentLabel(agent)} voice cloned`,
|
|
91
|
+
)));
|
|
92
|
+
return true;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
95
|
+
console.log(chalk.red(t(
|
|
96
|
+
`✗ ${getAgentLabel(agent)} 音色克隆失败:${msg}`,
|
|
97
|
+
`✗ ${getAgentLabel(agent)} voice cloning failed: ${msg}`,
|
|
98
|
+
)));
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function run(argv: string[]): Promise<void> {
|
|
104
|
+
// --verbose 可与角色名同栈传入(`arona voice add --verbose`):先剔除,避免被当成角色名
|
|
105
|
+
const args = argv.filter((a) => a !== "--verbose");
|
|
106
|
+
const sub = args[0];
|
|
107
|
+
if (sub !== "add") {
|
|
108
|
+
console.log(chalk.yellow(t(
|
|
109
|
+
"用法:arona voice add [<角色名>]",
|
|
110
|
+
"Usage: arona voice add [<character-name>]",
|
|
111
|
+
)));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { ttsApiKey, ttsModel } = readSettings();
|
|
116
|
+
const demoMode = readDemoMode();
|
|
117
|
+
if (!demoMode && !ttsApiKey) {
|
|
118
|
+
console.log(chalk.red(t(
|
|
119
|
+
"未找到百炼 API Key(settings.json 的 ttsApiKey)。请先运行 arona setup 配置。",
|
|
120
|
+
"Bailian API Key not found (settings.json ttsApiKey). Run `arona setup` first.",
|
|
121
|
+
)));
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const name = args[1];
|
|
126
|
+
|
|
127
|
+
if (name) {
|
|
128
|
+
// 指定单个角色
|
|
129
|
+
if (!isValidAgentId(name)) {
|
|
130
|
+
console.log(chalk.red(t(
|
|
131
|
+
`未知角色 "${name}"。可用角色:${AGENT_IDS.join("、")}`,
|
|
132
|
+
`Unknown character "${name}". Available: ${AGENT_IDS.join(", ")}`,
|
|
133
|
+
)));
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
// 演示模式不询问是否重新克隆,直接静默跳过。
|
|
137
|
+
if (!demoMode && hasVoice(name)) {
|
|
138
|
+
const again = await askYesNo(t(
|
|
139
|
+
`角色 ${getAgentLabel(name)} 已存在音色,是否重新克隆?(y/N) `,
|
|
140
|
+
`Character ${getAgentLabel(name)} already has a voice. Re-clone? (y/N) `,
|
|
141
|
+
));
|
|
142
|
+
if (!again) {
|
|
143
|
+
console.log(chalk.cyan(t("已取消。", "Cancelled.")));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
await cloneOne(name, ttsApiKey, ttsModel, demoMode);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 无参:TUI 展示全部角色(主 Agent + 子 Agent)。
|
|
152
|
+
// 演示模式:阿洛娜强制显示为已克隆(锁定、不重克隆);普拉娜/砂狼白子/小鸟游星野强制显示为未克隆(可选)。
|
|
153
|
+
const options = AGENT_IDS.map((id) => {
|
|
154
|
+
if (demoMode && id === "arona") {
|
|
155
|
+
return { id, label: `${getAgentLabel(id)}${t("(已克隆)", " (cloned)")}`, locked: true };
|
|
156
|
+
}
|
|
157
|
+
if (demoMode) {
|
|
158
|
+
return { id, label: getAgentLabel(id), locked: false };
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
id,
|
|
162
|
+
label: hasVoice(id) ? `${getAgentLabel(id)}${t("(已克隆)", " (cloned)")}` : getAgentLabel(id),
|
|
163
|
+
locked: hasVoice(id),
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
const initial = new Set<string>();
|
|
167
|
+
const selected = await multiSelect(
|
|
168
|
+
t("选择要补全音色的角色", "Select characters to add voices"),
|
|
169
|
+
options,
|
|
170
|
+
initial,
|
|
171
|
+
t(
|
|
172
|
+
" ↑/↓ 切换 · 空格选中 [*] · 回车克隆 · Esc 取消",
|
|
173
|
+
" ↑/↓ move · Space select [*] · Enter clone · Esc cancel",
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
if (selected === null) {
|
|
178
|
+
console.log(chalk.cyan(t("已取消。", "Cancelled.")));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (selected.size === 0) {
|
|
182
|
+
console.log(chalk.cyan(t("未选择任何角色。", "No character selected.")));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const id of AGENT_IDS) {
|
|
187
|
+
if (!selected.has(id)) continue;
|
|
188
|
+
await cloneOne(id, ttsApiKey, ttsModel, demoMode);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
run(process.argv.slice(2)).catch((err) => {
|
|
193
|
+
console.error(chalk.red(t(
|
|
194
|
+
`voice add 错误:${err instanceof Error ? err.message : err}`,
|
|
195
|
+
`voice add error: ${err instanceof Error ? err.message : err}`,
|
|
196
|
+
)));
|
|
197
|
+
process.exit(1);
|
|
198
|
+
});
|
package/src/voices.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// 每角色独立音色注册表(声音复刻 voice_id)。
|
|
2
|
+
// 存储到 ~/.arona/voices.json(不塞进 settings.json——多角色 voice_id 放一个字段不现实)。
|
|
3
|
+
// 结构:{ "arona": "<voice_id>", "plana": "<voice_id>" }
|
|
4
|
+
// 运行时 TTS 按当前主 Agent 的 voice_id 判定:无音色 → 强制静音(见 voice.ts)。
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { ARONA_DIR, SETTINGS_FILE, PROJECT_ROOT } from "./config.ts";
|
|
9
|
+
import { AGENT_IDS, type AgentId } from "./agent_registry.ts";
|
|
10
|
+
import { runPython } from "./utils/python.ts";
|
|
11
|
+
|
|
12
|
+
export const VOICES_FILE = join(ARONA_DIR, "voices.json");
|
|
13
|
+
|
|
14
|
+
/** 每个角色的音色源文件路径(声音复刻上传的音频) */
|
|
15
|
+
export const VOICE_AUDIO: Record<AgentId, string> = {
|
|
16
|
+
arona: join(PROJECT_ROOT, "assets", "blue-archive", "arona", "voice.mp3"),
|
|
17
|
+
plana: join(PROJECT_ROOT, "assets", "blue-archive", "plana", "voice.mp3"),
|
|
18
|
+
shiroko: join(PROJECT_ROOT, "assets", "blue-archive", "shiroko", "voice.mp3"),
|
|
19
|
+
hoshino: join(PROJECT_ROOT, "assets", "blue-archive", "hoshino", "voice.mp3"),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function loadVoices(): Record<string, string> {
|
|
23
|
+
if (!existsSync(VOICES_FILE)) return {};
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(readFileSync(VOICES_FILE, "utf-8"));
|
|
26
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
27
|
+
return parsed as Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
return {};
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 读取指定角色的 voice_id。
|
|
37
|
+
* 优先 voices.json;arona 缺省时回退读旧 settings.json#ttsVoice(向后兼容存量用户)。
|
|
38
|
+
*/
|
|
39
|
+
export function getVoiceId(agent: AgentId): string {
|
|
40
|
+
const voices = loadVoices();
|
|
41
|
+
const v = voices[agent];
|
|
42
|
+
if (typeof v === "string" && v) return v;
|
|
43
|
+
|
|
44
|
+
if (agent === "arona") {
|
|
45
|
+
try {
|
|
46
|
+
if (existsSync(SETTINGS_FILE)) {
|
|
47
|
+
const s = JSON.parse(readFileSync(SETTINGS_FILE, "utf-8")) as { ttsVoice?: unknown };
|
|
48
|
+
if (typeof s.ttsVoice === "string" && s.ttsVoice) return s.ttsVoice;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// settings.json 损坏/不可读:忽略
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 指定角色是否已克隆音色。 */
|
|
58
|
+
export function hasVoice(agent: AgentId): boolean {
|
|
59
|
+
return getVoiceId(agent) !== "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 写回指定角色的 voice_id(read-modify-write,保留其它角色)。 */
|
|
63
|
+
export function setVoiceId(agent: AgentId, voiceId: string): void {
|
|
64
|
+
const voices = loadVoices();
|
|
65
|
+
voices[agent] = voiceId;
|
|
66
|
+
writeFileSync(VOICES_FILE, JSON.stringify(voices, null, 2) + "\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 尚缺音色的全部角色列表(主 Agent + 子 Agent)。 */
|
|
70
|
+
export function getMissingAgents(): AgentId[] {
|
|
71
|
+
return AGENT_IDS.filter((id) => !hasVoice(id));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 克隆指定角色的音色,返回 voice_id。
|
|
76
|
+
* 复用 python/voice_clone.py:ARONA_VOICE_AUDIO + ARONA_VOICE_PREFIX 指定音频与角色前缀。
|
|
77
|
+
*/
|
|
78
|
+
export async function cloneVoice(agent: AgentId, apiKey: string, model: string): Promise<string> {
|
|
79
|
+
const out = await runPython(
|
|
80
|
+
"voice_clone.py",
|
|
81
|
+
[],
|
|
82
|
+
undefined,
|
|
83
|
+
{
|
|
84
|
+
QWEN_TTS_API_KEY: apiKey,
|
|
85
|
+
QWEN_TTS_MODEL: model,
|
|
86
|
+
ARONA_VOICE_AUDIO: VOICE_AUDIO[agent],
|
|
87
|
+
ARONA_VOICE_PREFIX: agent,
|
|
88
|
+
},
|
|
89
|
+
360000, // 6 min:python 侧轮询等待上限 5min + upload/create 耗时,Node 预算必须大于
|
|
90
|
+
// python 侧总耗时,否则服务端克隆已成功但本地先判超时(上传文件也不会被清理)
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
let parsed: { voice_id?: string; error?: string };
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(out);
|
|
96
|
+
} catch {
|
|
97
|
+
throw new Error(`invalid voice_clone output: ${out}`);
|
|
98
|
+
}
|
|
99
|
+
if (parsed.voice_id) return parsed.voice_id;
|
|
100
|
+
throw new Error(parsed.error || "voice_clone returned no voice_id");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 旧格式迁移:settings.json 直接存 voice_id(单角色时代)→ voices.json 新格式。
|
|
105
|
+
* 动作:settings.json#ttsVoice 非空且 voices.json 尚无 arona → 写入 voices.json.arona;
|
|
106
|
+
* 然后删除 settings.json#ttsVoice 完成清理(避免每次启动重复检测)。
|
|
107
|
+
* 幂等:无旧字段直接返回。失败静默(getVoiceId 的 legacy 回退仍兜底)。
|
|
108
|
+
*/
|
|
109
|
+
function migrateLegacyVoice(): void {
|
|
110
|
+
try {
|
|
111
|
+
if (!existsSync(SETTINGS_FILE)) return;
|
|
112
|
+
const settings = JSON.parse(readFileSync(SETTINGS_FILE, "utf-8")) as Record<string, unknown>;
|
|
113
|
+
const legacy = settings.ttsVoice;
|
|
114
|
+
if (typeof legacy !== "string" || !legacy) return;
|
|
115
|
+
|
|
116
|
+
const voices = loadVoices();
|
|
117
|
+
let changed = false;
|
|
118
|
+
if (typeof voices.arona !== "string" || !voices.arona) {
|
|
119
|
+
voices.arona = legacy;
|
|
120
|
+
changed = true;
|
|
121
|
+
}
|
|
122
|
+
if (changed) {
|
|
123
|
+
writeFileSync(VOICES_FILE, JSON.stringify(voices, null, 2) + "\n");
|
|
124
|
+
}
|
|
125
|
+
delete settings.ttsVoice;
|
|
126
|
+
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
|
|
127
|
+
} catch {
|
|
128
|
+
// 迁移失败静默:不影响启动,getVoiceId 的 legacy 回退仍能读到旧字段
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 模块加载时执行一次:任何入口(arona / arona setup / arona voice add / 未来一切命令)
|
|
133
|
+
// 只要加载本模块就自动完成旧格式 → voices.json 迁移。setup / voice add 读 voices.json 之前
|
|
134
|
+
// 迁移已就位。
|
|
135
|
+
migrateLegacyVoice();
|