agentvibes 5.11.2 → 5.12.0-alpha.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/.agentvibes/config.json +1 -15
- package/.claude/activation-instructions +54 -54
- package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
- package/.claude/commands/agent-vibes-rdp.md +24 -24
- package/.claude/config/audio-effects.cfg +5 -5
- package/.claude/config/background-music-enabled.txt +1 -1
- package/.claude/docs/TERMUX_SETUP.md +408 -408
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/audio-processor.sh +1 -1
- package/.claude/hooks/background-music-manager.sh +2 -1
- package/.claude/hooks/bmad-speak-enhanced.sh +37 -22
- package/.claude/hooks/bmad-speak.sh +26 -8
- package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +1 -1
- package/.claude/hooks/play-tts-kokoro.sh +9 -0
- package/.claude/hooks/play-tts-piper.sh +13 -1
- package/.claude/hooks/play-tts-ssh-remote.sh +78 -5
- package/.claude/hooks/play-tts-termux-ssh.sh +1 -1
- package/.claude/hooks/play-tts.sh +85 -4
- package/.claude/hooks-windows/background-music-manager.ps1 +2 -1
- package/.claude/hooks-windows/play-tts-kokoro.ps1 +14 -0
- package/.claude/hooks-windows/play-tts.ps1 +163 -30
- package/.claude/hooks-windows/voice-manager-windows.ps1 +101 -1
- package/.clawdbot/README.md +105 -105
- package/LICENSE +190 -190
- package/RELEASE_NOTES.md +29 -0
- package/WINDOWS-SETUP.md +208 -208
- package/bin/agent-vibes +39 -39
- package/bin/mcp-server.js +121 -121
- package/bin/mcp-server.sh +6 -19
- package/bin/resolve-utterance.js +137 -0
- package/bin/test-bmad-pr +78 -78
- package/mcp-server/QUICK_START.md +203 -203
- package/mcp-server/README.md +345 -345
- package/mcp-server/examples/claude_desktop_config.json +11 -11
- package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
- package/mcp-server/examples/custom_instructions.md +169 -169
- package/mcp-server/pyproject.toml +52 -52
- package/mcp-server/requirements.txt +2 -2
- package/mcp-server/server.py +280 -99
- package/mcp-server/test_mcp_correctness.py +486 -0
- package/mcp-server/test_server.py +395 -395
- package/mcp-server/test_windows_script_parity.py +341 -336
- package/package.json +3 -2
- package/setup-windows.ps1 +867 -815
- package/src/cli/list-personalities.js +110 -110
- package/src/cli/list-voices.js +114 -114
- package/src/commands/bmad-voices.js +394 -394
- package/src/console/app.js +13 -2
- package/src/console/brand-colors.js +13 -13
- package/src/console/constants/personalities.js +44 -44
- package/src/console/tabs/agents-tab.js +82 -9
- package/src/console/tabs/help-tab.js +314 -314
- package/src/console/tabs/music-tab.js +7 -1
- package/src/console/tabs/readme-tab.js +272 -272
- package/src/console/tabs/settings-tab.js +29 -1
- package/src/console/tabs/setup-tab.js +4 -4
- package/src/console/widgets/destroy-list.js +25 -25
- package/src/console/widgets/notice.js +55 -55
- package/src/console/widgets/track-picker.js +3 -3
- package/src/i18n/de.js +0 -1
- package/src/i18n/en.js +0 -1
- package/src/i18n/es.js +0 -1
- package/src/i18n/fr.js +0 -1
- package/src/i18n/hi.js +0 -1
- package/src/i18n/ja.js +0 -1
- package/src/i18n/ko.js +0 -1
- package/src/i18n/pt.js +0 -1
- package/src/i18n/zh-CN.js +0 -1
- package/src/installer/language-screen.js +31 -31
- package/src/installer/music-file-input.js +304 -304
- package/src/installer.js +205 -15
- package/src/services/config-service.js +264 -264
- package/src/services/language-service.js +47 -47
- package/src/services/llm-provider-service.js +7 -7
- package/src/services/utterance-loader.js +280 -0
- package/src/services/utterance-resolver.js +526 -0
- package/src/utils/audio-format-validator.js +277 -277
- package/src/utils/dependency-checker.js +469 -469
- package/src/utils/file-ownership-verifier.js +358 -358
- package/src/utils/music-file-validator.js +285 -285
- package/src/utils/preview-list-prompt.js +144 -144
- package/src/utils/secure-music-storage.js +412 -412
- package/templates/agentvibes-receiver.sh +74 -12
- package/voice-assignments.json +8245 -0
- package/.agentvibes/install-manifest.json +0 -342
- package/.claude/config/background-music-position.txt +0 -27
- package/.claude/config/background-music-volume.txt +0 -1
- package/.claude/config/background-music.cfg +0 -1
- package/.claude/config/background-music.txt +0 -1
- package/.claude/config/language.txt +0 -1
- package/.claude/config/personality.txt +0 -1
- package/.claude/config/tts-speech-rate.txt +0 -1
- package/.claude/config/tts-verbosity.txt +0 -1
- package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +0 -119
- package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +0 -153
- package/.claude/piper-voices-dir.txt +0 -1
- package/.mcp.json +0 -22
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: src/services/utterance-resolver.js
|
|
3
|
+
*
|
|
4
|
+
* AgentVibes — The Utterance Resolver (Story AVI-S8.5).
|
|
5
|
+
*
|
|
6
|
+
* SINGLE SOURCE OF TRUTH for how an utterance is spoken. AgentVibes historically
|
|
7
|
+
* decided voice/engine/transport/music/volume/etc. independently in four forked
|
|
8
|
+
* chains (bash play-tts.sh, PowerShell play-tts.ps1, the SSH sender, and the MCP
|
|
9
|
+
* server). They drifted, and the drift is where audio dies — nearly every entry
|
|
10
|
+
* in this project's regression history traces to that fork.
|
|
11
|
+
*
|
|
12
|
+
* This module reads a NORMALIZED bag of raw inputs ONCE, applies ONE documented
|
|
13
|
+
* precedence table, and emits a flat `UtterancePlan` the players execute verbatim
|
|
14
|
+
* (they read no config themselves). Every precedence decision is one function with
|
|
15
|
+
* one regression-test row. See docs/implementation-artifacts/8-5-precedence-map.md
|
|
16
|
+
* for the contract this implements.
|
|
17
|
+
*
|
|
18
|
+
* DESIGN: `resolveUtterance(inputs)` is a PURE function — no filesystem, no env
|
|
19
|
+
* reads, no clock. A thin loader (gatherInputs, Stage 2) collects the raw values;
|
|
20
|
+
* this module only decides. That keeps every landmine a testable table row.
|
|
21
|
+
*
|
|
22
|
+
* @patterns Pure resolver; data-driven precedence; frozen plan object.
|
|
23
|
+
* @related play-tts.sh, play-tts.ps1, play-tts-ssh-remote.sh, mcp-server/server.py
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A Kokoro voice id looks like `af_river`, `am_michael` (2-letter lang/gender
|
|
30
|
+
* prefix, then lowercase). Forces the kokoro engine (R1). Real kokoro ids use
|
|
31
|
+
* the `af_ am_ bf_ bm_` prefixes (kokoro-tts.py); piper ids carry an UPPERCASE
|
|
32
|
+
* locale + a hyphen (`en_US-amy-medium`) so they can never match this.
|
|
33
|
+
*/
|
|
34
|
+
const KOKORO_VOICE_RE = /^[a-z]{2}_[a-z0-9_]+$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A Piper voice id looks like `en_US-amy-medium` (lowercase lang, UPPERCASE
|
|
38
|
+
* region, hyphen) or a libritts multi-speaker `model::Speaker` form. Forces the
|
|
39
|
+
* piper engine (F4 — the mirror of the kokoro rule; how learning/mixed mode
|
|
40
|
+
* picks piper for a target-language voice even when the active engine is kokoro).
|
|
41
|
+
*/
|
|
42
|
+
const PIPER_VOICE_RE = /^[a-z]{2}_[A-Z]{2}-/;
|
|
43
|
+
|
|
44
|
+
/** Engines AgentVibes can route to. */
|
|
45
|
+
const ENGINES = ['piper', 'kokoro', 'elevenlabs', 'macos', 'soprano', 'sapi'];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Alias spellings that real config carries (per-LLM ENGINE column, FORCE_PROVIDER
|
|
49
|
+
* allowlist, SSH-forwarded provider). Without this, `windows-sapi` fell through
|
|
50
|
+
* the ENGINES check and was silently downgraded to piper (F5).
|
|
51
|
+
*/
|
|
52
|
+
const ENGINE_ALIASES = {
|
|
53
|
+
'windows-sapi': 'sapi',
|
|
54
|
+
'windows-piper': 'piper',
|
|
55
|
+
'macos-say': 'macos',
|
|
56
|
+
say: 'macos',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Named provenance of the explicit-voice slot (F1). Only 'llm-echo' is demoted. */
|
|
60
|
+
const VOICE_SOURCES = ['llm-echo', 'user-explicit', 'agent-profile', 'translation-target', 'audition'];
|
|
61
|
+
|
|
62
|
+
/** Transports. `local` plays here; the others hand off to a remote receiver. */
|
|
63
|
+
const TRANSPORTS = ['local', 'ssh-remote', 'agentvibes-receiver', 'termux-ssh'];
|
|
64
|
+
const REMOTE_TRANSPORTS = new Set(['ssh-remote', 'agentvibes-receiver', 'termux-ssh']);
|
|
65
|
+
|
|
66
|
+
/** The one canonical background-music volume default (feeds the 8.6 constants generator). */
|
|
67
|
+
const DEFAULT_BG_VOLUME = 0.20;
|
|
68
|
+
|
|
69
|
+
/** Default track when music is enabled but no track is chosen anywhere. */
|
|
70
|
+
const DEFAULT_BG_TRACK = 'agent_vibes_bachata_v1_loop.mp3';
|
|
71
|
+
|
|
72
|
+
const DEFAULT_PERSONALITY = 'normal';
|
|
73
|
+
const DEFAULT_LANGUAGE = 'english';
|
|
74
|
+
const DEFAULT_SPEED = 1.0;
|
|
75
|
+
|
|
76
|
+
/** First non-empty (non-null, non-undefined, non-'' after trim) value, else undefined. */
|
|
77
|
+
function firstNonEmpty(...vals) {
|
|
78
|
+
for (const v of vals) {
|
|
79
|
+
if (v === null || v === undefined) continue;
|
|
80
|
+
if (typeof v === 'string' && v.trim() === '') continue;
|
|
81
|
+
return v;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isKokoroVoice(voice) {
|
|
87
|
+
return typeof voice === 'string' && KOKORO_VOICE_RE.test(voice.trim());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isPiperVoice(voice) {
|
|
91
|
+
return typeof voice === 'string' && (PIPER_VOICE_RE.test(voice.trim()) || voice.includes('::'));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Coerce a config value to boolean (F8). Inputs originate as env/file STRINGS,
|
|
96
|
+
* so `Boolean("false")` / `Boolean("0")` would be wrongly truthy. Treat the
|
|
97
|
+
* literal falsey strings as false; everything else present is true.
|
|
98
|
+
*/
|
|
99
|
+
function toBool(v) {
|
|
100
|
+
if (v === true) return true;
|
|
101
|
+
if (v === false || v === null || v === undefined) return false;
|
|
102
|
+
const s = String(v).trim().toLowerCase();
|
|
103
|
+
return !(s === '' || s === 'false' || s === '0' || s === 'no' || s === 'off');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Recursively freeze a plain object so no player can mutate the plan in place (F9). */
|
|
107
|
+
function deepFreeze(obj) {
|
|
108
|
+
if (obj && typeof obj === 'object' && !Object.isFrozen(obj)) {
|
|
109
|
+
Object.freeze(obj);
|
|
110
|
+
for (const k of Object.keys(obj)) deepFreeze(obj[k]);
|
|
111
|
+
}
|
|
112
|
+
return obj;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Map an engine value (incl. alias spellings) to a canonical engine, else null (F5). */
|
|
116
|
+
function normalizeEngine(raw) {
|
|
117
|
+
if (raw === null || raw === undefined) return null;
|
|
118
|
+
const s = String(raw).trim().toLowerCase();
|
|
119
|
+
if (s === '') return null;
|
|
120
|
+
const canon = ENGINE_ALIASES[s] || s;
|
|
121
|
+
return ENGINES.includes(canon) ? canon : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Clamp a number into 0..1. */
|
|
125
|
+
function clamp01(n) {
|
|
126
|
+
if (!Number.isFinite(n)) return null;
|
|
127
|
+
if (n < 0) return 0;
|
|
128
|
+
if (n > 1) return 1;
|
|
129
|
+
return n;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Normalize a possibly-percent volume to a 0..1 fraction (used for the per-LLM
|
|
134
|
+
* BGVOL column and env override, which real chains treat as percent-or-fraction).
|
|
135
|
+
* Landmine C#7 / R9: "70" is 70% (0.70), never a 7000% gain. Fraction-native and
|
|
136
|
+
* percent-native sources are converted explicitly by the caller; this is the
|
|
137
|
+
* heuristic path for the ambiguous columns plus a final clamp.
|
|
138
|
+
*/
|
|
139
|
+
function normalizeVolume(raw, fallback = DEFAULT_BG_VOLUME) {
|
|
140
|
+
const n = typeof raw === 'number' ? raw : parseFloat(raw);
|
|
141
|
+
if (!Number.isFinite(n)) return fallback;
|
|
142
|
+
if (n <= 0) return 0;
|
|
143
|
+
if (n <= 1) return n;
|
|
144
|
+
if (n <= 100) return n / 100;
|
|
145
|
+
return 1; // anything above 100 → full scale, never a runaway gain
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A percent-native source (agent profile 0-100): always divide by 100, then clamp. */
|
|
149
|
+
function percentToFraction(raw) {
|
|
150
|
+
const n = typeof raw === 'number' ? raw : parseFloat(raw);
|
|
151
|
+
if (!Number.isFinite(n)) return null;
|
|
152
|
+
return clamp01(n / 100);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A fraction-native source (background-music-volume.txt, env): clamp only. */
|
|
156
|
+
function fractionVolume(raw) {
|
|
157
|
+
const n = typeof raw === 'number' ? raw : parseFloat(raw);
|
|
158
|
+
if (!Number.isFinite(n)) return null;
|
|
159
|
+
return clamp01(n);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* ------------------------------------------------------------------ *
|
|
163
|
+
* Per-field resolvers. Each is ONE decision = ONE regression-test row.
|
|
164
|
+
* `inputs` is the normalized bag; see gatherInputs / the schema below.
|
|
165
|
+
* ------------------------------------------------------------------ */
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* voice — the per-LLM row voice wins ONLY when the explicit voice is an LLM echo
|
|
169
|
+
* (R2; memory feedback_per_llm_voice_wins_over_explicit — LLMs parrot back the
|
|
170
|
+
* voice they saw in get_config, which otherwise defeats per-LLM routing).
|
|
171
|
+
*
|
|
172
|
+
* F1: this demotion is applied ONLY for `voiceSource === 'llm-echo'` (the default
|
|
173
|
+
* hook path). Every OTHER named source is a GENUINE explicit choice that must win:
|
|
174
|
+
* - 'user-explicit' MCP text_to_speech(voice=…), /agent-vibes:sample
|
|
175
|
+
* - 'agent-profile' BMAD party mode (each agent its own voice)
|
|
176
|
+
* - 'translation-target' the target-language voice in learning/translate mode
|
|
177
|
+
* - 'audition' voice-browser preview
|
|
178
|
+
* Otherwise falls back to per-LLM voice, then the provider's stored voice file.
|
|
179
|
+
*/
|
|
180
|
+
function resolveVoiceInfo(inputs) {
|
|
181
|
+
const { explicitVoice, perLlmVoice, providerVoice } = inputs;
|
|
182
|
+
const source = inputs.voiceSource || 'llm-echo';
|
|
183
|
+
const hasExplicit = firstNonEmpty(explicitVoice) !== undefined;
|
|
184
|
+
|
|
185
|
+
// `isOverride` marks a voice that came from a REAL override source (a genuine
|
|
186
|
+
// explicit pick or a per-LLM row) vs. merely the provider's stored voice file.
|
|
187
|
+
// Players adopt an override into their VOICE_OVERRIDE arg; a NON-override voice
|
|
188
|
+
// is left for the provider script's own file+model+speaker resolution (F-2 —
|
|
189
|
+
// adopting the provider-file voice as an "explicit override" skips piper's
|
|
190
|
+
// multi-speaker model/speaker-id lookup and plays speaker 0).
|
|
191
|
+
if (hasExplicit) {
|
|
192
|
+
if (source === 'llm-echo') {
|
|
193
|
+
if (firstNonEmpty(perLlmVoice) !== undefined) {
|
|
194
|
+
return { voice: String(perLlmVoice).trim(), isOverride: true }; // per-LLM wins over echo (R2)
|
|
195
|
+
}
|
|
196
|
+
// A bare LLM echo that just repeats the provider's stored voice is NOT a
|
|
197
|
+
// real override — mark it non-override so the provider script keeps its own
|
|
198
|
+
// file+model+speaker-id resolution (F-2: forcing it as an explicit override
|
|
199
|
+
// skips piper's multi-speaker lookup and plays speaker 0).
|
|
200
|
+
const ev = String(explicitVoice).trim();
|
|
201
|
+
const pv = firstNonEmpty(providerVoice);
|
|
202
|
+
if (pv !== undefined && ev === String(pv).trim()) {
|
|
203
|
+
return { voice: ev, isOverride: false };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { voice: String(explicitVoice).trim(), isOverride: true }; // genuine explicit pick
|
|
207
|
+
}
|
|
208
|
+
if (firstNonEmpty(perLlmVoice) !== undefined) {
|
|
209
|
+
return { voice: String(perLlmVoice).trim(), isOverride: true }; // per-LLM row voice
|
|
210
|
+
}
|
|
211
|
+
const pv = firstNonEmpty(providerVoice);
|
|
212
|
+
return { voice: pv === undefined ? '' : String(pv).trim(), isOverride: false }; // provider file fallback
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Back-compat string accessor (voice only). */
|
|
216
|
+
function resolveVoice(inputs) {
|
|
217
|
+
return resolveVoiceInfo(inputs).voice;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* engine — voice/engine coupling first (both platforms):
|
|
222
|
+
* - a Kokoro-shaped voice forces kokoro (R1; project_voice_engine_coupling)
|
|
223
|
+
* - a Piper-shaped voice forces piper (F4; mixed/learning mode)
|
|
224
|
+
* Then it depends on transport:
|
|
225
|
+
* - REMOTE: the receiver decides. Chain is forceProvider > receiverProvider,
|
|
226
|
+
* else `null` = "receiver picks from the forwarded voice" (F3). The per-LLM
|
|
227
|
+
* ENGINE column is NOT applied under transport (the local column doesn't
|
|
228
|
+
* describe the remote box).
|
|
229
|
+
* - LOCAL: per-LLM ENGINE column > forceProvider > provider file > piper.
|
|
230
|
+
* All engine values pass through alias normalization (F5: windows-sapi → sapi).
|
|
231
|
+
*/
|
|
232
|
+
function resolveEngine(inputs, voice, transport) {
|
|
233
|
+
if (isKokoroVoice(voice)) return 'kokoro';
|
|
234
|
+
if (isPiperVoice(voice)) return 'piper';
|
|
235
|
+
|
|
236
|
+
const isRemote = REMOTE_TRANSPORTS.has(transport);
|
|
237
|
+
if (isRemote) {
|
|
238
|
+
const remote = firstNonEmpty(inputs.forceProvider, inputs.receiverProvider);
|
|
239
|
+
return normalizeEngine(remote); // may be null → receiver decides from the voice
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const local = firstNonEmpty(inputs.perLlmEngine, inputs.forceProvider, inputs.providerEngine);
|
|
243
|
+
return normalizeEngine(local) || 'piper';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* transport — explicit transport in the provider file wins; else a per-LLM
|
|
248
|
+
* transport entry with mode==remote; else local.
|
|
249
|
+
*/
|
|
250
|
+
function resolveTransport(inputs) {
|
|
251
|
+
const explicit = firstNonEmpty(inputs.providerTransport);
|
|
252
|
+
if (explicit && REMOTE_TRANSPORTS.has(explicit)) return explicit;
|
|
253
|
+
if (inputs.perLlmTransportMode === 'remote') {
|
|
254
|
+
return firstNonEmpty(inputs.perLlmTransportKind, 'ssh-remote');
|
|
255
|
+
}
|
|
256
|
+
return 'local';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* sshTarget — resolves the remote endpoint and, critically, whether to pass a
|
|
261
|
+
* `-p` port arg. Port 22 MUST be omitted so an ~/.ssh/config alias's real port
|
|
262
|
+
* is honored (R4/R5; memory: project_ssh_port_alias_override).
|
|
263
|
+
*/
|
|
264
|
+
function resolveSshTarget(inputs, transport) {
|
|
265
|
+
if (!REMOTE_TRANSPORTS.has(transport)) return null;
|
|
266
|
+
const host = firstNonEmpty(inputs.sshHostEnv, inputs.sshHostConfig, inputs.sshHostLegacy);
|
|
267
|
+
if (host === undefined) return null;
|
|
268
|
+
const portRaw = firstNonEmpty(inputs.sshPortEnv, inputs.sshPortConfig);
|
|
269
|
+
const port = portRaw === undefined ? '22' : String(portRaw).trim();
|
|
270
|
+
return {
|
|
271
|
+
host: String(host).trim(),
|
|
272
|
+
port,
|
|
273
|
+
// Landmine: only pass -p when the port is a real, non-default numeric port.
|
|
274
|
+
// Port 22 is omitted so an ~/.ssh/config alias's real port is honored; a
|
|
275
|
+
// non-numeric value is never passed (F12).
|
|
276
|
+
passPortFlag: port !== '' && port !== '22' && /^\d+$/.test(port),
|
|
277
|
+
key: firstNonEmpty(inputs.sshKeyEnv, inputs.sshKeyConfig) || null,
|
|
278
|
+
connType: firstNonEmpty(inputs.sshConnType) || 'ssh',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* music — {enabled, track, volume}. Per-agent profile can enable music even when
|
|
284
|
+
* the global switch is off (R10). Volume defaults to 0.20 and is normalized
|
|
285
|
+
* (R8/R9). Track: custom-dir newest > test track > profile > per-LLM > default
|
|
286
|
+
* file > bachata.
|
|
287
|
+
*/
|
|
288
|
+
function resolveMusic(inputs) {
|
|
289
|
+
// enabled: a remote override is authoritative when present (force on/off).
|
|
290
|
+
// Otherwise music plays if ANY source enables it — OR semantics matching
|
|
291
|
+
// audio-processor.sh's `_bg_allowed` (global OR per-agent): a per-agent
|
|
292
|
+
// profile can turn music ON over a global-off (R10), but a profile's own
|
|
293
|
+
// "false" must NOT disable a global-on. Booleans are string-coerced (F8).
|
|
294
|
+
let enabled;
|
|
295
|
+
if (inputs.overrideMusicEnabled !== undefined) {
|
|
296
|
+
enabled = toBool(inputs.overrideMusicEnabled);
|
|
297
|
+
} else if (firstNonEmpty(inputs.overrideTrack) !== undefined) {
|
|
298
|
+
enabled = true;
|
|
299
|
+
} else {
|
|
300
|
+
enabled = toBool(inputs.profileMusicEnabled)
|
|
301
|
+
|| firstNonEmpty(inputs.perLlmBgFile) !== undefined
|
|
302
|
+
|| toBool(inputs.globalMusicEnabled);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// volume: each source carries a KNOWN unit (F7) — profile & per-LLM columns are
|
|
306
|
+
// percent (0-100), the volume file & env override are fractions. First present
|
|
307
|
+
// source wins, converted by its own unit; default 0.20.
|
|
308
|
+
let volume = DEFAULT_BG_VOLUME;
|
|
309
|
+
const volSource = [
|
|
310
|
+
[inputs.overrideVolume, fractionVolume],
|
|
311
|
+
[inputs.profileVolume, percentToFraction],
|
|
312
|
+
[inputs.perLlmBgVolume, normalizeVolume], // ambiguous column: percent-or-fraction heuristic
|
|
313
|
+
[inputs.bgVolumeFile, fractionVolume],
|
|
314
|
+
].find(([v]) => v !== null && v !== undefined && !(typeof v === 'string' && v.trim() === ''));
|
|
315
|
+
if (volSource) {
|
|
316
|
+
const conv = volSource[1](volSource[0]);
|
|
317
|
+
if (conv !== null && conv !== undefined) volume = conv;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// F11: volume normalizing to 0 means music-off (avoids 2s of dead air the mix
|
|
321
|
+
// pipeline inserts for an "enabled but silent" track). Match audio-processor.sh.
|
|
322
|
+
if (volume === 0) enabled = false;
|
|
323
|
+
|
|
324
|
+
let track = null;
|
|
325
|
+
if (enabled) {
|
|
326
|
+
track = firstNonEmpty(
|
|
327
|
+
inputs.overrideTrack, // remote/party sender's requested track (F2) — top of chain
|
|
328
|
+
inputs.customMusicNewest,
|
|
329
|
+
inputs.testTrack,
|
|
330
|
+
inputs.profileTrack,
|
|
331
|
+
inputs.perLlmBgFile,
|
|
332
|
+
inputs.bgTrackFile,
|
|
333
|
+
DEFAULT_BG_TRACK,
|
|
334
|
+
) || null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { enabled, track: enabled ? track : null, volume };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** reverb — one-shot override > per-LLM preset > reverb-level file > off. */
|
|
341
|
+
function resolveReverb(inputs) {
|
|
342
|
+
return firstNonEmpty(
|
|
343
|
+
inputs.reverbOverride,
|
|
344
|
+
inputs.perLlmReverb,
|
|
345
|
+
inputs.reverbFile,
|
|
346
|
+
) || 'off';
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** pretext — suppressed by AGENTVIBES_NO_PRETEXT; else per-LLM > file sources. */
|
|
350
|
+
function resolvePretext(inputs) {
|
|
351
|
+
if (toBool(inputs.noPretext)) return '';
|
|
352
|
+
return firstNonEmpty(
|
|
353
|
+
inputs.perLlmPretext,
|
|
354
|
+
inputs.pretextConfigJson,
|
|
355
|
+
inputs.pretextFile,
|
|
356
|
+
inputs.introTextFile,
|
|
357
|
+
) || '';
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** speed — translation target rate > speech-rate file > 1.0. Applies to ALL engines (R20). */
|
|
361
|
+
function resolveSpeed(inputs) {
|
|
362
|
+
const s = firstNonEmpty(inputs.translationRate, inputs.speechRate);
|
|
363
|
+
const n = s === undefined ? DEFAULT_SPEED : parseFloat(s);
|
|
364
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_SPEED;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** personality — per-call override > file > "normal". Absent file must not be written. */
|
|
368
|
+
function resolvePersonality(inputs) {
|
|
369
|
+
return firstNonEmpty(inputs.personalityOverride, inputs.personalityFile) || DEFAULT_PERSONALITY;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** language — learning/translation target > language file > english. Forwarded on SSH (R18). */
|
|
373
|
+
function resolveLanguage(inputs) {
|
|
374
|
+
return firstNonEmpty(
|
|
375
|
+
inputs.learningLanguage,
|
|
376
|
+
inputs.translationLanguage,
|
|
377
|
+
inputs.languageFile,
|
|
378
|
+
) || DEFAULT_LANGUAGE;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* mute — 3-level: project-unmute overrides project-mute overrides global-mute.
|
|
383
|
+
* Unified across platforms; forwarded on SSH so the receiver honors sender intent (R17).
|
|
384
|
+
*/
|
|
385
|
+
function resolveMute(inputs) {
|
|
386
|
+
if (toBool(inputs.projectUnmute)) return false;
|
|
387
|
+
if (toBool(inputs.projectMute)) return true;
|
|
388
|
+
return toBool(inputs.globalMute);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* resolveUtterance — the single entry point. Pure: (normalized inputs) → frozen plan.
|
|
393
|
+
*
|
|
394
|
+
* @param {object} inputs normalized raw values from all config sources (see the
|
|
395
|
+
* field-resolvers above and docs/.../8-5-precedence-map.md for each source's
|
|
396
|
+
* precedence). Missing keys are treated as absent.
|
|
397
|
+
* @returns {Readonly<UtterancePlan>}
|
|
398
|
+
*/
|
|
399
|
+
function resolveUtterance(inputs = {}) {
|
|
400
|
+
let transport = resolveTransport(inputs);
|
|
401
|
+
const sshTarget = resolveSshTarget(inputs, transport);
|
|
402
|
+
// F6: a remote transport with no resolvable host is a dead plan (silent audio
|
|
403
|
+
// death — the class this project bans). Fall back to local, matching bash
|
|
404
|
+
// (play-tts.sh only switches to remote when a host is present).
|
|
405
|
+
if (REMOTE_TRANSPORTS.has(transport) && sshTarget === null) transport = 'local';
|
|
406
|
+
|
|
407
|
+
const voiceInfo = resolveVoiceInfo(inputs);
|
|
408
|
+
const voice = voiceInfo.voice;
|
|
409
|
+
const engine = resolveEngine(inputs, voice, transport);
|
|
410
|
+
const music = resolveMusic(inputs);
|
|
411
|
+
|
|
412
|
+
const plan = {
|
|
413
|
+
text: inputs.text ?? '',
|
|
414
|
+
voice,
|
|
415
|
+
// True only when `voice` came from a real override (explicit pick or per-LLM
|
|
416
|
+
// row), NOT the provider's stored voice file. Players adopt it into their
|
|
417
|
+
// VOICE_OVERRIDE arg only when true, so a plain provider-file voice keeps the
|
|
418
|
+
// provider script's own model/speaker-id resolution (F-2). Engine coupling
|
|
419
|
+
// (R1) still uses `voice` regardless.
|
|
420
|
+
voiceIsOverride: voiceInfo.isOverride,
|
|
421
|
+
engine,
|
|
422
|
+
transport,
|
|
423
|
+
sshTarget,
|
|
424
|
+
voiceModel: firstNonEmpty(inputs.voiceModel) || null,
|
|
425
|
+
speakerId: (inputs.speakerId === undefined || inputs.speakerId === null || inputs.speakerId === '')
|
|
426
|
+
? null : String(inputs.speakerId),
|
|
427
|
+
personality: resolvePersonality(inputs),
|
|
428
|
+
language: resolveLanguage(inputs),
|
|
429
|
+
pretext: resolvePretext(inputs),
|
|
430
|
+
speed: resolveSpeed(inputs),
|
|
431
|
+
reverb: resolveReverb(inputs),
|
|
432
|
+
effects: firstNonEmpty(inputs.effectsOverride, inputs.perLlmEffects) || '',
|
|
433
|
+
music,
|
|
434
|
+
mute: resolveMute(inputs),
|
|
435
|
+
rdpMode: toBool(inputs.rdpModeExplicit) || toBool(inputs.rdpModeSniffed),
|
|
436
|
+
// outputPath is NEVER derived by "most recent file"; the provider emits an
|
|
437
|
+
// `AV_OUTPUT:` sentinel line and the player captures the exact path (R6/R7,
|
|
438
|
+
// memory: feedback_no_most_recent_file_heuristic). Carried here when known.
|
|
439
|
+
outputPath: firstNonEmpty(inputs.outputPath) || null,
|
|
440
|
+
// One canonical no-playback flag, replacing the NO_PLAY (ps1) / NO_PLAYBACK
|
|
441
|
+
// (bash) split (R11; memory landmine 5).
|
|
442
|
+
noPlayback: toBool(inputs.noPlay) || toBool(inputs.noPlayback),
|
|
443
|
+
// One canonical test-mode signal (replaces env-vs-file split).
|
|
444
|
+
testMode: toBool(inputs.testModeEnv) || toBool(inputs.testModeFile),
|
|
445
|
+
projectDir: firstNonEmpty(inputs.projectDir) || null,
|
|
446
|
+
llmKey: firstNonEmpty(inputs.llmKey) || 'llm:default',
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
return deepFreeze(plan);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* validatePlan — cheap structural guard so a malformed plan fails loudly at the
|
|
454
|
+
* seam rather than producing silent-wrong audio downstream.
|
|
455
|
+
* @returns {string[]} list of problems (empty = valid)
|
|
456
|
+
*/
|
|
457
|
+
function validatePlan(plan) {
|
|
458
|
+
const errs = [];
|
|
459
|
+
if (!plan || typeof plan !== 'object') return ['plan is not an object'];
|
|
460
|
+
// text/voice must be strings — a boolean here means the CLI mis-parsed an
|
|
461
|
+
// argument (e.g. a value that started with "--"); fail loud, never speak it.
|
|
462
|
+
if (typeof plan.text !== 'string') errs.push('text must be a string');
|
|
463
|
+
if (typeof plan.voice !== 'string') errs.push('voice must be a string');
|
|
464
|
+
const isRemote = REMOTE_TRANSPORTS.has(plan.transport);
|
|
465
|
+
// engine may be null ONLY on a remote plan (the receiver decides from the voice).
|
|
466
|
+
if (plan.engine === null) {
|
|
467
|
+
if (!isRemote) errs.push('engine null is only valid on a remote transport');
|
|
468
|
+
} else if (!ENGINES.includes(plan.engine)) {
|
|
469
|
+
errs.push(`engine "${plan.engine}" not in ${ENGINES.join('|')}`);
|
|
470
|
+
}
|
|
471
|
+
if (!TRANSPORTS.includes(plan.transport)) errs.push(`transport "${plan.transport}" invalid`);
|
|
472
|
+
if (typeof plan.music !== 'object' || plan.music === null) errs.push('music must be an object');
|
|
473
|
+
else {
|
|
474
|
+
if (typeof plan.music.enabled !== 'boolean') errs.push('music.enabled must be boolean');
|
|
475
|
+
if (!(plan.music.volume >= 0 && plan.music.volume <= 1)) errs.push('music.volume must be 0..1');
|
|
476
|
+
if (plan.music.enabled && !plan.music.track) errs.push('music enabled but no track');
|
|
477
|
+
}
|
|
478
|
+
if (isRemote) {
|
|
479
|
+
// F6: a remote plan MUST have a target (else it's a silent-audio dead plan).
|
|
480
|
+
if (!plan.sshTarget) errs.push(`transport ${plan.transport} but sshTarget is null`);
|
|
481
|
+
else {
|
|
482
|
+
if (plan.sshTarget.port === '22' && plan.sshTarget.passPortFlag) {
|
|
483
|
+
errs.push('sshTarget: port 22 must not pass -p (ssh alias)');
|
|
484
|
+
}
|
|
485
|
+
// F12: a port that will be passed must be numeric.
|
|
486
|
+
if (plan.sshTarget.passPortFlag && !/^\d+$/.test(String(plan.sshTarget.port))) {
|
|
487
|
+
errs.push(`sshTarget: non-numeric port "${plan.sshTarget.port}"`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return errs;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export {
|
|
495
|
+
resolveUtterance,
|
|
496
|
+
validatePlan,
|
|
497
|
+
// exported for targeted testing + reuse by the (Stage 2) loader and players
|
|
498
|
+
resolveVoice,
|
|
499
|
+
resolveVoiceInfo,
|
|
500
|
+
resolveEngine,
|
|
501
|
+
resolveTransport,
|
|
502
|
+
resolveSshTarget,
|
|
503
|
+
resolveMusic,
|
|
504
|
+
resolveReverb,
|
|
505
|
+
resolvePretext,
|
|
506
|
+
resolveSpeed,
|
|
507
|
+
resolvePersonality,
|
|
508
|
+
resolveLanguage,
|
|
509
|
+
resolveMute,
|
|
510
|
+
normalizeVolume,
|
|
511
|
+
percentToFraction,
|
|
512
|
+
fractionVolume,
|
|
513
|
+
isKokoroVoice,
|
|
514
|
+
isPiperVoice,
|
|
515
|
+
normalizeEngine,
|
|
516
|
+
toBool,
|
|
517
|
+
deepFreeze,
|
|
518
|
+
KOKORO_VOICE_RE,
|
|
519
|
+
PIPER_VOICE_RE,
|
|
520
|
+
ENGINE_ALIASES,
|
|
521
|
+
VOICE_SOURCES,
|
|
522
|
+
ENGINES,
|
|
523
|
+
TRANSPORTS,
|
|
524
|
+
DEFAULT_BG_VOLUME,
|
|
525
|
+
DEFAULT_BG_TRACK,
|
|
526
|
+
};
|