agentvibes 5.12.0 → 5.13.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/.claude/commands/agent-vibes/commands.json +0 -20
- package/.claude/commands/agent-vibes/unmute.md +6 -2
- package/.claude/config/audio-effects.cfg +6 -6
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/agentvibes-session-id.sh +69 -0
- package/.claude/hooks/bmad-party-speak.sh +20 -4
- package/.claude/hooks/bmad-speak.sh +60 -2
- package/.claude/hooks/bmad-tts-injector.sh +20 -1
- package/.claude/hooks/bmad-voice-manager.sh +25 -3
- package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
- package/.claude/hooks/clawdbot-receiver.sh +19 -1
- package/.claude/hooks/elevenlabs-voices.sh +62 -0
- package/.claude/hooks/kokoro-installer.sh +20 -10
- package/.claude/hooks/language-manager.sh +10 -3
- package/.claude/hooks/party-set-room.sh +71 -0
- package/.claude/hooks/party-stage-roster.py +328 -0
- package/.claude/hooks/personality-manager.sh +19 -2
- package/.claude/hooks/piper-voice-manager.sh +3 -2
- package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
- package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
- package/.claude/hooks/play-tts-kokoro.sh +33 -6
- package/.claude/hooks/play-tts-soprano.sh +3 -2
- package/.claude/hooks/play-tts-ssh-remote.sh +37 -29
- package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
- package/.claude/hooks/play-tts.sh +66 -61
- package/.claude/hooks/provider-catalog.json +352 -0
- package/.claude/hooks/provider-catalog.sh +161 -0
- package/.claude/hooks/provider-commands.sh +2 -1
- package/.claude/hooks/provider-manager.sh +47 -9
- package/.claude/hooks/python-resolver.sh +117 -0
- package/.claude/hooks/session-id.sh +56 -0
- package/.claude/hooks/session-start-tts.sh +39 -0
- package/.claude/hooks/speed-manager.sh +1 -1
- package/.claude/hooks/translate-manager.sh +3 -2
- package/.claude/hooks/translator.py +1 -1
- package/.claude/hooks/voice-manager.sh +242 -10
- package/.claude/hooks-windows/language-manager.ps1 +7 -1
- package/.claude/hooks-windows/personality-manager.ps1 +16 -1
- package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
- package/.claude/hooks-windows/play-tts.ps1 +32 -3
- package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
- package/.claude/hooks-windows/provider-manager.ps1 +63 -8
- package/.claude/hooks-windows/tts-watcher.ps1 +33 -12
- package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
- package/.mcp.json +0 -7
- package/README.md +12 -3
- package/RELEASE_NOTES.md +43 -0
- package/mcp-server/server.py +146 -49
- package/mcp-server/test_mcp_correctness.py +20 -2
- package/mcp-server/test_windows_script_parity.py +0 -2
- package/package.json +1 -1
- package/src/cli/list-voices.js +218 -114
- package/src/console/bling.js +71 -0
- package/src/console/music-preview.js +79 -0
- package/src/console/tabs/music-tab.js +16 -39
- package/src/console/tabs/settings-tab.js +195 -13
- package/src/console/tabs/setup-tab.js +9 -34
- package/src/console/tabs/voices-tab.js +83 -14
- package/src/console/widgets/track-picker.js +82 -0
- package/src/installer.js +124 -10
- package/src/services/provider-catalog.js +412 -0
- package/src/services/provider-voice-catalog.js +52 -73
- package/src/services/tts-engine-service.js +29 -0
- package/src/utils/provider-validator.js +62 -12
- package/.claude/commands/agent-vibes/language.md +0 -23
- package/.claude/commands/agent-vibes/learn.md +0 -67
- package/.claude/commands/agent-vibes/replay-target.md +0 -14
- package/.claude/commands/agent-vibes/target-voice.md +0 -26
- package/.claude/commands/agent-vibes/target.md +0 -30
- package/.claude/hooks/learn-manager.sh +0 -492
- package/.claude/hooks/replay-target-audio.sh +0 -95
- package/.claude/hooks-windows/learn-manager.ps1 +0 -241
package/src/installer.js
CHANGED
|
@@ -141,6 +141,33 @@ async function saveManifest(manifestPath, files) {
|
|
|
141
141
|
// • dest hash == manifest hash → copy (action: 'updated')
|
|
142
142
|
// • dest hash != manifest hash → skip (action: 'skipped', .user.bak saved)
|
|
143
143
|
// • no manifest entry yet → copy (action: 'updated')
|
|
144
|
+
// Preserve destPath's CURRENT content in a sidecar backup before anything
|
|
145
|
+
// overwrites it. Never clobbers an existing .user.bak — that backup may hold the
|
|
146
|
+
// only surviving copy of an earlier customization; a second backup gets a
|
|
147
|
+
// content-stamped name instead. Returns true only when the current content is
|
|
148
|
+
// safely stored somewhere, so callers can refuse to overwrite when it isn't.
|
|
149
|
+
async function backupUserFile(destPath) {
|
|
150
|
+
const bak = `${destPath}.user.bak`;
|
|
151
|
+
try {
|
|
152
|
+
const currentHash = await computeFileHash(destPath);
|
|
153
|
+
if (!currentHash) return false;
|
|
154
|
+
|
|
155
|
+
const existingHash = await computeFileHash(bak);
|
|
156
|
+
if (existingHash === currentHash) return true; // already backed up
|
|
157
|
+
|
|
158
|
+
if (existingHash) {
|
|
159
|
+
// A different backup exists — keep it and stamp this one.
|
|
160
|
+
await fs.copyFile(destPath, `${destPath}.user.bak.${currentHash.slice(0, 8)}`);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await fs.copyFile(destPath, bak);
|
|
165
|
+
return true;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
144
171
|
async function manifestSafeCopy(srcPath, destPath, manifest) {
|
|
145
172
|
const srcHash = await computeFileHash(srcPath);
|
|
146
173
|
if (!srcHash) return { action: 'skipped', hash: null }; // src missing
|
|
@@ -157,15 +184,29 @@ async function manifestSafeCopy(srcPath, destPath, manifest) {
|
|
|
157
184
|
}
|
|
158
185
|
|
|
159
186
|
const manifestHash = manifest[destPath]?.hash;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
187
|
+
|
|
188
|
+
if (manifestHash) {
|
|
189
|
+
if (destHash !== manifestHash) {
|
|
190
|
+
// User modified the file since we last installed it — preserve it
|
|
191
|
+
await backupUserFile(destPath);
|
|
192
|
+
return { action: 'skipped', hash: destHash };
|
|
193
|
+
}
|
|
194
|
+
// Byte-identical to what we last installed — untouched, ours to update
|
|
195
|
+
await fs.copyFile(srcPath, destPath);
|
|
196
|
+
return { action: 'updated', hash: srcHash };
|
|
164
197
|
}
|
|
165
198
|
|
|
166
|
-
//
|
|
199
|
+
// No manifest entry, and dest differs from what we ship. This is either a
|
|
200
|
+
// user's customized file or an older stock copy predating the manifest — the
|
|
201
|
+
// two are indistinguishable, so we must assume it could be a customization.
|
|
202
|
+
// Back it up before overwriting, and if the backup can't be secured, refuse
|
|
203
|
+
// to overwrite at all rather than destroy the only copy.
|
|
204
|
+
// (CLAUDE.md: never overwrite existing user .claude/ config.)
|
|
205
|
+
if (!(await backupUserFile(destPath))) {
|
|
206
|
+
return { action: 'skipped', hash: destHash };
|
|
207
|
+
}
|
|
167
208
|
await fs.copyFile(srcPath, destPath);
|
|
168
|
-
return { action: 'updated', hash: srcHash };
|
|
209
|
+
return { action: 'updated', hash: srcHash, backedUp: true };
|
|
169
210
|
}
|
|
170
211
|
|
|
171
212
|
// Delete only the files listed in manifest that reside under baseDir.
|
|
@@ -3593,14 +3634,17 @@ async function copyCommandFiles(targetDir, spinner) {
|
|
|
3593
3634
|
*/
|
|
3594
3635
|
function shouldIncludeHookFile(file, stat) {
|
|
3595
3636
|
if (isNativeWindows()) {
|
|
3596
|
-
// Include .ps1 scripts, .py helpers,
|
|
3637
|
+
// Include .ps1 scripts, .py helpers, hooks.json, and the generated
|
|
3638
|
+
// provider-catalog.json; exclude dotfiles and prepare-release
|
|
3597
3639
|
return stat.isFile() &&
|
|
3598
|
-
(file.endsWith('.ps1') || file.endsWith('.py') || file === 'hooks.json'
|
|
3640
|
+
(file.endsWith('.ps1') || file.endsWith('.py') || file === 'hooks.json' ||
|
|
3641
|
+
file === 'provider-catalog.json') &&
|
|
3599
3642
|
!file.includes('prepare-release') &&
|
|
3600
3643
|
!file.startsWith('.');
|
|
3601
3644
|
}
|
|
3602
3645
|
return stat.isFile() &&
|
|
3603
|
-
(file.endsWith('.sh') || file.endsWith('.py') || file === 'hooks.json'
|
|
3646
|
+
(file.endsWith('.sh') || file.endsWith('.py') || file === 'hooks.json' ||
|
|
3647
|
+
file === 'provider-catalog.json') &&
|
|
3604
3648
|
!file.includes('prepare-release') &&
|
|
3605
3649
|
!file.startsWith('.');
|
|
3606
3650
|
}
|
|
@@ -5668,6 +5712,16 @@ async function performUpdateOperations(targetDir, spinner) {
|
|
|
5668
5712
|
spinner.text = 'Checking for old configuration...';
|
|
5669
5713
|
await detectAndMigrateOldConfig(targetDir, spinner);
|
|
5670
5714
|
|
|
5715
|
+
// Upgrade safety for the new opt-in injection gate (session-start-tts.sh):
|
|
5716
|
+
// a project that was already installed and talking pre-gate has no marker, so
|
|
5717
|
+
// the gate would newly silence it. Backfill the enable marker for a PROJECT
|
|
5718
|
+
// update so existing users keep their audio. A GLOBAL (home-dir) update stays
|
|
5719
|
+
// opt-in — and we say so, otherwise the user's voice just stops with no clue.
|
|
5720
|
+
if ((await configureInjectionScope(targetDir)) === 'global') {
|
|
5721
|
+
console.log('');
|
|
5722
|
+
warnGlobalInjectionScope();
|
|
5723
|
+
}
|
|
5724
|
+
|
|
5671
5725
|
return {
|
|
5672
5726
|
commandCount,
|
|
5673
5727
|
hookCount: hookResult.count,
|
|
@@ -5721,6 +5775,61 @@ async function updateAgentVibes(targetDir, options) {
|
|
|
5721
5775
|
}
|
|
5722
5776
|
}
|
|
5723
5777
|
|
|
5778
|
+
/**
|
|
5779
|
+
* Configure the session-start injection opt-in marker for this install/update.
|
|
5780
|
+
*
|
|
5781
|
+
* - PROJECT install → drop `.claude/agentvibes-enabled` so session-start-tts.sh
|
|
5782
|
+
* injects the TTS protocol for THIS project (and only this one), preserving the
|
|
5783
|
+
* "install in a project and it just works" experience.
|
|
5784
|
+
* - GLOBAL install (target === home dir) → write nothing; injecting into every
|
|
5785
|
+
* session is exactly the "cacophony of agents" we are preventing.
|
|
5786
|
+
*
|
|
5787
|
+
* Uses `agentvibes-enabled`, NOT `agentvibes-unmuted`, on purpose: the unmuted
|
|
5788
|
+
* marker OVERRIDES a global mute in play-tts.sh, so writing it here would let an
|
|
5789
|
+
* install/update silently defeat a user's `~/.agentvibes-muted` kill-switch.
|
|
5790
|
+
* Non-destructive: never touches an existing mute/unmute/enabled choice.
|
|
5791
|
+
*
|
|
5792
|
+
* @param {string} targetDir - install target
|
|
5793
|
+
* @returns {Promise<'global'|'enabled'|'kept'|'error'>} what happened (for messaging)
|
|
5794
|
+
*/
|
|
5795
|
+
async function configureInjectionScope(targetDir) {
|
|
5796
|
+
try {
|
|
5797
|
+
// path.relative is case-insensitive on win32 and normalizes separators, so a
|
|
5798
|
+
// lowercase drive letter or trailing slash no longer misdetects a home
|
|
5799
|
+
// install; realpath (best-effort) also collapses a symlinked $HOME.
|
|
5800
|
+
let resolvedTarget = path.resolve(targetDir);
|
|
5801
|
+
let resolvedHome = path.resolve(os.homedir());
|
|
5802
|
+
try { resolvedTarget = fsSync.realpathSync.native(resolvedTarget); } catch { /* dir may not exist yet */ }
|
|
5803
|
+
try { resolvedHome = fsSync.realpathSync.native(resolvedHome); } catch { /* ignore */ }
|
|
5804
|
+
if (path.relative(resolvedHome, resolvedTarget) === '') return 'global';
|
|
5805
|
+
|
|
5806
|
+
const claudeDir = path.join(targetDir, '.claude');
|
|
5807
|
+
// Respect any prior explicit choice — enabled OR either mute/unmute marker.
|
|
5808
|
+
for (const m of ['agentvibes-enabled', 'agentvibes-unmuted', 'agentvibes-muted']) {
|
|
5809
|
+
if (await fs.access(path.join(claudeDir, m)).then(() => true).catch(() => false)) {
|
|
5810
|
+
return 'kept';
|
|
5811
|
+
}
|
|
5812
|
+
}
|
|
5813
|
+
await fs.mkdir(claudeDir, { recursive: true });
|
|
5814
|
+
await fs.writeFile(path.join(claudeDir, 'agentvibes-enabled'), '', 'utf8');
|
|
5815
|
+
return 'enabled';
|
|
5816
|
+
} catch (err) {
|
|
5817
|
+
// Never fail install/update over the marker, but don't swallow silently —
|
|
5818
|
+
// a read-only FS here would otherwise produce a mysterious "silent" project.
|
|
5819
|
+
console.log(chalk.gray(` (Could not set TTS injection marker: ${err.code || err.message}. Run /agent-vibes:unmute to enable.)`));
|
|
5820
|
+
return 'error';
|
|
5821
|
+
}
|
|
5822
|
+
}
|
|
5823
|
+
|
|
5824
|
+
// Print the "global install stays silent" guidance (shared by install + update).
|
|
5825
|
+
function warnGlobalInjectionScope() {
|
|
5826
|
+
console.log(chalk.yellow.bold(' ⚠ Global install detected (installed at your home directory)'));
|
|
5827
|
+
console.log(chalk.gray(' TTS stays OFF by default so it will NOT talk in every session at once.'));
|
|
5828
|
+
console.log(chalk.gray(' Enable it in a specific project with: ') + chalk.cyan('/agent-vibes:unmute'));
|
|
5829
|
+
console.log(chalk.gray(' (or re-run the installer inside a project folder, not your home dir).'));
|
|
5830
|
+
console.log('');
|
|
5831
|
+
}
|
|
5832
|
+
|
|
5724
5833
|
// Installation function
|
|
5725
5834
|
async function install(options = {}) {
|
|
5726
5835
|
const currentDir = process.env.INIT_CWD || process.cwd();
|
|
@@ -6243,6 +6352,11 @@ def _strip_markdown(text: str) -> str:
|
|
|
6243
6352
|
console.log(chalk.magenta(' \u2661 Sponsor this Developer github.com/sponsors/paulpreibisch'));
|
|
6244
6353
|
console.log('');
|
|
6245
6354
|
|
|
6355
|
+
// Opt-in injection marker (prevents the global-install cacophony). See
|
|
6356
|
+
// configureInjectionScope: project installs enable THIS project; a home-dir
|
|
6357
|
+
// install stays silent. Uses agentvibes-enabled so it can't override a global mute.
|
|
6358
|
+
if ((await configureInjectionScope(targetDir)) === 'global') warnGlobalInjectionScope();
|
|
6359
|
+
|
|
6246
6360
|
if (!(options.nonInteractive || process.env.AGENT_VIBES_NON_INTERACTIVE === '1')) {
|
|
6247
6361
|
// Clean final summary
|
|
6248
6362
|
console.log('');
|
|
@@ -6997,7 +7111,7 @@ export {
|
|
|
6997
7111
|
CRITICAL_HOOKS, CRITICAL_HOOKS_WINDOWS,
|
|
6998
7112
|
// Manifest utilities (used by tests and external tooling)
|
|
6999
7113
|
getProjectManifestPath, getGlobalManifestPath,
|
|
7000
|
-
loadManifest, saveManifest, computeFileHash, manifestSafeCopy, removeManifestFiles,
|
|
7114
|
+
loadManifest, saveManifest, computeFileHash, manifestSafeCopy, backupUserFile, removeManifestFiles,
|
|
7001
7115
|
// Pure helper functions exported for testing
|
|
7002
7116
|
readJsonConfigSafe, backupConfigFile,
|
|
7003
7117
|
isPiperProvider, supportsEmoji, getPersonalityIcon,
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider Catalog — Layer 2 of the SSOT program (companion to the Utterance
|
|
3
|
+
* Resolver, src/services/utterance-resolver.js). See
|
|
4
|
+
* docs/architecture/provider-catalog-design.md.
|
|
5
|
+
*
|
|
6
|
+
* ONE record per provider answers the inventory question — "WHAT exists to speak
|
|
7
|
+
* with?": voice lists, defaults, membership validation, per-platform runtime,
|
|
8
|
+
* and display names. The resolver answers the orthogonal question ("HOW is this
|
|
9
|
+
* utterance spoken?"). The two layers NEVER import each other — the seam is
|
|
10
|
+
* welded by conformance assertions only (see
|
|
11
|
+
* test/unit/provider-catalog-conformance.test.js). That keeps the resolver's
|
|
12
|
+
* 433-test contract untouched.
|
|
13
|
+
*
|
|
14
|
+
* DISCIPLINE: this module is PURE data + PURE functions. No `fs`, no `env`, no
|
|
15
|
+
* clock — exactly like the resolver. Discovered providers (piper, macos, SAPI)
|
|
16
|
+
* receive their installed-voice list via injection (`{ installed }`); the
|
|
17
|
+
* catalog owns the RULE, never the I/O.
|
|
18
|
+
*
|
|
19
|
+
* SECURITY NOTE: the receiver `ALLOWED_VOICES` allowlists
|
|
20
|
+
* (clawdbot-receiver-SECURE.sh) are a SECURITY control, NOT inventory — they are
|
|
21
|
+
* deliberately narrower than this catalog and must never be unified with it.
|
|
22
|
+
*
|
|
23
|
+
* @module services/provider-catalog
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
/* ------------------------------------------------------------------ *
|
|
29
|
+
* Static voice data (the canonical copies; provider-voice-catalog.js
|
|
30
|
+
* re-exports these for back-compat).
|
|
31
|
+
* ------------------------------------------------------------------ */
|
|
32
|
+
|
|
33
|
+
/** ElevenLabs DEFAULT-LIBRARY voices (voice_id + display metadata), available to
|
|
34
|
+
* every API key. Community/library voices that must be added to an account first
|
|
35
|
+
* are intentionally excluded so a switch can't fail at synth time. Mirrored on
|
|
36
|
+
* the shell side by .claude/hooks/elevenlabs-voices.sh (parity-guarded). */
|
|
37
|
+
const ELEVENLABS_VOICES = [
|
|
38
|
+
{ id: 'EXAVITQu4vr4xnSDxMaL', name: 'Sarah', gender: 'Female', lang: 'en-US', desc: 'Mature, reassuring' },
|
|
39
|
+
{ id: 'CwhRBWXzGAHq8TQ4Fs17', name: 'Roger', gender: 'Male', lang: 'en-US', desc: 'Laid-back, casual' },
|
|
40
|
+
{ id: 'FGY2WhTYpPnrIDTdsKH5', name: 'Laura', gender: 'Female', lang: 'en-US', desc: 'Enthusiast, quirky' },
|
|
41
|
+
{ id: 'IKne3meq5aSn9XLyUdCD', name: 'Charlie', gender: 'Male', lang: 'en-AU', desc: 'Deep, confident' },
|
|
42
|
+
{ id: 'JBFqnCBsd6RMkjVDRZzb', name: 'George', gender: 'Male', lang: 'en-GB', desc: 'Warm storyteller' },
|
|
43
|
+
{ id: 'N2lVS1w4EtoT3dr4eOWO', name: 'Callum', gender: 'Male', lang: 'en-US', desc: 'Husky trickster' },
|
|
44
|
+
{ id: 'SAz9YHcvj6GT2YYXdXww', name: 'River', gender: '', lang: 'en-US', desc: 'Relaxed, neutral' },
|
|
45
|
+
{ id: 'SOYHLrjzK2X1ezoPC6cr', name: 'Harry', gender: 'Male', lang: 'en-US', desc: 'Fierce warrior' },
|
|
46
|
+
{ id: 'TX3LPaxmHKxFdv7VOQHJ', name: 'Liam', gender: 'Male', lang: 'en-US', desc: 'Energetic creator' },
|
|
47
|
+
{ id: 'Xb7hH8MSUJpSbSDYk0k2', name: 'Alice', gender: 'Female', lang: 'en-GB', desc: 'Clear educator' },
|
|
48
|
+
{ id: 'XrExE9yKIg1WjnnlVkGX', name: 'Matilda', gender: 'Female', lang: 'en-US', desc: 'Knowledgable, pro' },
|
|
49
|
+
{ id: 'bIHbv24MWmeRgasZH58o', name: 'Will', gender: 'Male', lang: 'en-US', desc: 'Relaxed optimist' },
|
|
50
|
+
{ id: 'cgSgspJ2msm6clMCkdW9', name: 'Jessica', gender: 'Female', lang: 'en-US', desc: 'Playful, bright' },
|
|
51
|
+
{ id: 'cjVigY5qzO86Huf0OWal', name: 'Eric', gender: 'Male', lang: 'en-US', desc: 'Smooth, trustworthy' },
|
|
52
|
+
{ id: 'hpp4J3VqNfWAUOO0d1Us', name: 'Bella', gender: 'Female', lang: 'en-US', desc: 'Professional, warm' },
|
|
53
|
+
{ id: 'iP95p4xoKVk53GoZ742B', name: 'Chris', gender: 'Male', lang: 'en-US', desc: 'Charming, down-to-earth' },
|
|
54
|
+
{ id: 'nPczCjzI2devNBz1zQrb', name: 'Brian', gender: 'Male', lang: 'en-US', desc: 'Deep, comforting' },
|
|
55
|
+
{ id: 'onwK4e9ZLuTAKqWW03F9', name: 'Daniel', gender: 'Male', lang: 'en-GB', desc: 'Steady broadcaster' },
|
|
56
|
+
{ id: 'pFZP5JQG7iQjIQuC4Bku', name: 'Lily', gender: 'Female', lang: 'en-GB', desc: 'Velvety actress' },
|
|
57
|
+
{ id: 'pNInz6obpgDQGcFmaJgB', name: 'Adam', gender: 'Male', lang: 'en-US', desc: 'Dominant, firm' },
|
|
58
|
+
{ id: 'pqHfZKP75CvOlQylNhV4', name: 'Bill', gender: 'Male', lang: 'en-US', desc: 'Wise, mature' },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** Kokoro voice ids. Gender is encoded in the id: the 2nd char is f|m. */
|
|
62
|
+
const KOKORO_VOICE_IDS = [
|
|
63
|
+
// American English
|
|
64
|
+
'af_heart','af_alloy','af_aoede','af_bella','af_jessica','af_kore','af_nicole','af_nova','af_river','af_sarah','af_sky',
|
|
65
|
+
'am_adam','am_echo','am_eric','am_fenrir','am_liam','am_michael','am_onyx','am_puck',
|
|
66
|
+
// British English
|
|
67
|
+
'bf_alice','bf_emma','bf_isabella','bf_lily',
|
|
68
|
+
'bm_daniel','bm_fable','bm_george','bm_lewis',
|
|
69
|
+
// Japanese
|
|
70
|
+
'jf_alpha','jf_gongitsune','jf_nezumi','jf_tebukuro','jm_kumo',
|
|
71
|
+
// Mandarin Chinese
|
|
72
|
+
'zf_xiaobei','zf_xiaoni','zf_xiaoxiao','zf_xiaoyi','zm_yunxi','zm_yunxia','zm_yunyang',
|
|
73
|
+
// Spanish
|
|
74
|
+
'ef_dora','em_alex','em_santa',
|
|
75
|
+
// French
|
|
76
|
+
'ff_siwis',
|
|
77
|
+
// Hindi
|
|
78
|
+
'hf_alpha','hm_omega',
|
|
79
|
+
// Italian
|
|
80
|
+
'if_sara','im_nicola',
|
|
81
|
+
// Brazilian Portuguese
|
|
82
|
+
'pf_dora','pm_alex','pm_santa',
|
|
83
|
+
// Korean
|
|
84
|
+
'kf_alpha','km_hyunsu',
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Infer a Kokoro voice's gender from its id. Kokoro ids follow `<lang><sex>_name`
|
|
89
|
+
* where the 2nd character is `f` (female) or `m` (male).
|
|
90
|
+
* @param {string} id
|
|
91
|
+
* @returns {'Female'|'Male'|''}
|
|
92
|
+
*/
|
|
93
|
+
function kokoroGender(id) {
|
|
94
|
+
const c = typeof id === 'string' && id.length > 1 ? id[1].toLowerCase() : '';
|
|
95
|
+
if (c === 'f') return 'Female';
|
|
96
|
+
if (c === 'm') return 'Male';
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ------------------------------------------------------------------ *
|
|
101
|
+
* Voice-model validation rules (pure). Each returns { ok, canonical, reason }.
|
|
102
|
+
* ------------------------------------------------------------------ */
|
|
103
|
+
|
|
104
|
+
/** Piper-family voice shape: `en_US-lessac-medium` or a `model::Speaker` form. */
|
|
105
|
+
const PIPER_VOICE_SHAPE = /^[a-z]{2,3}_[A-Z]{2}-/;
|
|
106
|
+
/** Raw ElevenLabs voice_id shape (preserves elevenlabs-voices.sh:66 semantics). */
|
|
107
|
+
const ELEVENLABS_ID_SHAPE = /^[A-Za-z0-9]{20}$/;
|
|
108
|
+
|
|
109
|
+
/* ------------------------------------------------------------------ *
|
|
110
|
+
* The seven canonical ProviderRecords.
|
|
111
|
+
*
|
|
112
|
+
* `aliases` are spellings that resolve to THIS record via getProvider()
|
|
113
|
+
* (e.g. windows-sapi ← 'sapi'). `engineId` is the resolver ENGINES value
|
|
114
|
+
* ('sapi' for windows-sapi, 'piper' for windows-piper); the derived
|
|
115
|
+
* engineAliasTable() mirrors the resolver's ENGINE_ALIASES.
|
|
116
|
+
* ------------------------------------------------------------------ */
|
|
117
|
+
|
|
118
|
+
const RECORDS = [
|
|
119
|
+
{
|
|
120
|
+
id: 'soprano',
|
|
121
|
+
engineId: 'soprano',
|
|
122
|
+
aliases: [],
|
|
123
|
+
displayName: 'Soprano TTS',
|
|
124
|
+
voiceModel: 'single',
|
|
125
|
+
voices: ['soprano-default'],
|
|
126
|
+
defaultVoice: 'soprano-default',
|
|
127
|
+
runtime: { unix: 'play-tts-soprano.sh', windows: 'play-tts-soprano.ps1', darwinOnly: false },
|
|
128
|
+
requires: 'pip',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: 'piper',
|
|
132
|
+
engineId: 'piper',
|
|
133
|
+
aliases: [],
|
|
134
|
+
displayName: 'Piper TTS',
|
|
135
|
+
voiceModel: 'discovered',
|
|
136
|
+
voices: null, // discovered on disk (*.onnx glob)
|
|
137
|
+
defaultVoice: 'en_US-lessac-medium', // preferred fallback, NOT a guaranteed install
|
|
138
|
+
runtime: { unix: 'play-tts-piper.sh', windows: null, darwinOnly: false }, // Windows uses windows-piper
|
|
139
|
+
requires: 'pip',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'kokoro',
|
|
143
|
+
engineId: 'kokoro',
|
|
144
|
+
aliases: [],
|
|
145
|
+
displayName: 'Kokoro TTS',
|
|
146
|
+
voiceModel: 'static',
|
|
147
|
+
voices: KOKORO_VOICE_IDS,
|
|
148
|
+
defaultVoice: 'af_heart',
|
|
149
|
+
runtime: { unix: 'play-tts-kokoro.sh', windows: 'play-tts-kokoro.ps1', darwinOnly: false },
|
|
150
|
+
requires: 'pip',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'elevenlabs',
|
|
154
|
+
engineId: 'elevenlabs',
|
|
155
|
+
aliases: [],
|
|
156
|
+
displayName: 'ElevenLabs',
|
|
157
|
+
voiceModel: 'name-to-id',
|
|
158
|
+
voices: ELEVENLABS_VOICES,
|
|
159
|
+
defaultVoice: 'Sarah',
|
|
160
|
+
runtime: { unix: 'play-tts-elevenlabs.sh', windows: null, darwinOnly: false }, // ← no ps1 runtime; the contradiction, now data
|
|
161
|
+
requires: 'api-key',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 'macos',
|
|
165
|
+
engineId: 'macos',
|
|
166
|
+
aliases: ['macos-say', 'say'],
|
|
167
|
+
displayName: 'macOS Say',
|
|
168
|
+
voiceModel: 'discovered',
|
|
169
|
+
voices: null, // `say -v ?`
|
|
170
|
+
defaultVoice: 'Samantha',
|
|
171
|
+
runtime: { unix: 'play-tts-macos.sh', windows: null, darwinOnly: true },
|
|
172
|
+
requires: 'builtin',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'windows-sapi',
|
|
176
|
+
engineId: 'sapi',
|
|
177
|
+
aliases: ['sapi'],
|
|
178
|
+
displayName: 'Windows SAPI',
|
|
179
|
+
voiceModel: 'discovered',
|
|
180
|
+
voices: null, // System.Speech
|
|
181
|
+
defaultVoice: '', // system default
|
|
182
|
+
runtime: { unix: null, windows: 'play-tts-sapi.ps1', darwinOnly: false },
|
|
183
|
+
requires: 'builtin',
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'windows-piper',
|
|
187
|
+
engineId: 'piper',
|
|
188
|
+
aliases: ['piper'], // on Windows, bare 'piper' forwards to windows-piper
|
|
189
|
+
displayName: 'Piper TTS',
|
|
190
|
+
voiceModel: 'discovered',
|
|
191
|
+
voices: null, // same *.onnx models as piper
|
|
192
|
+
defaultVoice: 'en_US-lessac-medium',
|
|
193
|
+
runtime: { unix: null, windows: 'play-tts-windows-piper.ps1', darwinOnly: false },
|
|
194
|
+
requires: 'exe',
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
// Lookup indexes (built once).
|
|
199
|
+
const _BY_ID = new Map();
|
|
200
|
+
const _BY_ALIAS = new Map();
|
|
201
|
+
for (const r of RECORDS) {
|
|
202
|
+
_BY_ID.set(r.id, r);
|
|
203
|
+
}
|
|
204
|
+
for (const r of RECORDS) {
|
|
205
|
+
for (const a of r.aliases) {
|
|
206
|
+
// Exact-id spellings always win, so never let an alias shadow a real id.
|
|
207
|
+
if (!_BY_ID.has(a) && !_BY_ALIAS.has(a)) _BY_ALIAS.set(a, r);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Normalize an id-or-alias to a lowercase key. */
|
|
212
|
+
function _norm(idOrAlias) {
|
|
213
|
+
return typeof idOrAlias === 'string' ? idOrAlias.trim().toLowerCase() : '';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* ------------------------------------------------------------------ *
|
|
217
|
+
* Public API (design §3.2). All entries are alias-normalizing.
|
|
218
|
+
* ------------------------------------------------------------------ */
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @param {string} idOrAlias
|
|
222
|
+
* @returns {object|null} the ProviderRecord, or null if unknown.
|
|
223
|
+
*/
|
|
224
|
+
function getProvider(idOrAlias) {
|
|
225
|
+
const key = _norm(idOrAlias);
|
|
226
|
+
if (!key) return null;
|
|
227
|
+
return _BY_ID.get(key) || _BY_ALIAS.get(key) || null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @returns {object[]} all seven records (fresh array; records are shared). */
|
|
231
|
+
function listProviders() {
|
|
232
|
+
return RECORDS.slice();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Is a provider playable on `platform`?
|
|
237
|
+
* - 'windows' → has a Windows runtime script.
|
|
238
|
+
* - 'darwin' → has a Unix runtime script (darwin runs the .sh players).
|
|
239
|
+
* - 'unix' → has a Unix runtime AND is not darwin-only (i.e. Linux).
|
|
240
|
+
* @param {string} idOrAlias
|
|
241
|
+
* @param {'unix'|'windows'|'darwin'} platform
|
|
242
|
+
* @returns {boolean}
|
|
243
|
+
*/
|
|
244
|
+
function isAvailable(idOrAlias, platform) {
|
|
245
|
+
const r = getProvider(idOrAlias);
|
|
246
|
+
if (!r) return false;
|
|
247
|
+
if (platform === 'windows') return r.runtime.windows !== null;
|
|
248
|
+
if (platform === 'darwin') return r.runtime.unix !== null;
|
|
249
|
+
return r.runtime.unix !== null && !r.runtime.darwinOnly; // 'unix' == generic Linux
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* @param {'unix'|'windows'|'darwin'} platform
|
|
254
|
+
* @returns {object[]} records available on that platform.
|
|
255
|
+
*/
|
|
256
|
+
function providersFor(platform) {
|
|
257
|
+
return RECORDS.filter((r) => isAvailable(r.id, platform));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* List a provider's voices as `{ id, name?, gender, lang? }` entries. Discovered
|
|
262
|
+
* providers require an injected `installed` list (else an empty array).
|
|
263
|
+
* @param {string} idOrAlias
|
|
264
|
+
* @param {{installed?: string[]}} [opts]
|
|
265
|
+
* @returns {Array<{id: string, name?: string, gender: string, lang?: string}>}
|
|
266
|
+
*/
|
|
267
|
+
function listVoices(idOrAlias, { installed } = {}) {
|
|
268
|
+
const r = getProvider(idOrAlias);
|
|
269
|
+
if (!r) return [];
|
|
270
|
+
switch (r.voiceModel) {
|
|
271
|
+
case 'static': // kokoro
|
|
272
|
+
return r.voices.map((id) => ({ id, gender: kokoroGender(id) }));
|
|
273
|
+
case 'name-to-id': // elevenlabs
|
|
274
|
+
return r.voices.map((v) => ({ id: v.id, name: v.name, gender: v.gender || '', lang: v.lang }));
|
|
275
|
+
case 'single': // soprano
|
|
276
|
+
return r.voices.map((id) => ({ id, gender: '' }));
|
|
277
|
+
case 'discovered': // piper / macos / windows-sapi / windows-piper
|
|
278
|
+
default:
|
|
279
|
+
if (Array.isArray(installed)) return installed.map((id) => ({ id, gender: '' }));
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* @param {string} idOrAlias
|
|
286
|
+
* @returns {string} the provider's default voice ('' for windows-sapi = system default).
|
|
287
|
+
*/
|
|
288
|
+
function defaultVoice(idOrAlias) {
|
|
289
|
+
const r = getProvider(idOrAlias);
|
|
290
|
+
return r ? r.defaultVoice : '';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Validate a voice for a provider. STRICT membership at config-write time
|
|
295
|
+
* (typos never reach voice.txt). Returns `{ ok, canonical, reason }` — never a
|
|
296
|
+
* bare boolean — so every rejection carries a message and the accepted spelling
|
|
297
|
+
* is normalized ONCE.
|
|
298
|
+
*
|
|
299
|
+
* @param {string} idOrAlias
|
|
300
|
+
* @param {string} voice
|
|
301
|
+
* @param {{installed?: string[], allowUnlisted?: boolean}} [opts]
|
|
302
|
+
* `allowUnlisted` expresses the AGENTVIBES_ALLOW_UNLISTED_VOICE escape hatch —
|
|
303
|
+
* the catalog never reads env; consumers pass the flag (design §3.3). It relaxes
|
|
304
|
+
* MEMBERSHIP, not shape.
|
|
305
|
+
* @returns {{ok: boolean, canonical: string|null, reason: string}}
|
|
306
|
+
*/
|
|
307
|
+
function validateVoice(idOrAlias, voice, { installed, allowUnlisted = false } = {}) {
|
|
308
|
+
const r = getProvider(idOrAlias);
|
|
309
|
+
if (!r) return { ok: false, canonical: null, reason: `Unknown provider: ${idOrAlias}` };
|
|
310
|
+
const raw = typeof voice === 'string' ? voice.trim() : '';
|
|
311
|
+
|
|
312
|
+
switch (r.voiceModel) {
|
|
313
|
+
case 'static': { // kokoro — case-fold, exact membership
|
|
314
|
+
const folded = raw.toLowerCase();
|
|
315
|
+
if (r.voices.includes(folded)) return { ok: true, canonical: folded, reason: '' };
|
|
316
|
+
if (allowUnlisted && folded) return { ok: true, canonical: folded, reason: '' };
|
|
317
|
+
return { ok: false, canonical: null, reason: `"${voice}" is not a known ${r.displayName} voice` };
|
|
318
|
+
}
|
|
319
|
+
case 'name-to-id': { // elevenlabs — case-insensitive name → id, OR raw 20-char id
|
|
320
|
+
const lc = raw.toLowerCase();
|
|
321
|
+
const byName = r.voices.find((v) => v.name.toLowerCase() === lc);
|
|
322
|
+
if (byName) return { ok: true, canonical: byName.id, reason: '' };
|
|
323
|
+
if (ELEVENLABS_ID_SHAPE.test(raw)) return { ok: true, canonical: raw, reason: '' };
|
|
324
|
+
return { ok: false, canonical: null, reason: `"${voice}" is not a known ElevenLabs voice name or id` };
|
|
325
|
+
}
|
|
326
|
+
case 'single': { // soprano — '', 'soprano', 'soprano-default' → canonical 'soprano-default'
|
|
327
|
+
const lc = raw.toLowerCase();
|
|
328
|
+
if (lc === '' || lc === r.id || lc === r.defaultVoice) {
|
|
329
|
+
return { ok: true, canonical: r.defaultVoice, reason: '' };
|
|
330
|
+
}
|
|
331
|
+
return { ok: false, canonical: null, reason: `${r.displayName} has a single voice (${r.defaultVoice})` };
|
|
332
|
+
}
|
|
333
|
+
case 'discovered': // piper / macos / windows-sapi / windows-piper
|
|
334
|
+
default: {
|
|
335
|
+
const isPiperFamily = r.engineId === 'piper';
|
|
336
|
+
if (Array.isArray(installed)) {
|
|
337
|
+
if (installed.includes(raw)) return { ok: true, canonical: raw, reason: '' };
|
|
338
|
+
if (!allowUnlisted) {
|
|
339
|
+
return { ok: false, canonical: null, reason: `"${voice}" is not an installed ${r.displayName} voice` };
|
|
340
|
+
}
|
|
341
|
+
// allowUnlisted: fall through to shape/permissive check
|
|
342
|
+
}
|
|
343
|
+
if (isPiperFamily) {
|
|
344
|
+
if (PIPER_VOICE_SHAPE.test(raw) || raw.includes('::')) return { ok: true, canonical: raw, reason: '' };
|
|
345
|
+
return { ok: false, canonical: null, reason: `"${voice}" is not a valid Piper voice id` };
|
|
346
|
+
}
|
|
347
|
+
// macos / windows-sapi: permissive when no list is available; '' = system default.
|
|
348
|
+
if (raw === '' && r.defaultVoice === '') return { ok: true, canonical: '', reason: '' };
|
|
349
|
+
if (raw) return { ok: true, canonical: raw, reason: '' };
|
|
350
|
+
return { ok: false, canonical: null, reason: `Empty voice for ${r.displayName}` };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* @param {string} idOrAlias
|
|
357
|
+
* @returns {string} a human display name, or the input unchanged if unknown
|
|
358
|
+
* (mirrors provider-validator.getProviderDisplayName's passthrough).
|
|
359
|
+
*/
|
|
360
|
+
function displayName(idOrAlias) {
|
|
361
|
+
const r = getProvider(idOrAlias);
|
|
362
|
+
if (r) return r.displayName;
|
|
363
|
+
return typeof idOrAlias === 'string' ? idOrAlias : '';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Derived alias→engine table, built to MIRROR the resolver's ENGINE_ALIASES
|
|
368
|
+
* (utterance-resolver.js:52-57). A record whose id differs from its engineId
|
|
369
|
+
* contributes id→engineId; each non-identity alias contributes alias→engineId.
|
|
370
|
+
* Conformance group 6 asserts this deep-equals the resolver's table.
|
|
371
|
+
* @returns {Record<string,string>}
|
|
372
|
+
*/
|
|
373
|
+
function engineAliasTable() {
|
|
374
|
+
const table = {};
|
|
375
|
+
for (const r of RECORDS) {
|
|
376
|
+
if (r.id !== r.engineId) table[r.id] = r.engineId;
|
|
377
|
+
for (const a of r.aliases) {
|
|
378
|
+
if (a !== r.engineId) table[a] = r.engineId;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return table;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export {
|
|
385
|
+
getProvider,
|
|
386
|
+
listProviders,
|
|
387
|
+
providersFor,
|
|
388
|
+
isAvailable,
|
|
389
|
+
listVoices,
|
|
390
|
+
defaultVoice,
|
|
391
|
+
validateVoice,
|
|
392
|
+
displayName,
|
|
393
|
+
engineAliasTable,
|
|
394
|
+
kokoroGender,
|
|
395
|
+
ELEVENLABS_VOICES,
|
|
396
|
+
KOKORO_VOICE_IDS,
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
export default {
|
|
400
|
+
getProvider,
|
|
401
|
+
listProviders,
|
|
402
|
+
providersFor,
|
|
403
|
+
isAvailable,
|
|
404
|
+
listVoices,
|
|
405
|
+
defaultVoice,
|
|
406
|
+
validateVoice,
|
|
407
|
+
displayName,
|
|
408
|
+
engineAliasTable,
|
|
409
|
+
kokoroGender,
|
|
410
|
+
ELEVENLABS_VOICES,
|
|
411
|
+
KOKORO_VOICE_IDS,
|
|
412
|
+
};
|