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.
Files changed (97) hide show
  1. package/.agentvibes/config.json +1 -15
  2. package/.claude/activation-instructions +54 -54
  3. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  4. package/.claude/commands/agent-vibes-rdp.md +24 -24
  5. package/.claude/config/audio-effects.cfg +5 -5
  6. package/.claude/config/background-music-enabled.txt +1 -1
  7. package/.claude/docs/TERMUX_SETUP.md +408 -408
  8. package/.claude/github-star-reminder.txt +1 -1
  9. package/.claude/hooks/audio-processor.sh +1 -1
  10. package/.claude/hooks/background-music-manager.sh +2 -1
  11. package/.claude/hooks/bmad-speak-enhanced.sh +37 -22
  12. package/.claude/hooks/bmad-speak.sh +26 -8
  13. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +1 -1
  14. package/.claude/hooks/play-tts-kokoro.sh +9 -0
  15. package/.claude/hooks/play-tts-piper.sh +13 -1
  16. package/.claude/hooks/play-tts-ssh-remote.sh +78 -5
  17. package/.claude/hooks/play-tts-termux-ssh.sh +1 -1
  18. package/.claude/hooks/play-tts.sh +85 -4
  19. package/.claude/hooks-windows/background-music-manager.ps1 +2 -1
  20. package/.claude/hooks-windows/play-tts-kokoro.ps1 +14 -0
  21. package/.claude/hooks-windows/play-tts.ps1 +163 -30
  22. package/.claude/hooks-windows/voice-manager-windows.ps1 +101 -1
  23. package/.clawdbot/README.md +105 -105
  24. package/LICENSE +190 -190
  25. package/RELEASE_NOTES.md +29 -0
  26. package/WINDOWS-SETUP.md +208 -208
  27. package/bin/agent-vibes +39 -39
  28. package/bin/mcp-server.js +121 -121
  29. package/bin/mcp-server.sh +6 -19
  30. package/bin/resolve-utterance.js +137 -0
  31. package/bin/test-bmad-pr +78 -78
  32. package/mcp-server/QUICK_START.md +203 -203
  33. package/mcp-server/README.md +345 -345
  34. package/mcp-server/examples/claude_desktop_config.json +11 -11
  35. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  36. package/mcp-server/examples/custom_instructions.md +169 -169
  37. package/mcp-server/pyproject.toml +52 -52
  38. package/mcp-server/requirements.txt +2 -2
  39. package/mcp-server/server.py +280 -99
  40. package/mcp-server/test_mcp_correctness.py +486 -0
  41. package/mcp-server/test_server.py +395 -395
  42. package/mcp-server/test_windows_script_parity.py +341 -336
  43. package/package.json +3 -2
  44. package/setup-windows.ps1 +867 -815
  45. package/src/cli/list-personalities.js +110 -110
  46. package/src/cli/list-voices.js +114 -114
  47. package/src/commands/bmad-voices.js +394 -394
  48. package/src/console/app.js +13 -2
  49. package/src/console/brand-colors.js +13 -13
  50. package/src/console/constants/personalities.js +44 -44
  51. package/src/console/tabs/agents-tab.js +82 -9
  52. package/src/console/tabs/help-tab.js +314 -314
  53. package/src/console/tabs/music-tab.js +7 -1
  54. package/src/console/tabs/readme-tab.js +272 -272
  55. package/src/console/tabs/settings-tab.js +29 -1
  56. package/src/console/tabs/setup-tab.js +4 -4
  57. package/src/console/widgets/destroy-list.js +25 -25
  58. package/src/console/widgets/notice.js +55 -55
  59. package/src/console/widgets/track-picker.js +3 -3
  60. package/src/i18n/de.js +0 -1
  61. package/src/i18n/en.js +0 -1
  62. package/src/i18n/es.js +0 -1
  63. package/src/i18n/fr.js +0 -1
  64. package/src/i18n/hi.js +0 -1
  65. package/src/i18n/ja.js +0 -1
  66. package/src/i18n/ko.js +0 -1
  67. package/src/i18n/pt.js +0 -1
  68. package/src/i18n/zh-CN.js +0 -1
  69. package/src/installer/language-screen.js +31 -31
  70. package/src/installer/music-file-input.js +304 -304
  71. package/src/installer.js +205 -15
  72. package/src/services/config-service.js +264 -264
  73. package/src/services/language-service.js +47 -47
  74. package/src/services/llm-provider-service.js +7 -7
  75. package/src/services/utterance-loader.js +280 -0
  76. package/src/services/utterance-resolver.js +526 -0
  77. package/src/utils/audio-format-validator.js +277 -277
  78. package/src/utils/dependency-checker.js +469 -469
  79. package/src/utils/file-ownership-verifier.js +358 -358
  80. package/src/utils/music-file-validator.js +285 -285
  81. package/src/utils/preview-list-prompt.js +144 -144
  82. package/src/utils/secure-music-storage.js +412 -412
  83. package/templates/agentvibes-receiver.sh +74 -12
  84. package/voice-assignments.json +8245 -0
  85. package/.agentvibes/install-manifest.json +0 -342
  86. package/.claude/config/background-music-position.txt +0 -27
  87. package/.claude/config/background-music-volume.txt +0 -1
  88. package/.claude/config/background-music.cfg +0 -1
  89. package/.claude/config/background-music.txt +0 -1
  90. package/.claude/config/language.txt +0 -1
  91. package/.claude/config/personality.txt +0 -1
  92. package/.claude/config/tts-speech-rate.txt +0 -1
  93. package/.claude/config/tts-verbosity.txt +0 -1
  94. package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +0 -119
  95. package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +0 -153
  96. package/.claude/piper-voices-dir.txt +0 -1
  97. 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
+ };