verbalcoding 0.2.12 → 0.2.13
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/.env.example +74 -4
- package/README.es.md +3 -1
- package/README.fr.md +3 -1
- package/README.ja.md +3 -1
- package/README.ko.md +4 -2
- package/README.md +4 -2
- package/README.ru.md +3 -1
- package/README.zh.md +3 -1
- package/app-node/agent_adapters.test.mjs +14 -0
- package/app-node/agent_routing.mjs +148 -0
- package/app-node/agent_routing.test.mjs +138 -0
- package/app-node/agent_turn.mjs +86 -0
- package/app-node/agent_turn.test.mjs +109 -0
- package/app-node/bridge_context.mjs +73 -0
- package/app-node/bridge_context.test.mjs +54 -0
- package/app-node/bridge_state.mjs +4 -0
- package/app-node/bridge_wireup.test.mjs +462 -0
- package/app-node/cli_install.test.mjs +31 -0
- package/app-node/cross_agent_routing.test.mjs +78 -0
- package/app-node/discord_command_router.mjs +204 -0
- package/app-node/discord_command_router.test.mjs +311 -0
- package/app-node/discord_voice_setup.mjs +251 -0
- package/app-node/discord_voice_setup.test.mjs +86 -0
- package/app-node/hermes_profiles.test.mjs +12 -1
- package/app-node/install_config.mjs +110 -3
- package/app-node/install_config.test.mjs +8 -0
- package/app-node/instance_doctor.test.mjs +9 -0
- package/app-node/instances.test.mjs +8 -1
- package/app-node/main.mjs +488 -1368
- package/app-node/mcp_tools.test.mjs +7 -0
- package/app-node/notification_handler.mjs +89 -0
- package/app-node/notification_handler.test.mjs +187 -0
- package/app-node/plan_dispatcher.mjs +215 -0
- package/app-node/plan_dispatcher.test.mjs +101 -0
- package/app-node/plan_mode.mjs +36 -7
- package/app-node/plan_mode.test.mjs +78 -0
- package/app-node/progress_handler.mjs +220 -0
- package/app-node/progress_handler.test.mjs +193 -0
- package/app-node/progress_speech.mjs +54 -32
- package/app-node/progress_speech.test.mjs +12 -3
- package/app-node/project_sessions.mjs +5 -2
- package/app-node/project_sessions.test.mjs +7 -0
- package/app-node/research_mode.mjs +282 -0
- package/app-node/research_mode.test.mjs +264 -0
- package/app-node/restart_notice.mjs +3 -0
- package/app-node/restart_notice.test.mjs +11 -0
- package/app-node/session_ontology.mjs +271 -0
- package/app-node/session_ontology.test.mjs +130 -0
- package/app-node/smart_progress.mjs +1 -1
- package/app-node/stream_sentencer.mjs +32 -2
- package/app-node/stream_sentencer.test.mjs +65 -0
- package/app-node/streaming_tts_queue.mjs +5 -1
- package/app-node/streaming_tts_queue.test.mjs +7 -1
- package/app-node/stt_whisper.mjs +24 -0
- package/app-node/stt_whisper.test.mjs +32 -0
- package/app-node/text_routing.mjs +4 -2
- package/app-node/tts_backends.mjs +537 -3
- package/app-node/tts_backends.test.mjs +454 -0
- package/app-node/tts_player.mjs +164 -0
- package/app-node/tts_player.test.mjs +202 -0
- package/app-node/tts_runtime.mjs +134 -0
- package/app-node/tts_runtime.test.mjs +89 -0
- package/app-node/tts_settings.mjs +150 -3
- package/app-node/tts_settings.test.mjs +204 -0
- package/app-node/tts_voice_config.mjs +136 -2
- package/app-node/tts_voice_config.test.mjs +94 -0
- package/app-node/utterance_router.mjs +216 -0
- package/app-node/utterance_router.test.mjs +236 -0
- package/app-node/voice_autojoin.mjs +37 -0
- package/app-node/voice_autojoin.test.mjs +59 -0
- package/app-node/voice_io.mjs +272 -0
- package/app-node/voice_io.test.mjs +102 -0
- package/app-node/voice_turn_runner.mjs +449 -0
- package/app-node/voice_turn_runner.test.mjs +289 -0
- package/docs/CONFIGURATION.md +12 -2
- package/docs/HARNESSES.md +58 -0
- package/docs/HARNESS_AIDER.md +50 -0
- package/docs/HARNESS_CLAUDE.md +56 -0
- package/docs/HARNESS_CODEX.md +56 -0
- package/docs/HARNESS_CURSOR.md +45 -0
- package/docs/HARNESS_GEMINI.md +45 -0
- package/docs/HARNESS_HERMES.md +57 -0
- package/docs/HARNESS_OPENCLAW.md +44 -0
- package/docs/HARNESS_OPENCODE.md +44 -0
- package/docs/README.md +1 -0
- package/docs/ROADMAP.md +20 -5
- package/docs/TTS_BACKENDS.md +227 -0
- package/docs/USAGE.md +22 -0
- package/docs/i18n/AGENTS.es.md +34 -0
- package/docs/i18n/AGENTS.fr.md +34 -0
- package/docs/i18n/AGENTS.ja.md +34 -0
- package/docs/i18n/AGENTS.ko.md +34 -0
- package/docs/i18n/AGENTS.ru.md +34 -0
- package/docs/i18n/AGENTS.zh.md +34 -0
- package/docs/i18n/HARNESSES.es.md +58 -0
- package/docs/i18n/HARNESSES.fr.md +58 -0
- package/docs/i18n/HARNESSES.ja.md +58 -0
- package/docs/i18n/HARNESSES.ko.md +58 -0
- package/docs/i18n/HARNESSES.ru.md +58 -0
- package/docs/i18n/HARNESSES.zh.md +58 -0
- package/docs/i18n/HARNESS_AIDER.es.md +48 -0
- package/docs/i18n/HARNESS_AIDER.fr.md +48 -0
- package/docs/i18n/HARNESS_AIDER.ja.md +50 -0
- package/docs/i18n/HARNESS_AIDER.ko.md +50 -0
- package/docs/i18n/HARNESS_AIDER.ru.md +48 -0
- package/docs/i18n/HARNESS_AIDER.zh.md +48 -0
- package/docs/i18n/HARNESS_CLAUDE.es.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.fr.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.ja.md +56 -0
- package/docs/i18n/HARNESS_CLAUDE.ko.md +56 -0
- package/docs/i18n/HARNESS_CLAUDE.ru.md +55 -0
- package/docs/i18n/HARNESS_CLAUDE.zh.md +56 -0
- package/docs/i18n/HARNESS_CODEX.es.md +55 -0
- package/docs/i18n/HARNESS_CODEX.fr.md +55 -0
- package/docs/i18n/HARNESS_CODEX.ja.md +56 -0
- package/docs/i18n/HARNESS_CODEX.ko.md +56 -0
- package/docs/i18n/HARNESS_CODEX.ru.md +55 -0
- package/docs/i18n/HARNESS_CODEX.zh.md +56 -0
- package/docs/i18n/HARNESS_CURSOR.es.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.fr.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.ja.md +45 -0
- package/docs/i18n/HARNESS_CURSOR.ko.md +45 -0
- package/docs/i18n/HARNESS_CURSOR.ru.md +42 -0
- package/docs/i18n/HARNESS_CURSOR.zh.md +42 -0
- package/docs/i18n/HARNESS_GEMINI.es.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.fr.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.ja.md +45 -0
- package/docs/i18n/HARNESS_GEMINI.ko.md +45 -0
- package/docs/i18n/HARNESS_GEMINI.ru.md +44 -0
- package/docs/i18n/HARNESS_GEMINI.zh.md +45 -0
- package/docs/i18n/HARNESS_HERMES.es.md +54 -0
- package/docs/i18n/HARNESS_HERMES.fr.md +54 -0
- package/docs/i18n/HARNESS_HERMES.ja.md +57 -0
- package/docs/i18n/HARNESS_HERMES.ko.md +57 -0
- package/docs/i18n/HARNESS_HERMES.ru.md +54 -0
- package/docs/i18n/HARNESS_HERMES.zh.md +57 -0
- package/docs/i18n/HARNESS_OPENCLAW.es.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.fr.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.ja.md +44 -0
- package/docs/i18n/HARNESS_OPENCLAW.ko.md +44 -0
- package/docs/i18n/HARNESS_OPENCLAW.ru.md +41 -0
- package/docs/i18n/HARNESS_OPENCLAW.zh.md +42 -0
- package/docs/i18n/HARNESS_OPENCODE.es.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.fr.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.ja.md +44 -0
- package/docs/i18n/HARNESS_OPENCODE.ko.md +44 -0
- package/docs/i18n/HARNESS_OPENCODE.ru.md +41 -0
- package/docs/i18n/HARNESS_OPENCODE.zh.md +44 -0
- package/docs/superpowers/plans/2026-05-14-cross-agent-voice-transfer.md +625 -0
- package/docs/superpowers/plans/2026-05-21-audio-overview-narrated-diffs.md +95 -0
- package/docs/superpowers/plans/2026-05-21-autoresearch-ontology.md +83 -0
- package/docs/superpowers/plans/2026-05-21-phase11-push-to-talk-wakeword-v2.md +77 -0
- package/docs/superpowers/plans/2026-05-21-phase12-multi-user-voice.md +147 -0
- package/docs/superpowers/plans/2026-05-21-phase14-verbalbench.md +136 -0
- package/docs/superpowers/plans/2026-05-21-phase15-phone-companion.md +72 -0
- package/integrations/fireredtts2/mlx_llm.py +183 -0
- package/integrations/fireredtts2/synth.py +156 -0
- package/integrations/fireredtts2/synth_mlx.py +196 -0
- package/integrations/mlxaudio/synth.py +74 -0
- package/integrations/neuttsair/synth.py +104 -0
- package/integrations/omnivoice/synth.py +110 -0
- package/package.json +6 -1
- package/scripts/cli.mjs +84 -0
- package/scripts/doctor.mjs +104 -4
- package/scripts/install.mjs +5 -1
- package/scripts/install_fireredtts2.sh +109 -0
- package/scripts/install_mlxaudio.sh +34 -0
- package/scripts/install_mossttsnano.sh +46 -0
- package/scripts/postinstall.mjs +34 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Discord voice channel join / attach / shutdown machinery.
|
|
2
|
+
//
|
|
3
|
+
// Phase 5d extraction from main.mjs. createDiscordVoiceSetup(deps) closes
|
|
4
|
+
// over the bridge state (connection, player, activeVoiceChannelId,
|
|
5
|
+
// currentAbortController, ttsBackend, agentAdaptersBySession) plus the
|
|
6
|
+
// Discord client and a handful of helpers, and returns the seven functions
|
|
7
|
+
// main.mjs used to own: connectTo, autoJoin, findVoiceChannelBySelector,
|
|
8
|
+
// voiceChannelLabel, resolveVoiceChannelForAttach,
|
|
9
|
+
// attachVoiceChannelToTextSession, gracefulShutdown.
|
|
10
|
+
//
|
|
11
|
+
// The shutdown guard (`shutdownStarted`) lives as a closure variable
|
|
12
|
+
// inside the factory so SIGTERM/SIGINT handlers in main.mjs see exactly
|
|
13
|
+
// one shared flag.
|
|
14
|
+
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import {
|
|
18
|
+
AudioPlayerStatus,
|
|
19
|
+
VoiceConnectionStatus,
|
|
20
|
+
entersState,
|
|
21
|
+
joinVoiceChannel,
|
|
22
|
+
} from '@discordjs/voice';
|
|
23
|
+
import { pickOccupiedUserVoiceChannel } from './voice_autojoin.mjs';
|
|
24
|
+
import { formatRestartShutdownNotice } from './restart_notice.mjs';
|
|
25
|
+
|
|
26
|
+
export function createDiscordVoiceSetup(deps) {
|
|
27
|
+
const {
|
|
28
|
+
bridge,
|
|
29
|
+
client,
|
|
30
|
+
settings,
|
|
31
|
+
ROOT,
|
|
32
|
+
log,
|
|
33
|
+
warn,
|
|
34
|
+
speakText,
|
|
35
|
+
waitEvent,
|
|
36
|
+
subscribeUser,
|
|
37
|
+
pendingFallbackNoticePromises,
|
|
38
|
+
bindProjectSessionToChannel,
|
|
39
|
+
createProjectSession,
|
|
40
|
+
resolveProjectSessionForChannel,
|
|
41
|
+
saveProjectSessionsState,
|
|
42
|
+
projectSessionsState,
|
|
43
|
+
invalidateBackendAdaptersForSession,
|
|
44
|
+
VOICE_CONNECT_TIMEOUT_MS,
|
|
45
|
+
} = deps;
|
|
46
|
+
|
|
47
|
+
async function connectTo(channel) {
|
|
48
|
+
if (bridge.connection) {
|
|
49
|
+
try { bridge.connection.destroy(); } catch {}
|
|
50
|
+
}
|
|
51
|
+
bridge.activeVoiceChannelId = channel.id;
|
|
52
|
+
bridge.connection = joinVoiceChannel({
|
|
53
|
+
channelId: channel.id,
|
|
54
|
+
guildId: channel.guild.id,
|
|
55
|
+
adapterCreator: channel.guild.voiceAdapterCreator,
|
|
56
|
+
selfDeaf: false,
|
|
57
|
+
selfMute: false,
|
|
58
|
+
});
|
|
59
|
+
const voiceConnection = bridge.connection;
|
|
60
|
+
voiceConnection.subscribe(bridge.player);
|
|
61
|
+
voiceConnection.on('error', e => warn('voice connection error', e?.stack || e));
|
|
62
|
+
voiceConnection.on('stateChange', async (oldState, newState) => {
|
|
63
|
+
log('voice connection state', oldState.status, '->', newState.status);
|
|
64
|
+
if (bridge.connection !== voiceConnection) {
|
|
65
|
+
log('ignore stale voice connection state', oldState.status, '->', newState.status);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (newState.status === VoiceConnectionStatus.Disconnected) {
|
|
69
|
+
try {
|
|
70
|
+
await Promise.race([
|
|
71
|
+
entersState(voiceConnection, VoiceConnectionStatus.Signalling, 5000),
|
|
72
|
+
entersState(voiceConnection, VoiceConnectionStatus.Connecting, 5000),
|
|
73
|
+
]);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (bridge.connection !== voiceConnection) return;
|
|
76
|
+
warn('voice connection disconnected; reconnecting to channel', channel.guild.name, channel.name, e?.message || e);
|
|
77
|
+
try { voiceConnection.destroy(); } catch {}
|
|
78
|
+
bridge.connection = null;
|
|
79
|
+
setTimeout(() => connectTo(channel).catch(err => warn('voice reconnect failed', err?.stack || err)), 1500);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
await entersState(voiceConnection, VoiceConnectionStatus.Ready, VOICE_CONNECT_TIMEOUT_MS);
|
|
84
|
+
voiceConnection.receiver.speaking.on('start', userId => subscribeUser(voiceConnection.receiver, userId));
|
|
85
|
+
log(`Listening in voice channel ${channel.guild.name} / ${channel.name}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function autoJoin() {
|
|
89
|
+
const attempted = [];
|
|
90
|
+
for (const guild of client.guilds.cache.values()) {
|
|
91
|
+
await guild.channels.fetch().catch(e => warn('auto-join channel fetch failed', guild.name, e?.message || e));
|
|
92
|
+
}
|
|
93
|
+
const activeGuildId = bridge.activeVoiceChannelId ? client.channels.cache.get(bridge.activeVoiceChannelId)?.guild?.id || '' : '';
|
|
94
|
+
const occupied = pickOccupiedUserVoiceChannel(client.guilds.cache.values(), settings.allowedUsers, {
|
|
95
|
+
activeVoiceChannelId: bridge.activeVoiceChannelId,
|
|
96
|
+
activeGuildId,
|
|
97
|
+
});
|
|
98
|
+
if (occupied) {
|
|
99
|
+
attempted.push(`${occupied.guild.name}/${occupied.name}`);
|
|
100
|
+
try {
|
|
101
|
+
log('auto-join following occupied user voice channel', occupied.guild.name, occupied.name);
|
|
102
|
+
await connectTo(occupied);
|
|
103
|
+
return;
|
|
104
|
+
} catch (e) {
|
|
105
|
+
warn('auto-join occupied user voice channel failed; trying configured channels', occupied.guild.name, occupied.name, e?.stack || e);
|
|
106
|
+
try { bridge.connection?.destroy(); } catch {}
|
|
107
|
+
bridge.connection = null;
|
|
108
|
+
bridge.activeVoiceChannelId = '';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const preferredName of settings.autoJoinVoiceChannels) {
|
|
112
|
+
for (const guild of client.guilds.cache.values()) {
|
|
113
|
+
const channels = await guild.channels.fetch();
|
|
114
|
+
for (const ch of channels.values()) {
|
|
115
|
+
if (!ch?.isVoiceBased?.() || ch.name.toLowerCase() !== preferredName) continue;
|
|
116
|
+
attempted.push(`${guild.name}/${ch.name}`);
|
|
117
|
+
try {
|
|
118
|
+
await connectTo(ch);
|
|
119
|
+
return;
|
|
120
|
+
} catch (e) {
|
|
121
|
+
warn('auto-join failed; trying next configured voice channel', guild.name, ch.name, e?.stack || e);
|
|
122
|
+
try { bridge.connection?.destroy(); } catch {}
|
|
123
|
+
bridge.connection = null;
|
|
124
|
+
bridge.activeVoiceChannelId = '';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
warn('No auto-join channel found or reachable', settings.autoJoinVoiceChannels, 'attempted', attempted);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function findVoiceChannelBySelector(guild, selector) {
|
|
133
|
+
const wanted = String(selector || '').trim();
|
|
134
|
+
if (!wanted || !guild) return null;
|
|
135
|
+
const id = wanted.replace(/^<#(\d+)>$/, '$1');
|
|
136
|
+
const channels = await guild.channels.fetch();
|
|
137
|
+
const voiceChannels = [...channels.values()].filter(ch => ch?.isVoiceBased?.());
|
|
138
|
+
const byId = voiceChannels.find(ch => ch.id === id);
|
|
139
|
+
if (byId) return byId;
|
|
140
|
+
const matches = voiceChannels.filter(ch => String(ch.name || '').toLowerCase() === wanted.toLowerCase());
|
|
141
|
+
if (matches.length === 1) return matches[0];
|
|
142
|
+
if (matches.length > 1) throw new Error(`같은 이름의 음성 채널이 여러 개야. 채널 ID나 멘션으로 지정해줘: ${wanted}`);
|
|
143
|
+
throw new Error(`음성 채널을 찾지 못했어: ${wanted}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function voiceChannelLabel(guild, channelId) {
|
|
147
|
+
if (!channelId || !guild) return '없음';
|
|
148
|
+
try {
|
|
149
|
+
const ch = await guild.channels.fetch(channelId);
|
|
150
|
+
return ch?.name || '지정됨';
|
|
151
|
+
} catch {
|
|
152
|
+
return '지정됨';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function resolveVoiceChannelForAttach(msg, selector = '') {
|
|
157
|
+
if (selector) return findVoiceChannelBySelector(msg.guild, selector);
|
|
158
|
+
if (msg.member?.voice?.channel) return msg.member.voice.channel;
|
|
159
|
+
if (bridge.activeVoiceChannelId && msg.guild) {
|
|
160
|
+
try {
|
|
161
|
+
const ch = await msg.guild.channels.fetch(bridge.activeVoiceChannelId);
|
|
162
|
+
if (ch?.isVoiceBased?.()) return ch;
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
165
|
+
throw new Error('붙일 음성 채널을 못 찾았어. 음성채널에 들어가서 `!session attach-voice`를 치거나 `--voice "채널명"`을 붙여줘.');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function attachVoiceChannelToTextSession(msg, command) {
|
|
169
|
+
const voiceChannel = await resolveVoiceChannelForAttach(msg, command.voice);
|
|
170
|
+
let session = null;
|
|
171
|
+
if (command.name) {
|
|
172
|
+
session = bindProjectSessionToChannel({ state: projectSessionsState, nameOrSlug: command.name, channelId: msg.channelId });
|
|
173
|
+
} else {
|
|
174
|
+
session = resolveProjectSessionForChannel(msg.channelId)
|
|
175
|
+
|| resolveProjectSessionForChannel(voiceChannel.id);
|
|
176
|
+
if (!session) {
|
|
177
|
+
const fallbackName = String(msg.channel?.name || `channel-${msg.channelId}`).trim() || `channel-${msg.channelId}`;
|
|
178
|
+
session = createProjectSession({
|
|
179
|
+
root: ROOT,
|
|
180
|
+
state: projectSessionsState,
|
|
181
|
+
name: fallbackName,
|
|
182
|
+
workdir: settings.agent.cwd || ROOT,
|
|
183
|
+
channelId: msg.channelId,
|
|
184
|
+
voiceChannelId: voiceChannel.id,
|
|
185
|
+
transcriptChannelId: msg.channelId,
|
|
186
|
+
mcpContext: 'Ad-hoc Discord text channel session',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
session.transcriptChannelId = msg.channelId;
|
|
191
|
+
session.voiceChannelId = voiceChannel.id;
|
|
192
|
+
projectSessionsState.channelSessions[msg.channelId] = session.slug;
|
|
193
|
+
projectSessionsState.channelSessions[voiceChannel.id] = session.slug;
|
|
194
|
+
saveProjectSessionsState();
|
|
195
|
+
bridge.agentAdaptersBySession.delete(session.slug);
|
|
196
|
+
invalidateBackendAdaptersForSession(session.slug);
|
|
197
|
+
if (bridge.activeVoiceChannelId !== voiceChannel.id) await connectTo(voiceChannel);
|
|
198
|
+
return msg.reply(`${session.name} 세션을 이 텍스트 채널과 음성 채널 ${voiceChannel.name}에 붙였어. 이제 그 음성채널 발화의 STT/답변 텍스트는 이 채널로 가.`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let shutdownStarted = false;
|
|
202
|
+
async function gracefulShutdown(signalName) {
|
|
203
|
+
if (shutdownStarted) return;
|
|
204
|
+
shutdownStarted = true;
|
|
205
|
+
log('graceful shutdown requested', signalName, 'connection', Boolean(bridge.connection));
|
|
206
|
+
try {
|
|
207
|
+
if (bridge.currentAbortController && !bridge.currentAbortController.signal.aborted) bridge.currentAbortController.abort();
|
|
208
|
+
} catch (e) {
|
|
209
|
+
warn('abort before shutdown failed', e?.stack || e);
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
if (bridge.connection) {
|
|
213
|
+
let detail = '';
|
|
214
|
+
const noticePath = path.join(ROOT, '.cache', 'restart-notice.txt');
|
|
215
|
+
try {
|
|
216
|
+
if (fs.existsSync(noticePath)) {
|
|
217
|
+
detail = fs.readFileSync(noticePath, 'utf8').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
218
|
+
}
|
|
219
|
+
} catch (e) {
|
|
220
|
+
warn('read restart notice failed', e?.stack || e);
|
|
221
|
+
}
|
|
222
|
+
await speakText(formatRestartShutdownNotice(detail, settings.tts.edge.voice));
|
|
223
|
+
await waitEvent(bridge.player, AudioPlayerStatus.Idle, 30000).catch(() => {});
|
|
224
|
+
}
|
|
225
|
+
} catch (e) {
|
|
226
|
+
warn('shutdown voice notice failed', e?.stack || e);
|
|
227
|
+
}
|
|
228
|
+
if (pendingFallbackNoticePromises.size) {
|
|
229
|
+
try {
|
|
230
|
+
await Promise.race([
|
|
231
|
+
Promise.allSettled(Array.from(pendingFallbackNoticePromises)),
|
|
232
|
+
new Promise(resolve => setTimeout(resolve, 3000)),
|
|
233
|
+
]);
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
236
|
+
try { bridge.ttsBackend?.close?.(); } catch (e) { warn('tts backend close failed', e?.message || e); }
|
|
237
|
+
try { bridge.connection?.destroy(); } catch {}
|
|
238
|
+
try { client.destroy(); } catch {}
|
|
239
|
+
process.exit(0);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
connectTo,
|
|
244
|
+
autoJoin,
|
|
245
|
+
findVoiceChannelBySelector,
|
|
246
|
+
voiceChannelLabel,
|
|
247
|
+
resolveVoiceChannelForAttach,
|
|
248
|
+
attachVoiceChannelToTextSession,
|
|
249
|
+
gracefulShutdown,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createDiscordVoiceSetup } from './discord_voice_setup.mjs';
|
|
4
|
+
import { createBridge } from './bridge_context.mjs';
|
|
5
|
+
|
|
6
|
+
function makeDeps(overrides = {}) {
|
|
7
|
+
const bridge = createBridge();
|
|
8
|
+
return {
|
|
9
|
+
bridge,
|
|
10
|
+
client: { destroy: () => {}, guilds: { cache: new Map() } },
|
|
11
|
+
settings: { allowedUsers: new Set(), autoJoinVoiceChannels: ['general'], agent: {}, tts: { edge: { voice: 'ko-KR-x' } } },
|
|
12
|
+
ROOT: '/tmp/vc-root',
|
|
13
|
+
log: () => {}, warn: () => {},
|
|
14
|
+
speakText: async () => {},
|
|
15
|
+
waitEvent: async () => {},
|
|
16
|
+
subscribeUser: () => {},
|
|
17
|
+
pendingFallbackNoticePromises: new Set(),
|
|
18
|
+
bindProjectSessionToChannel: () => ({ slug: 's', name: 'S', voiceChannelId: '', transcriptChannelId: '' }),
|
|
19
|
+
createProjectSession: () => ({ slug: 's', name: 'S', voiceChannelId: '', transcriptChannelId: '' }),
|
|
20
|
+
resolveProjectSessionForChannel: () => null,
|
|
21
|
+
saveProjectSessionsState: () => {},
|
|
22
|
+
projectSessionsState: { channelSessions: {} },
|
|
23
|
+
invalidateBackendAdaptersForSession: () => {},
|
|
24
|
+
VOICE_CONNECT_TIMEOUT_MS: 5000,
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('createDiscordVoiceSetup exposes the expected functions', () => {
|
|
30
|
+
const setup = createDiscordVoiceSetup(makeDeps());
|
|
31
|
+
for (const name of ['connectTo', 'autoJoin', 'findVoiceChannelBySelector', 'voiceChannelLabel', 'resolveVoiceChannelForAttach', 'attachVoiceChannelToTextSession', 'gracefulShutdown']) {
|
|
32
|
+
assert.equal(typeof setup[name], 'function', `${name} is exposed`);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('voiceChannelLabel returns "없음" when no channel id is given', async () => {
|
|
37
|
+
const { voiceChannelLabel } = createDiscordVoiceSetup(makeDeps());
|
|
38
|
+
const guild = { channels: { fetch: async () => null } };
|
|
39
|
+
assert.equal(await voiceChannelLabel(guild, ''), '없음');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('voiceChannelLabel returns "지정됨" when fetch throws', async () => {
|
|
43
|
+
const { voiceChannelLabel } = createDiscordVoiceSetup(makeDeps());
|
|
44
|
+
const guild = { channels: { fetch: async () => { throw new Error('boom'); } } };
|
|
45
|
+
assert.equal(await voiceChannelLabel(guild, 'ch-id'), '지정됨');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('findVoiceChannelBySelector resolves by channel name', async () => {
|
|
49
|
+
const ch = { id: 'vc-1', name: 'General', isVoiceBased: () => true };
|
|
50
|
+
const guild = { channels: { fetch: async () => new Map([['vc-1', ch]]) } };
|
|
51
|
+
const { findVoiceChannelBySelector } = createDiscordVoiceSetup(makeDeps());
|
|
52
|
+
const out = await findVoiceChannelBySelector(guild, 'general');
|
|
53
|
+
assert.equal(out, ch);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('findVoiceChannelBySelector throws on ambiguous name', async () => {
|
|
57
|
+
const a = { id: 'vc-1', name: 'general', isVoiceBased: () => true };
|
|
58
|
+
const b = { id: 'vc-2', name: 'general', isVoiceBased: () => true };
|
|
59
|
+
const guild = { channels: { fetch: async () => new Map([['vc-1', a], ['vc-2', b]]) } };
|
|
60
|
+
const { findVoiceChannelBySelector } = createDiscordVoiceSetup(makeDeps());
|
|
61
|
+
await assert.rejects(() => findVoiceChannelBySelector(guild, 'general'), /여러 개야/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('findVoiceChannelBySelector throws when no match', async () => {
|
|
65
|
+
const guild = { channels: { fetch: async () => new Map() } };
|
|
66
|
+
const { findVoiceChannelBySelector } = createDiscordVoiceSetup(makeDeps());
|
|
67
|
+
await assert.rejects(() => findVoiceChannelBySelector(guild, 'nope'), /찾지 못했어/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('resolveVoiceChannelForAttach falls back to msg.member voice channel', async () => {
|
|
71
|
+
const ch = { id: 'vc-1', isVoiceBased: () => true };
|
|
72
|
+
const { resolveVoiceChannelForAttach } = createDiscordVoiceSetup(makeDeps());
|
|
73
|
+
const msg = { member: { voice: { channel: ch } }, guild: null };
|
|
74
|
+
const out = await resolveVoiceChannelForAttach(msg, '');
|
|
75
|
+
assert.equal(out, ch);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('autoJoin uses pickOccupiedUserVoiceChannel when one is returned', async () => {
|
|
79
|
+
// The real pickOccupiedUserVoiceChannel is module-imported; we can only stub the
|
|
80
|
+
// surrounding inputs. Verify autoJoin doesn't throw when no matching channels.
|
|
81
|
+
const deps = makeDeps();
|
|
82
|
+
const { autoJoin } = createDiscordVoiceSetup(deps);
|
|
83
|
+
await autoJoin();
|
|
84
|
+
// No channels available -> no connection, no throw.
|
|
85
|
+
assert.equal(deps.bridge.connection, null);
|
|
86
|
+
});
|
|
@@ -21,8 +21,15 @@ import path from 'node:path';
|
|
|
21
21
|
|
|
22
22
|
import { hermesProfilesRoot, hermesProfileDir, profileExists } from './hermes_profiles.mjs';
|
|
23
23
|
|
|
24
|
+
const __tempRoots = [];
|
|
25
|
+
test.after(() => {
|
|
26
|
+
for (const root of __tempRoots) try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
|
|
27
|
+
});
|
|
28
|
+
|
|
24
29
|
function tempHome() {
|
|
25
|
-
|
|
30
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-hermes-home-'));
|
|
31
|
+
__tempRoots.push(root);
|
|
32
|
+
return root;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
test('hermesProfilesRoot resolves under HOME', () => {
|
|
@@ -208,6 +215,7 @@ import { applyProjectContextToSoul, VC_SOUL_MARKER_START, VC_SOUL_MARKER_END } f
|
|
|
208
215
|
|
|
209
216
|
test('applyProjectContextToSoul appends a marker block to existing SOUL.md', () => {
|
|
210
217
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-soul-'));
|
|
218
|
+
__tempRoots.push(tmp);
|
|
211
219
|
const soulPath = path.join(tmp, 'SOUL.md');
|
|
212
220
|
const persona = 'You are Hermes Agent, an intelligent AI assistant.';
|
|
213
221
|
fs.writeFileSync(soulPath, persona);
|
|
@@ -222,6 +230,7 @@ test('applyProjectContextToSoul appends a marker block to existing SOUL.md', ()
|
|
|
222
230
|
|
|
223
231
|
test('applyProjectContextToSoul updates an existing marker block in place (idempotent)', () => {
|
|
224
232
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-soul-'));
|
|
233
|
+
__tempRoots.push(tmp);
|
|
225
234
|
const soulPath = path.join(tmp, 'SOUL.md');
|
|
226
235
|
fs.writeFileSync(soulPath, 'Persona text.');
|
|
227
236
|
applyProjectContextToSoul(soulPath, 'first context');
|
|
@@ -237,6 +246,7 @@ test('applyProjectContextToSoul updates an existing marker block in place (idemp
|
|
|
237
246
|
|
|
238
247
|
test('applyProjectContextToSoul writes a fresh SOUL.md when none exists', () => {
|
|
239
248
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-soul-'));
|
|
249
|
+
__tempRoots.push(tmp);
|
|
240
250
|
const soulPath = path.join(tmp, 'SOUL.md');
|
|
241
251
|
applyProjectContextToSoul(soulPath, 'fresh project context');
|
|
242
252
|
const out = fs.readFileSync(soulPath, 'utf8');
|
|
@@ -247,6 +257,7 @@ test('applyProjectContextToSoul writes a fresh SOUL.md when none exists', () =>
|
|
|
247
257
|
|
|
248
258
|
test('applyProjectContextToSoul is a no-op when projectContext is empty', () => {
|
|
249
259
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-soul-'));
|
|
260
|
+
__tempRoots.push(tmp);
|
|
250
261
|
const soulPath = path.join(tmp, 'SOUL.md');
|
|
251
262
|
fs.writeFileSync(soulPath, 'persona');
|
|
252
263
|
applyProjectContextToSoul(soulPath, ' ');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { languagePreset, normalizeLanguageKey } from './language_config.mjs';
|
|
2
|
+
import { normalizeTtsBackendName, SUPPORTED_TTS_BACKENDS } from './tts_settings.mjs';
|
|
2
3
|
|
|
3
4
|
export const SUPPORTED_HARNESSES = [
|
|
4
5
|
'hermes',
|
|
@@ -21,6 +22,8 @@ function clean(value, fallback = '') {
|
|
|
21
22
|
return v || fallback;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
export { SUPPORTED_TTS_BACKENDS };
|
|
26
|
+
|
|
24
27
|
export function normalizeInstallAnswers(input = {}) {
|
|
25
28
|
const harness = clean(input.harness || input.AGENT_BACKEND, 'hermes').toLowerCase();
|
|
26
29
|
const normalizedHarness = SUPPORTED_HARNESSES.includes(harness) ? harness : 'custom';
|
|
@@ -33,9 +36,7 @@ export function normalizeInstallAnswers(input = {}) {
|
|
|
33
36
|
DISCORD_ALLOWED_USERS: clean(input.allowedUsers || input.DISCORD_ALLOWED_USERS),
|
|
34
37
|
AUTO_JOIN_VOICE_CHANNELS: clean(input.autoJoinVoiceChannels || input.AUTO_JOIN_VOICE_CHANNELS, '일반,General,general'),
|
|
35
38
|
TRANSCRIPT_CHANNEL_ID: clean(input.transcriptChannelId || input.TRANSCRIPT_CHANNEL_ID),
|
|
36
|
-
TTS_BACKEND:
|
|
37
|
-
? clean(input.ttsBackend || input.TTS_BACKEND, 'edge').toLowerCase()
|
|
38
|
-
: 'edge',
|
|
39
|
+
TTS_BACKEND: normalizeTtsBackendName(input.ttsBackend || input.TTS_BACKEND, 'edge'),
|
|
39
40
|
EDGE_TTS_COMMAND: clean(input.edgeTtsCommand || input.EDGE_TTS_COMMAND || input.TTS_EDGE_COMMAND, 'edge-tts'),
|
|
40
41
|
VOICE_LANGUAGE: clean(input.voiceLanguage || input.VOICE_LANGUAGE, preset.voiceLanguage),
|
|
41
42
|
WHISPER_CPP_LANGUAGE: clean(input.whisperLanguage || input.WHISPER_CPP_LANGUAGE || input.STT_LANGUAGE, preset.sttLanguage),
|
|
@@ -59,6 +60,59 @@ export function normalizeInstallAnswers(input = {}) {
|
|
|
59
60
|
OPENVOICE_STYLE: clean(input.openvoiceStyle || input.OPENVOICE_STYLE, 'default'),
|
|
60
61
|
OPENVOICE_TIMEOUT_MS: clean(input.openvoiceTimeoutMs || input.OPENVOICE_TIMEOUT_MS, '90000'),
|
|
61
62
|
OPENVOICE_PROGRESS: input.openvoiceProgress === true || input.OPENVOICE_PROGRESS === '1' ? '1' : '0',
|
|
63
|
+
OMNIVOICE_PYTHON: clean(input.omnivoicePython || input.OMNIVOICE_PYTHON, './.venv-omnivoice/bin/python'),
|
|
64
|
+
OMNIVOICE_MODEL: clean(input.omnivoiceModel || input.OMNIVOICE_MODEL, 'k2-fsa/OmniVoice'),
|
|
65
|
+
OMNIVOICE_DEVICE: clean(input.omnivoiceDevice || input.OMNIVOICE_DEVICE, 'mps'),
|
|
66
|
+
OMNIVOICE_DTYPE: clean(input.omnivoiceDtype || input.OMNIVOICE_DTYPE, 'float16'),
|
|
67
|
+
OMNIVOICE_REF_AUDIO: clean(input.omnivoiceRefAudio || input.OMNIVOICE_REF_AUDIO || input.OPENVOICE_REF_AUDIO, './voice-samples/user-reference.wav'),
|
|
68
|
+
OMNIVOICE_REF_TEXT: clean(input.omnivoiceRefText || input.OMNIVOICE_REF_TEXT),
|
|
69
|
+
OMNIVOICE_LANGUAGE: clean(input.omnivoiceLanguage || input.OMNIVOICE_LANGUAGE, 'ko'),
|
|
70
|
+
OMNIVOICE_SPEAKER: clean(input.omnivoiceSpeaker || input.OMNIVOICE_SPEAKER),
|
|
71
|
+
OMNIVOICE_TIMEOUT_MS: clean(input.omnivoiceTimeoutMs || input.OMNIVOICE_TIMEOUT_MS, '180000'),
|
|
72
|
+
OMNIVOICE_PROGRESS: input.omnivoiceProgress === true || input.OMNIVOICE_PROGRESS === '1' ? '1' : '0',
|
|
73
|
+
QWEN3TTS_COMMAND: clean(input.qwen3TtsCommand || input.QWEN3TTS_COMMAND, 'audio'),
|
|
74
|
+
QWEN3TTS_MODE: clean(input.qwen3TtsMode || input.QWEN3TTS_MODE, 'custom'),
|
|
75
|
+
QWEN3TTS_MODEL: clean(input.qwen3TtsModel || input.QWEN3TTS_MODEL, 'customVoice'),
|
|
76
|
+
QWEN3TTS_LANGUAGE: clean(input.qwen3TtsLanguage || input.QWEN3TTS_LANGUAGE, 'korean'),
|
|
77
|
+
QWEN3TTS_SPEAKER: clean(input.qwen3TtsSpeaker || input.QWEN3TTS_SPEAKER, 'sohee'),
|
|
78
|
+
QWEN3TTS_PROGRESS: input.qwen3TtsProgress === true || input.QWEN3TTS_PROGRESS === '1' ? '1' : '0',
|
|
79
|
+
MLXAUDIO_PYTHON: clean(input.mlxAudioPython || input.MLXAUDIO_PYTHON, './.venv-mlxaudio/bin/python'),
|
|
80
|
+
MLXAUDIO_MODEL: clean(input.mlxAudioModel || input.MLXAUDIO_MODEL, 'mlx-community/Qwen3-TTS-12Hz-1.7B-Base-8bit'),
|
|
81
|
+
MLXAUDIO_VOICE: clean(input.mlxAudioVoice || input.MLXAUDIO_VOICE, 'Chelsie'),
|
|
82
|
+
MLXAUDIO_LANG_CODE: clean(input.mlxAudioLangCode || input.MLXAUDIO_LANG_CODE, 'ko'),
|
|
83
|
+
MLXAUDIO_TIMEOUT_MS: clean(input.mlxAudioTimeoutMs || input.MLXAUDIO_TIMEOUT_MS, '180000'),
|
|
84
|
+
MLXAUDIO_PROGRESS: input.mlxAudioProgress === true || input.MLXAUDIO_PROGRESS === '1' ? '1' : '0',
|
|
85
|
+
FIREREDTTS2_COMMAND: clean(input.fireRedTts2Command || input.FIREREDTTS2_COMMAND, './.local/bin/fireredtts2'),
|
|
86
|
+
FIREREDTTS2_PRETRAINED_DIR: clean(input.fireRedTts2PretrainedDir || input.FIREREDTTS2_PRETRAINED_DIR, 'pretrained_models/FireRedTTS2'),
|
|
87
|
+
FIREREDTTS2_DEVICE: clean(input.fireRedTts2Device || input.FIREREDTTS2_DEVICE, 'auto'),
|
|
88
|
+
FIREREDTTS2_GEN_TYPE: clean(input.fireRedTts2GenType || input.FIREREDTTS2_GEN_TYPE, 'monologue'),
|
|
89
|
+
FIREREDTTS2_SPEAKER: clean(input.fireRedTts2Speaker || input.FIREREDTTS2_SPEAKER, 'S1'),
|
|
90
|
+
FIREREDTTS2_PROMPT_AUDIO: clean(input.fireRedTts2PromptAudio || input.FIREREDTTS2_PROMPT_AUDIO, './voice-samples/user-reference.wav'),
|
|
91
|
+
FIREREDTTS2_PROMPT_TEXT: clean(input.fireRedTts2PromptText || input.FIREREDTTS2_PROMPT_TEXT),
|
|
92
|
+
FIREREDTTS2_BF16: input.fireRedTts2Bf16 === true || input.FIREREDTTS2_BF16 === '1' ? '1' : '0',
|
|
93
|
+
FIREREDTTS2_TIMEOUT_MS: clean(input.fireRedTts2TimeoutMs || input.FIREREDTTS2_TIMEOUT_MS, '180000'),
|
|
94
|
+
FIREREDTTS2_PROGRESS: input.fireRedTts2Progress === true || input.FIREREDTTS2_PROGRESS === '1' ? '1' : '0',
|
|
95
|
+
MOSSTTSNANO_COMMAND: clean(input.mossTtsNanoCommand || input.MOSSTTSNANO_COMMAND, './.venv-mossttsnano/bin/python'),
|
|
96
|
+
MOSSTTSNANO_SCRIPT: clean(input.mossTtsNanoScript || input.MOSSTTSNANO_SCRIPT, 'vendor/MOSS-TTS-Nano/infer.py'),
|
|
97
|
+
MOSSTTSNANO_CHECKPOINT: clean(input.mossTtsNanoCheckpoint || input.MOSSTTSNANO_CHECKPOINT, 'OpenMOSS-Team/MOSS-TTS-Nano'),
|
|
98
|
+
MOSSTTSNANO_MODE: clean(input.mossTtsNanoMode || input.MOSSTTSNANO_MODE, 'continuation'),
|
|
99
|
+
MOSSTTSNANO_DEVICE: clean(input.mossTtsNanoDevice || input.MOSSTTSNANO_DEVICE, 'auto'),
|
|
100
|
+
MOSSTTSNANO_DTYPE: clean(input.mossTtsNanoDtype || input.MOSSTTSNANO_DTYPE, 'auto'),
|
|
101
|
+
MOSSTTSNANO_PROMPT_AUDIO: clean(input.mossTtsNanoPromptAudio || input.MOSSTTSNANO_PROMPT_AUDIO, './voice-samples/user-reference.wav'),
|
|
102
|
+
MOSSTTSNANO_TIMEOUT_MS: clean(input.mossTtsNanoTimeoutMs || input.MOSSTTSNANO_TIMEOUT_MS, '120000'),
|
|
103
|
+
MOSSTTSNANO_PROGRESS: input.mossTtsNanoProgress === true || input.MOSSTTSNANO_PROGRESS === '1' ? '1' : '0',
|
|
104
|
+
NEUTTSAIR_PYTHON: clean(input.neuttsAirPython || input.NEUTTSAIR_PYTHON, './.venv-neuttsair/bin/python'),
|
|
105
|
+
NEUTTSAIR_SCRIPT: clean(input.neuttsAirScript || input.NEUTTSAIR_SCRIPT, 'integrations/neuttsair/synth.py'),
|
|
106
|
+
NEUTTSAIR_BACKBONE_REPO: clean(input.neuttsAirBackboneRepo || input.NEUTTSAIR_BACKBONE_REPO, 'neuphonic/neutts-air-q4-gguf'),
|
|
107
|
+
NEUTTSAIR_BACKBONE_DEVICE: clean(input.neuttsAirBackboneDevice || input.NEUTTSAIR_BACKBONE_DEVICE, 'mps'),
|
|
108
|
+
NEUTTSAIR_CODEC_REPO: clean(input.neuttsAirCodecRepo || input.NEUTTSAIR_CODEC_REPO, 'neuphonic/neucodec'),
|
|
109
|
+
NEUTTSAIR_CODEC_DEVICE: clean(input.neuttsAirCodecDevice || input.NEUTTSAIR_CODEC_DEVICE, 'mps'),
|
|
110
|
+
NEUTTSAIR_REF_AUDIO: clean(input.neuttsAirRefAudio || input.NEUTTSAIR_REF_AUDIO || input.OPENVOICE_REF_AUDIO, './voice-samples/user-reference.wav'),
|
|
111
|
+
NEUTTSAIR_REF_TEXT: clean(input.neuttsAirRefText || input.NEUTTSAIR_REF_TEXT),
|
|
112
|
+
NEUTTSAIR_LANGUAGE: clean(input.neuttsAirLanguage || input.NEUTTSAIR_LANGUAGE, 'en'),
|
|
113
|
+
NEUTTSAIR_SAMPLE_RATE: clean(input.neuttsAirSampleRate || input.NEUTTSAIR_SAMPLE_RATE, '24000'),
|
|
114
|
+
NEUTTSAIR_TIMEOUT_MS: clean(input.neuttsAirTimeoutMs || input.NEUTTSAIR_TIMEOUT_MS, '120000'),
|
|
115
|
+
NEUTTSAIR_PROGRESS: input.neuttsAirProgress === true || input.NEUTTSAIR_PROGRESS === '1' ? '1' : '0',
|
|
62
116
|
REQUIRE_WAKE_WORD: input.requireWakeWord === true || input.REQUIRE_WAKE_WORD === '1' ? '1' : '0',
|
|
63
117
|
MIN_UTTERANCE_SECONDS: clean(input.minUtteranceSeconds || input.MIN_UTTERANCE_SECONDS, '1.0'),
|
|
64
118
|
UTTERANCE_IDLE_MS: clean(input.utteranceIdleMs || input.UTTERANCE_IDLE_MS, '4500'),
|
|
@@ -136,6 +190,59 @@ export function buildEnvFile(values = {}) {
|
|
|
136
190
|
'OPENVOICE_STYLE',
|
|
137
191
|
'OPENVOICE_TIMEOUT_MS',
|
|
138
192
|
'OPENVOICE_PROGRESS',
|
|
193
|
+
'OMNIVOICE_PYTHON',
|
|
194
|
+
'OMNIVOICE_MODEL',
|
|
195
|
+
'OMNIVOICE_DEVICE',
|
|
196
|
+
'OMNIVOICE_DTYPE',
|
|
197
|
+
'OMNIVOICE_REF_AUDIO',
|
|
198
|
+
'OMNIVOICE_REF_TEXT',
|
|
199
|
+
'OMNIVOICE_LANGUAGE',
|
|
200
|
+
'OMNIVOICE_SPEAKER',
|
|
201
|
+
'OMNIVOICE_TIMEOUT_MS',
|
|
202
|
+
'OMNIVOICE_PROGRESS',
|
|
203
|
+
'QWEN3TTS_COMMAND',
|
|
204
|
+
'QWEN3TTS_MODE',
|
|
205
|
+
'QWEN3TTS_MODEL',
|
|
206
|
+
'QWEN3TTS_LANGUAGE',
|
|
207
|
+
'QWEN3TTS_SPEAKER',
|
|
208
|
+
'QWEN3TTS_PROGRESS',
|
|
209
|
+
'MLXAUDIO_PYTHON',
|
|
210
|
+
'MLXAUDIO_MODEL',
|
|
211
|
+
'MLXAUDIO_VOICE',
|
|
212
|
+
'MLXAUDIO_LANG_CODE',
|
|
213
|
+
'MLXAUDIO_TIMEOUT_MS',
|
|
214
|
+
'MLXAUDIO_PROGRESS',
|
|
215
|
+
'FIREREDTTS2_COMMAND',
|
|
216
|
+
'FIREREDTTS2_PRETRAINED_DIR',
|
|
217
|
+
'FIREREDTTS2_DEVICE',
|
|
218
|
+
'FIREREDTTS2_GEN_TYPE',
|
|
219
|
+
'FIREREDTTS2_SPEAKER',
|
|
220
|
+
'FIREREDTTS2_PROMPT_AUDIO',
|
|
221
|
+
'FIREREDTTS2_PROMPT_TEXT',
|
|
222
|
+
'FIREREDTTS2_BF16',
|
|
223
|
+
'FIREREDTTS2_TIMEOUT_MS',
|
|
224
|
+
'FIREREDTTS2_PROGRESS',
|
|
225
|
+
'MOSSTTSNANO_COMMAND',
|
|
226
|
+
'MOSSTTSNANO_SCRIPT',
|
|
227
|
+
'MOSSTTSNANO_CHECKPOINT',
|
|
228
|
+
'MOSSTTSNANO_MODE',
|
|
229
|
+
'MOSSTTSNANO_DEVICE',
|
|
230
|
+
'MOSSTTSNANO_DTYPE',
|
|
231
|
+
'MOSSTTSNANO_PROMPT_AUDIO',
|
|
232
|
+
'MOSSTTSNANO_TIMEOUT_MS',
|
|
233
|
+
'MOSSTTSNANO_PROGRESS',
|
|
234
|
+
'NEUTTSAIR_PYTHON',
|
|
235
|
+
'NEUTTSAIR_SCRIPT',
|
|
236
|
+
'NEUTTSAIR_BACKBONE_REPO',
|
|
237
|
+
'NEUTTSAIR_BACKBONE_DEVICE',
|
|
238
|
+
'NEUTTSAIR_CODEC_REPO',
|
|
239
|
+
'NEUTTSAIR_CODEC_DEVICE',
|
|
240
|
+
'NEUTTSAIR_REF_AUDIO',
|
|
241
|
+
'NEUTTSAIR_REF_TEXT',
|
|
242
|
+
'NEUTTSAIR_LANGUAGE',
|
|
243
|
+
'NEUTTSAIR_SAMPLE_RATE',
|
|
244
|
+
'NEUTTSAIR_TIMEOUT_MS',
|
|
245
|
+
'NEUTTSAIR_PROGRESS',
|
|
139
246
|
'REQUIRE_WAKE_WORD',
|
|
140
247
|
'MIN_UTTERANCE_SECONDS',
|
|
141
248
|
'UTTERANCE_IDLE_MS',
|
|
@@ -61,6 +61,10 @@ test('normalizeInstallAnswers maps supported harnesses to backend env', () => {
|
|
|
61
61
|
assert.equal(answers.SUPERTONIC_COMMAND, 'supertonic');
|
|
62
62
|
assert.equal(answers.SUPERTONIC_SPEED, '1.0');
|
|
63
63
|
assert.equal(answers.SUPERTONIC_LANGUAGE, 'ko');
|
|
64
|
+
assert.equal(answers.OMNIVOICE_PYTHON, './.venv-omnivoice/bin/python');
|
|
65
|
+
assert.equal(answers.OMNIVOICE_MODEL, 'k2-fsa/OmniVoice');
|
|
66
|
+
assert.equal(answers.OMNIVOICE_REF_AUDIO, './voice-samples/user-reference.wav');
|
|
67
|
+
assert.equal(answers.OMNIVOICE_LANGUAGE, 'ko');
|
|
64
68
|
assert.equal(answers.OPENVOICE_LANGUAGE, 'KR');
|
|
65
69
|
assert.equal(answers.REQUIRE_WAKE_WORD, '0');
|
|
66
70
|
assert.equal(answers.UTTERANCE_IDLE_MS, '4500');
|
|
@@ -87,6 +91,8 @@ test('buildEnvFile writes configurable CLI harness and Discord settings without
|
|
|
87
91
|
TTS_VOLUME: '1.6',
|
|
88
92
|
REQUIRE_WAKE_WORD: '0',
|
|
89
93
|
OPENVOICE_REF_AUDIO: './voice-samples/me.wav',
|
|
94
|
+
OMNIVOICE_PYTHON: './.venv-omnivoice/bin/python',
|
|
95
|
+
OMNIVOICE_REF_AUDIO: './voice-samples/omni.wav',
|
|
90
96
|
});
|
|
91
97
|
const parsed = parseKeyValueEnv(envText);
|
|
92
98
|
|
|
@@ -102,6 +108,8 @@ test('buildEnvFile writes configurable CLI harness and Discord settings without
|
|
|
102
108
|
assert.equal(parsed.SUPERTONIC_STEPS, '3');
|
|
103
109
|
assert.equal(parsed.TTS_VOLUME, '1.6');
|
|
104
110
|
assert.equal(parsed.OPENVOICE_REF_AUDIO, './voice-samples/me.wav');
|
|
111
|
+
assert.equal(parsed.OMNIVOICE_PYTHON, './.venv-omnivoice/bin/python');
|
|
112
|
+
assert.equal(parsed.OMNIVOICE_REF_AUDIO, './voice-samples/omni.wav');
|
|
105
113
|
assert.equal(parsed.DISCORD_BOT_TOKEN, 'token-abc');
|
|
106
114
|
assert.equal(parsed.REQUIRE_WAKE_WORD, '0');
|
|
107
115
|
});
|
|
@@ -6,9 +6,15 @@ import path from 'node:path';
|
|
|
6
6
|
|
|
7
7
|
import { checkInstanceConfigs, tokenFingerprint } from './instance_doctor.mjs';
|
|
8
8
|
|
|
9
|
+
const __tempRoots = [];
|
|
10
|
+
test.after(() => {
|
|
11
|
+
for (const root of __tempRoots) try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
|
|
12
|
+
});
|
|
13
|
+
|
|
9
14
|
function tempRepo() {
|
|
10
15
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-instance-doctor-'));
|
|
11
16
|
fs.mkdirSync(path.join(root, 'instances'), { recursive: true });
|
|
17
|
+
__tempRoots.push(root);
|
|
12
18
|
return root;
|
|
13
19
|
}
|
|
14
20
|
|
|
@@ -68,6 +74,7 @@ test('checkInstanceConfigs treats omitted runtime paths as effective default col
|
|
|
68
74
|
|
|
69
75
|
test('checkInstanceConfigs warns when HERMES_HOME points at a missing profile', () => {
|
|
70
76
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-doctor-'));
|
|
77
|
+
__tempRoots.push(root);
|
|
71
78
|
const instancesDir = path.join(root, 'instances');
|
|
72
79
|
fs.mkdirSync(instancesDir, { recursive: true });
|
|
73
80
|
fs.writeFileSync(path.join(instancesDir, 'llm-wiki.env'), [
|
|
@@ -84,6 +91,7 @@ test('checkInstanceConfigs warns when HERMES_HOME points at a missing profile',
|
|
|
84
91
|
|
|
85
92
|
test('checkInstanceConfigs errors when profile terminal.cwd differs from AGENT_CWD', () => {
|
|
86
93
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-doctor-'));
|
|
94
|
+
__tempRoots.push(root);
|
|
87
95
|
const instancesDir = path.join(root, 'instances');
|
|
88
96
|
const profileDir = path.join(root, '.hermes', 'profiles', 'llm-wiki');
|
|
89
97
|
fs.mkdirSync(instancesDir, { recursive: true });
|
|
@@ -106,6 +114,7 @@ test('checkInstanceConfigs errors when profile terminal.cwd differs from AGENT_C
|
|
|
106
114
|
|
|
107
115
|
test('checkInstanceConfigs reads only terminal.cwd, ignoring sibling cwd keys', () => {
|
|
108
116
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-doctor-'));
|
|
117
|
+
__tempRoots.push(root);
|
|
109
118
|
const instancesDir = path.join(root, 'instances');
|
|
110
119
|
const profileDir = path.join(root, '.hermes', 'profiles', 'llm-wiki');
|
|
111
120
|
fs.mkdirSync(instancesDir, { recursive: true });
|
|
@@ -14,8 +14,15 @@ import {
|
|
|
14
14
|
statusForInstance,
|
|
15
15
|
} from './instances.mjs';
|
|
16
16
|
|
|
17
|
+
const __tempRoots = [];
|
|
18
|
+
test.after(() => {
|
|
19
|
+
for (const root of __tempRoots) try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
|
|
20
|
+
});
|
|
21
|
+
|
|
17
22
|
function tempDir() {
|
|
18
|
-
|
|
23
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-instances-'));
|
|
24
|
+
__tempRoots.push(root);
|
|
25
|
+
return root;
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
test('listInstanceEnvFiles finds env files except example', () => {
|