agentvibes 5.12.0-alpha.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/.agentvibes/config.json +15 -1
- package/.agentvibes/install-manifest.json +342 -0
- package/.claude/activation-instructions +54 -54
- package/.claude/commands/agent-vibes/commands.json +0 -20
- package/.claude/commands/agent-vibes/unmute.md +6 -2
- 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 +6 -6
- package/.claude/config/audio-effects.cfg.bak-kokoro +7 -0
- package/.claude/config/background-music-enabled.txt +1 -1
- package/.claude/config/background-music-position.txt +27 -0
- package/.claude/config/background-music-volume.txt +1 -0
- package/.claude/config/background-music.cfg +1 -0
- package/.claude/config/background-music.txt +1 -0
- package/.claude/config/language.txt +1 -0
- package/.claude/config/personality.txt +1 -0
- package/.claude/config/tts-speech-rate.txt +1 -0
- package/.claude/config/tts-verbosity.txt +1 -0
- package/.claude/docs/TERMUX_SETUP.md +408 -408
- 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 +72 -27
- package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
- package/.claude/hooks/play-tts.sh +86 -69
- 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/audio-cache-utils.ps1.user.bak +119 -0
- 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/soprano-gradio-synth.py.user.bak +153 -0
- package/.claude/hooks-windows/tts-watcher.ps1 +84 -8
- package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
- package/.claude/piper-voices-dir.txt +1 -0
- package/.clawdbot/README.md +105 -105
- package/.mcp.json +15 -0
- package/LICENSE +190 -190
- package/README.md +20 -6
- package/RELEASE_NOTES.md +81 -0
- package/WINDOWS-SETUP.md +208 -208
- package/bin/agent-vibes +39 -39
- package/bin/mcp-server.js +121 -121
- 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 +146 -49
- package/mcp-server/test_mcp_correctness.py +20 -2
- package/mcp-server/test_server.py +395 -395
- package/mcp-server/test_windows_script_parity.py +0 -2
- package/package.json +1 -1
- package/src/cli/list-personalities.js +110 -110
- package/src/cli/list-voices.js +115 -11
- package/src/commands/bmad-voices.js +394 -394
- package/src/console/app.js +1 -9
- package/src/console/bling.js +71 -0
- package/src/console/brand-colors.js +13 -13
- package/src/console/constants/personalities.js +44 -44
- package/src/console/footer-config.js +0 -4
- package/src/console/music-preview.js +79 -0
- package/src/console/navigation.js +0 -1
- package/src/console/tabs/agents-tab.js +14 -2
- package/src/console/tabs/help-tab.js +314 -314
- package/src/console/tabs/music-tab.js +81 -5
- package/src/console/tabs/placeholder-tab.js +0 -2
- package/src/console/tabs/readme-tab.js +272 -272
- package/src/console/tabs/settings-tab.js +207 -14
- package/src/console/tabs/setup-tab.js +39 -36
- package/src/console/tabs/voices-tab.js +102 -23
- package/src/console/widgets/destroy-list.js +25 -25
- package/src/console/widgets/notice.js +55 -55
- package/src/console/widgets/track-picker.js +82 -0
- package/src/installer/language-screen.js +31 -31
- package/src/installer/music-file-input.js +304 -304
- package/src/installer.js +124 -10
- package/src/services/language-service.js +47 -47
- package/src/services/navigation-service.js +1 -1
- 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/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/provider-validator.js +62 -12
- package/src/utils/secure-music-storage.js +412 -412
- package/voice-assignments.json +8244 -8244
- 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
|
@@ -19,9 +19,11 @@ import path from 'node:path';
|
|
|
19
19
|
import os from 'node:os';
|
|
20
20
|
import crypto from 'node:crypto';
|
|
21
21
|
import { spawn } from 'node:child_process';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
22
23
|
import {
|
|
23
24
|
scanInstalledVoices, getVoiceMeta, genderIconTag, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker,
|
|
24
25
|
} from './voices-tab.js';
|
|
26
|
+
import { voicesForProvider } from '../../services/provider-voice-catalog.js';
|
|
25
27
|
import { LanguageService } from '../../services/language-service.js';
|
|
26
28
|
import { SUPPORTED_LANGUAGES, t } from '../../i18n/strings.js';
|
|
27
29
|
import { buildAudioEnv, detectWavPlayer, detectRemoteLlm } from '../audio-env.js';
|
|
@@ -32,7 +34,7 @@ import { PERSONALITY_EMOJIS } from '../constants/personalities.js';
|
|
|
32
34
|
import { formatTrackName as _sharedFormatTrackName, formatReverbState as _sharedFormatReverbState } from '../widgets/format-utils.js';
|
|
33
35
|
import { showNotice as _showNoticeWidget } from '../widgets/notice.js';
|
|
34
36
|
import {
|
|
35
|
-
getAvailableEngines, checkEngineInstalled,
|
|
37
|
+
getAvailableEngines, getAllEngines, checkEngineInstalled, receiverProviderId,
|
|
36
38
|
} from '../../services/tts-engine-service.js';
|
|
37
39
|
|
|
38
40
|
const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
|
|
@@ -130,6 +132,16 @@ export function createSettingsTab(screen, services) {
|
|
|
130
132
|
// Capture cwd once at construction (matches agents-tab.js pattern)
|
|
131
133
|
const _projectRoot = process.cwd();
|
|
132
134
|
|
|
135
|
+
// Preview hooks: prefer the CURRENT package copy over the project-local
|
|
136
|
+
// .claude/hooks (a tarball reinstall refreshes node_modules only, not the
|
|
137
|
+
// project's .claude), so previews never run a stale sender. CLAUDE_PROJECT_DIR
|
|
138
|
+
// still points at _projectRoot so the hook reads the project's config/provider.
|
|
139
|
+
const _pkgClaudeDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '.claude');
|
|
140
|
+
function _hookScript(subdir, name) {
|
|
141
|
+
const pkg = path.join(_pkgClaudeDir, subdir, name);
|
|
142
|
+
return fs.existsSync(pkg) ? pkg : path.join(_projectRoot, '.claude', subdir, name);
|
|
143
|
+
}
|
|
144
|
+
|
|
133
145
|
// ── Container ────────────────────────────────────────────────────────────
|
|
134
146
|
|
|
135
147
|
const box = blessed.box({
|
|
@@ -168,7 +180,7 @@ export function createSettingsTab(screen, services) {
|
|
|
168
180
|
const installed = engines.find(e => checkEngineInstalled(e.id));
|
|
169
181
|
return installed ? installed.name : '(none)';
|
|
170
182
|
}
|
|
171
|
-
const match =
|
|
183
|
+
const match = getAllEngines().find(e => e.id === engine);
|
|
172
184
|
return match ? match.name : engine;
|
|
173
185
|
},
|
|
174
186
|
desc: 'Global default — individual providers can override in Setup → Configure',
|
|
@@ -474,15 +486,128 @@ export function createSettingsTab(screen, services) {
|
|
|
474
486
|
|
|
475
487
|
// ── TTS Engine editor ────────────────────────────────────────────────────
|
|
476
488
|
|
|
489
|
+
// Build actionable "how to enable" text for an engine that shows [N/A] in the
|
|
490
|
+
// picker, so the user is never left stranded not knowing what to do.
|
|
491
|
+
function _engineGuidance(engine) {
|
|
492
|
+
if (!engine) return '';
|
|
493
|
+
if (engine.requiresApiKey) {
|
|
494
|
+
const keyHint = engine.id === 'elevenlabs'
|
|
495
|
+
? '\n or save it to ~/.agentvibes/elevenlabs-key.txt'
|
|
496
|
+
: '';
|
|
497
|
+
return `${engine.name} needs an API key.\n\n`
|
|
498
|
+
+ `Set ${engine.apiKeyEnvVar} in your shell profile${keyHint}.\n\n`
|
|
499
|
+
+ `Get a free key at elevenlabs.io, then reopen this menu.`;
|
|
500
|
+
}
|
|
501
|
+
if (engine.installCmd && !/^echo /.test(engine.installCmd)) {
|
|
502
|
+
return `${engine.name} isn't installed yet.\n\nInstall it with:\n ${engine.installCmd}\n\nThen reopen this menu.`;
|
|
503
|
+
}
|
|
504
|
+
return `${engine.name} isn't available on this system.`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function _showEngineGuidance(engine) {
|
|
508
|
+
const text = _engineGuidance(engine);
|
|
509
|
+
if (!text) return;
|
|
510
|
+
navigationService?.openModal();
|
|
511
|
+
const lines = text.split('\n');
|
|
512
|
+
const width = Math.min(76, Math.max(44, ...lines.map(l => l.length + 4)));
|
|
513
|
+
const modal = blessed.box({
|
|
514
|
+
parent: screen,
|
|
515
|
+
top: 'center',
|
|
516
|
+
left: 'center',
|
|
517
|
+
width,
|
|
518
|
+
height: lines.length + 4,
|
|
519
|
+
border: { type: 'line' },
|
|
520
|
+
tags: true,
|
|
521
|
+
keys: true,
|
|
522
|
+
mouse: true,
|
|
523
|
+
label: ` {bold}{yellow-fg} How to enable ${engine.name} {/yellow-fg}{/bold} `,
|
|
524
|
+
content: text,
|
|
525
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'yellow' } },
|
|
526
|
+
});
|
|
527
|
+
modal.setFront();
|
|
528
|
+
const close = () => {
|
|
529
|
+
navigationService?.closeModal();
|
|
530
|
+
destroyList(modal, screen);
|
|
531
|
+
box.focus();
|
|
532
|
+
screen.render();
|
|
533
|
+
};
|
|
534
|
+
modal.key(['enter', 'escape', 'q', 'space'], close);
|
|
535
|
+
modal.focus();
|
|
536
|
+
screen.render();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Announce the chosen engine to the remote by writing receiver-provider.txt —
|
|
540
|
+
// the exact file play-tts-ssh-remote.sh reads to fill the payload's `provider`
|
|
541
|
+
// field. The receiver then honors it (or overrides with its own local config).
|
|
542
|
+
// Written to ~/.agentvibes/config/ (the sender's guaranteed fallback path).
|
|
543
|
+
function _writeReceiverProvider(engineId) {
|
|
544
|
+
const provider = receiverProviderId(engineId);
|
|
545
|
+
try {
|
|
546
|
+
const dir = path.join(os.homedir(), '.agentvibes', 'config');
|
|
547
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
548
|
+
fs.writeFileSync(path.join(dir, 'receiver-provider.txt'), provider + '\n', 'utf8');
|
|
549
|
+
} catch (e) {
|
|
550
|
+
process.stderr.write(`[AgentVibes] could not write receiver-provider.txt: ${e.message}\n`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Confirm selecting an engine that can't run on THIS machine but may run on the
|
|
555
|
+
// remote receiver (remote mode only). onConfirm() runs on Yes.
|
|
556
|
+
function _confirmReceiverEngine(engine, onConfirm) {
|
|
557
|
+
navigationService?.openModal();
|
|
558
|
+
const lines = [
|
|
559
|
+
`${engine.name} can't run on this machine's OS.`,
|
|
560
|
+
'',
|
|
561
|
+
'In remote mode the receiver synthesizes the audio, so this',
|
|
562
|
+
'only works if your remote receiver supports it.',
|
|
563
|
+
'',
|
|
564
|
+
`Are you sure your receiver supports ${engine.name}?`,
|
|
565
|
+
'',
|
|
566
|
+
' [Y] Yes, send it to the receiver [N] Cancel',
|
|
567
|
+
];
|
|
568
|
+
const modal = blessed.box({
|
|
569
|
+
parent: screen,
|
|
570
|
+
top: 'center',
|
|
571
|
+
left: 'center',
|
|
572
|
+
width: Math.min(70, Math.max(50, ...lines.map(l => l.length + 4))),
|
|
573
|
+
height: lines.length + 4,
|
|
574
|
+
border: { type: 'line' },
|
|
575
|
+
tags: true,
|
|
576
|
+
keys: true,
|
|
577
|
+
mouse: true,
|
|
578
|
+
label: ' {bold}{yellow-fg} Confirm remote engine {/yellow-fg}{/bold} ',
|
|
579
|
+
content: lines.join('\n'),
|
|
580
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'yellow' } },
|
|
581
|
+
});
|
|
582
|
+
modal.setFront();
|
|
583
|
+
// Restore focus to whatever was focused before (the engine list) so cancel
|
|
584
|
+
// returns the user there to pick again; on confirm, onConfirm() takes over.
|
|
585
|
+
const prevFocus = screen.focused;
|
|
586
|
+
const close = (confirmed) => {
|
|
587
|
+
navigationService?.closeModal();
|
|
588
|
+
destroyList(modal, screen);
|
|
589
|
+
if (prevFocus && !prevFocus.detached) prevFocus.focus(); else box.focus();
|
|
590
|
+
screen.render();
|
|
591
|
+
if (confirmed) onConfirm();
|
|
592
|
+
};
|
|
593
|
+
modal.key(['y', 'Y', 'enter'], () => close(true));
|
|
594
|
+
modal.key(['n', 'N', 'escape', 'q'], () => close(false));
|
|
595
|
+
modal.focus();
|
|
596
|
+
screen.render();
|
|
597
|
+
}
|
|
598
|
+
|
|
477
599
|
function _editTtsEngine() {
|
|
478
600
|
navigationService?.openModal();
|
|
479
601
|
|
|
480
|
-
|
|
602
|
+
// Show ALL engines (not just the platform-available ones) so off-platform
|
|
603
|
+
// engines like Windows SAPI appear greyed "(Not supported)" instead of looking
|
|
604
|
+
// missing. `supported` marks whether the engine can run on this OS.
|
|
605
|
+
const engines = getAllEngines();
|
|
481
606
|
const modal = blessed.list({
|
|
482
607
|
parent: screen,
|
|
483
608
|
top: 'center',
|
|
484
609
|
left: 'center',
|
|
485
|
-
width:
|
|
610
|
+
width: 54,
|
|
486
611
|
height: engines.length + 4,
|
|
487
612
|
border: { type: 'line' },
|
|
488
613
|
tags: true,
|
|
@@ -500,17 +625,51 @@ export function createSettingsTab(screen, services) {
|
|
|
500
625
|
});
|
|
501
626
|
modal.setFront();
|
|
502
627
|
|
|
628
|
+
// In remote mode the receiver synthesizes, so off-platform engines (e.g.
|
|
629
|
+
// Windows SAPI on a Linux server) ARE valid choices — the selection is sent
|
|
630
|
+
// to the receiver. Locally they cannot run, so they stay greyed & blocked.
|
|
631
|
+
const _isRemote = (configService?.getConfig?.()?.audio_destination ?? 'local') === 'remote';
|
|
632
|
+
|
|
503
633
|
const items = engines.map(e => {
|
|
634
|
+
if (!e.supported) {
|
|
635
|
+
if (_isRemote) {
|
|
636
|
+
// Selectable, but tag it so it's clear it runs on the receiver, not here.
|
|
637
|
+
return `{cyan-fg} ${e.name} (Receiver){/cyan-fg}`;
|
|
638
|
+
}
|
|
639
|
+
// Greyed, non-selectable: not runnable on this OS and no remote to send to.
|
|
640
|
+
return `{#607d8b-fg} ${e.name} (Not supported){/#607d8b-fg}`;
|
|
641
|
+
}
|
|
504
642
|
const installed = checkEngineInstalled(e.id);
|
|
505
643
|
const status = installed ? '{green-fg}[OK]{/green-fg}' : '{yellow-fg}[N/A]{/yellow-fg}';
|
|
506
644
|
return ` ${e.name} ${status}`;
|
|
507
645
|
});
|
|
508
646
|
modal.setItems(items);
|
|
509
647
|
|
|
648
|
+
// Commit a selection: persist the local default AND announce it to the remote.
|
|
649
|
+
const _applyEngine = (sel, showGuidance) => {
|
|
650
|
+
configService.set('ttsEngine', sel.id);
|
|
651
|
+
_writeReceiverProvider(sel.id);
|
|
652
|
+
_closeModal();
|
|
653
|
+
if (showGuidance) _showEngineGuidance(sel);
|
|
654
|
+
};
|
|
655
|
+
|
|
510
656
|
modal.key(['enter'], () => {
|
|
511
657
|
const sel = engines[modal.selected];
|
|
512
|
-
if (sel)
|
|
513
|
-
|
|
658
|
+
if (!sel) { _closeModal(); return; }
|
|
659
|
+
if (!sel.supported) {
|
|
660
|
+
if (!_isRemote) {
|
|
661
|
+
// No remote receiver to run it on — explain and keep the menu open.
|
|
662
|
+
_showNoticeWidget(screen, `${sel.name} is not supported on this system`);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
// Remote mode: confirm the receiver can run it, then send the selection.
|
|
666
|
+
_confirmReceiverEngine(sel, () => _applyEngine(sel, false));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
// Supported locally: [N/A] means "installable here" — offer guidance, but not
|
|
670
|
+
// in remote mode (local install is irrelevant when the receiver synthesizes).
|
|
671
|
+
const notInstalled = !checkEngineInstalled(sel.id);
|
|
672
|
+
_applyEngine(sel, notInstalled && !_isRemote);
|
|
514
673
|
});
|
|
515
674
|
modal.key(['escape', 'q'], _closeModal);
|
|
516
675
|
|
|
@@ -633,7 +792,17 @@ export function createSettingsTab(screen, services) {
|
|
|
633
792
|
if (_vpClosed) return;
|
|
634
793
|
const savedIdx = vpList.selected ?? 0;
|
|
635
794
|
const savedScroll = vpList.childBase ?? 0;
|
|
636
|
-
|
|
795
|
+
// Provider-aware: list the ACTIVE engine's voices, derived from the Provider
|
|
796
|
+
// Catalog (via the voicesForProvider shim) — Kokoro/ElevenLabs from the catalog,
|
|
797
|
+
// Piper from the on-disk scan, soprano its single entry. Map {id,gender} → id
|
|
798
|
+
// so the rest of this picker (which treats _allVoices as string ids) is unchanged.
|
|
799
|
+
// The active ENGINE drives which voices to list. The "Default TTS Engine"
|
|
800
|
+
// selector writes config.ttsEngine (kokoro/elevenlabs/piper/…); prefer it,
|
|
801
|
+
// then the provider, then piper. (getActiveProvider = config.provider, which
|
|
802
|
+
// can be a transport like agentvibes-receiver and isn't the engine.)
|
|
803
|
+
const _activeProvider = configService?.getConfig?.()?.ttsEngine
|
|
804
|
+
|| providerService?.getActiveProvider?.() || 'piper';
|
|
805
|
+
_allVoices = voicesForProvider(_activeProvider, { scanInstalledVoices, getVoiceMeta }).map(v => v.id);
|
|
637
806
|
// Sort by display name so the first-letter quick jump is intuitive
|
|
638
807
|
_allVoices.sort((a, b) => getVoiceMeta(a).displayName.localeCompare(
|
|
639
808
|
getVoiceMeta(b).displayName, undefined, { sensitivity: 'base' }));
|
|
@@ -651,17 +820,41 @@ export function createSettingsTab(screen, services) {
|
|
|
651
820
|
const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)]; // NOSONAR
|
|
652
821
|
|
|
653
822
|
if (_isWin) {
|
|
654
|
-
//
|
|
655
|
-
const
|
|
656
|
-
|
|
823
|
+
// Which engine is being previewed? (Default TTS Engine selector.)
|
|
824
|
+
const _engine = configService?.getConfig?.()?.ttsEngine
|
|
825
|
+
|| providerService?.getActiveProvider?.() || 'piper';
|
|
826
|
+
const _prov = receiverProviderId(_engine); // windows-sapi / piper / kokoro / …
|
|
827
|
+
|
|
828
|
+
// Windows SAPI voice names ("Microsoft David Desktop") don't auto-route, and
|
|
829
|
+
// play-tts.ps1's per-LLM engine resolution overrides -ProviderOverride back to
|
|
830
|
+
// Piper — so preview SAPI by calling its provider script DIRECTLY. Deterministic
|
|
831
|
+
// and it plays the voice (effects are skipped, which is fine for a preview).
|
|
832
|
+
// Piper/Kokoro keep the existing play-tts.ps1 path (they auto-route correctly).
|
|
833
|
+
const _directNames = _prov === 'windows-sapi'
|
|
834
|
+
? ['play-tts-sapi.ps1', 'play-tts-windows-sapi.ps1']
|
|
835
|
+
: null;
|
|
836
|
+
let _script = null;
|
|
837
|
+
const _psArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File'];
|
|
838
|
+
if (_directNames) {
|
|
839
|
+
for (const n of _directNames) {
|
|
840
|
+
const p = _hookScript('hooks-windows', n);
|
|
841
|
+
if (fs.existsSync(p)) { _script = p; break; }
|
|
842
|
+
}
|
|
843
|
+
if (_script) _psArgs.push(_script, '-Text', phrase, '-VoiceOverride', voiceId);
|
|
844
|
+
}
|
|
845
|
+
if (!_script) {
|
|
846
|
+
_script = _hookScript('hooks-windows', 'play-tts.ps1');
|
|
847
|
+
if (!fs.existsSync(_script)) return;
|
|
848
|
+
_psArgs.push(_script, phrase, voiceId);
|
|
849
|
+
}
|
|
850
|
+
|
|
657
851
|
_previewVoiceId = voiceId;
|
|
658
852
|
if (!_vpClosed) {
|
|
659
853
|
vpPreviewLine.setContent(`{cyan-fg}♪ Playing: ${voiceId}...{/cyan-fg}`);
|
|
660
854
|
_refreshVP();
|
|
661
855
|
}
|
|
662
|
-
_previewProc = spawn('powershell',
|
|
663
|
-
|
|
664
|
-
], { stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv });
|
|
856
|
+
_previewProc = spawn('powershell', _psArgs, // NOSONAR
|
|
857
|
+
{ stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv });
|
|
665
858
|
_previewProc.on('exit', () => {
|
|
666
859
|
if (_previewVoiceId === voiceId) {
|
|
667
860
|
_previewVoiceId = null;
|
|
@@ -674,7 +867,7 @@ export function createSettingsTab(screen, services) {
|
|
|
674
867
|
}
|
|
675
868
|
|
|
676
869
|
// Non-Windows: use bash play-tts.sh
|
|
677
|
-
const playTtsScript =
|
|
870
|
+
const playTtsScript = _hookScript('hooks', 'play-tts.sh');
|
|
678
871
|
if (!fs.existsSync(playTtsScript)) return;
|
|
679
872
|
|
|
680
873
|
const remoteLlm = detectRemoteLlm();
|
|
@@ -46,7 +46,13 @@ import { destroyList } from '../widgets/destroy-list.js';
|
|
|
46
46
|
import { scanInstalledVoices, getVoiceMeta, genderIconTag, formatVoiceRow, voiceRowHeader, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker, getFavorites, getThumbsDown, toggleFavorite, toggleThumbsUp, toggleThumbsDown } from './voices-tab.js';
|
|
47
47
|
import { attachBtnBlink } from './agents-tab.js';
|
|
48
48
|
import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
|
|
49
|
+
import { buildBlingCommand, playBlingCue } from '../bling.js';
|
|
49
50
|
import { spawn, spawnSync } from 'node:child_process';
|
|
51
|
+
|
|
52
|
+
// Re-exported for backward compatibility — buildBlingCommand moved to
|
|
53
|
+
// ../bling.js so the music-preview surfaces can share it. Existing imports and
|
|
54
|
+
// tests that reference it from this module keep working.
|
|
55
|
+
export { buildBlingCommand };
|
|
50
56
|
import os from 'node:os';
|
|
51
57
|
import crypto from 'node:crypto';
|
|
52
58
|
import net from 'node:net';
|
|
@@ -271,33 +277,8 @@ export function buildPyModuleCheckArgs(mods) {
|
|
|
271
277
|
return ['-c', `import importlib.util as u, sys; sys.exit(0 if ${expr} else 1)`];
|
|
272
278
|
}
|
|
273
279
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
* (no spawning here, so it is unit-testable). Plays the bundled CC0 wav when
|
|
277
|
-
* present, else falls back to a system sound (Windows) / freedesktop cue or
|
|
278
|
-
* terminal bell (POSIX). stdio is ignored by the caller, so the POSIX bell is
|
|
279
|
-
* redirected to /dev/tty rather than the discarded stdout.
|
|
280
|
-
* @param {string} platform - process.platform value
|
|
281
|
-
* @param {string} wavPath - absolute path to the bling wav
|
|
282
|
-
* @param {boolean} haveWav - whether wavPath exists on disk
|
|
283
|
-
* @returns {{command: string, args: string[]}}
|
|
284
|
-
*/
|
|
285
|
-
export function buildBlingCommand(platform, wavPath, haveWav) {
|
|
286
|
-
if (platform === 'win32') {
|
|
287
|
-
const ps = haveWav
|
|
288
|
-
? `Add-Type -AssemblyName System.Windows.Forms; (New-Object System.Media.SoundPlayer('${wavPath.replace(/'/g, "''")}')).PlaySync()`
|
|
289
|
-
: '[System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 700';
|
|
290
|
-
return { command: 'powershell', args: ['-NoProfile', '-Command', ps] };
|
|
291
|
-
}
|
|
292
|
-
if (haveWav) {
|
|
293
|
-
// Pass wavPath as a positional arg ($1) so the path is never interpolated
|
|
294
|
-
// into the shell string (prevents injection / breakage on special chars).
|
|
295
|
-
const sh = 'paplay "$1" 2>/dev/null || aplay -q "$1" 2>/dev/null || printf "\\a" > /dev/tty 2>/dev/null';
|
|
296
|
-
return { command: 'bash', args: ['-c', sh, '--', wavPath] };
|
|
297
|
-
}
|
|
298
|
-
const sh = 'paplay /usr/share/sounds/freedesktop/stereo/message.oga 2>/dev/null || printf "\\a" > /dev/tty 2>/dev/null';
|
|
299
|
-
return { command: 'bash', args: ['-c', sh] };
|
|
300
|
-
}
|
|
280
|
+
// buildBlingCommand moved to ../bling.js (imported + re-exported at the top of
|
|
281
|
+
// this file). See that module for the readiness-cue command builder.
|
|
301
282
|
|
|
302
283
|
// ---------------------------------------------------------------------------
|
|
303
284
|
// Dependency detection helpers
|
|
@@ -2852,11 +2833,21 @@ export function createSetupTab(screen, services) {
|
|
|
2852
2833
|
let _kSpinInterval = null;
|
|
2853
2834
|
let _kSpinFrame = 0;
|
|
2854
2835
|
let _kSpinningIdx = -1;
|
|
2836
|
+
let _kSpinStartTs = 0;
|
|
2837
|
+
let _kFloorTimer = null; // pending _stopKSpinnerWithFloor timer (tracked so it can be cancelled)
|
|
2838
|
+
// Remote SSH preview is fire-and-forget: play-tts-ssh-remote.sh backgrounds
|
|
2839
|
+
// the ssh call and exits within milliseconds, so the row spinner would be
|
|
2840
|
+
// torn down before it ever paints a frame while the receiver plays the audio
|
|
2841
|
+
// a beat later — the user hears sound but never sees the spinner. Hold the
|
|
2842
|
+
// spinner on-screen for this minimum window so the remote path still gives a
|
|
2843
|
+
// visible "preview sent" cue (fire-and-forget has no playback signal to await).
|
|
2844
|
+
const _K_MIN_SPIN_MS = 1100;
|
|
2855
2845
|
|
|
2856
2846
|
function _startKSpinner(listIdx) {
|
|
2857
2847
|
_stopKSpinner();
|
|
2858
2848
|
_kSpinningIdx = listIdx;
|
|
2859
2849
|
_kSpinFrame = 0;
|
|
2850
|
+
_kSpinStartTs = Date.now();
|
|
2860
2851
|
_kSpinInterval = setInterval(() => {
|
|
2861
2852
|
if (_kClosed) { _stopKSpinner(); return; }
|
|
2862
2853
|
const spin = `{cyan-fg}${_K_SPIN[_kSpinFrame++ % _K_SPIN.length]}{/cyan-fg}`;
|
|
@@ -2866,6 +2857,9 @@ export function createSetupTab(screen, services) {
|
|
|
2866
2857
|
}
|
|
2867
2858
|
|
|
2868
2859
|
function _stopKSpinner() {
|
|
2860
|
+
// Cancel any pending floor timer so a stale one can't fire against a newer
|
|
2861
|
+
// preview's spinner (rapid Space presses) or after the picker closes.
|
|
2862
|
+
if (_kFloorTimer) { clearTimeout(_kFloorTimer); _kFloorTimer = null; }
|
|
2869
2863
|
if (_kSpinInterval) { clearInterval(_kSpinInterval); _kSpinInterval = null; }
|
|
2870
2864
|
if (_kSpinningIdx >= 0 && !_kClosed) {
|
|
2871
2865
|
kPicker.setItem(_kSpinningIdx, _kokoroItem(voices[_kSpinningIdx]));
|
|
@@ -2874,6 +2868,21 @@ export function createSetupTab(screen, services) {
|
|
|
2874
2868
|
_kSpinningIdx = -1;
|
|
2875
2869
|
}
|
|
2876
2870
|
|
|
2871
|
+
// Stop the spinner but keep it on-screen for at least _K_MIN_SPIN_MS so a
|
|
2872
|
+
// fire-and-forget remote send stays visible; runs `after` once the floor is
|
|
2873
|
+
// met. No-ops safely if the picker closes in the meantime (_stopKSpinner and
|
|
2874
|
+
// the _kClosed guard both short-circuit). Used only by the remote path — the
|
|
2875
|
+
// local synth path already spins for the whole (multi-second) synthesis.
|
|
2876
|
+
function _stopKSpinnerWithFloor(after) {
|
|
2877
|
+
if (_kFloorTimer) { clearTimeout(_kFloorTimer); _kFloorTimer = null; }
|
|
2878
|
+
const wait = Math.max(0, _K_MIN_SPIN_MS - (Date.now() - _kSpinStartTs));
|
|
2879
|
+
_kFloorTimer = setTimeout(() => {
|
|
2880
|
+
_kFloorTimer = null;
|
|
2881
|
+
_stopKSpinner();
|
|
2882
|
+
if (!_kClosed && after) after();
|
|
2883
|
+
}, wait);
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2877
2886
|
const LEGEND_H = 3;
|
|
2878
2887
|
const pickerH = Math.min(voices.length + LEGEND_H + 3, 24);
|
|
2879
2888
|
|
|
@@ -3147,14 +3156,8 @@ export function createSetupTab(screen, services) {
|
|
|
3147
3156
|
// Sound: "Ui sounds - Shimmering success" by Philip_Berger, CC0 (freesound
|
|
3148
3157
|
// #648212). See .claude/audio/ui/CREDITS.txt. Falls back to a system sound
|
|
3149
3158
|
// if the bundled asset is ever missing.
|
|
3150
|
-
const _blingWav = path.join(packageDir, '.claude', 'audio', 'ui', 'bling-success.wav');
|
|
3151
3159
|
function _playReadyCue() {
|
|
3152
|
-
|
|
3153
|
-
const { command, args } = buildBlingCommand(process.platform, _blingWav, fs.existsSync(_blingWav));
|
|
3154
|
-
const cue = spawn(command, args, { stdio: 'ignore', detached: true }); // NOSONAR
|
|
3155
|
-
cue.on('error', () => { /* best-effort cue; a spawn failure must not surface */ });
|
|
3156
|
-
cue.unref();
|
|
3157
|
-
} catch { /* the readiness cue is purely cosmetic — never break the preview */ }
|
|
3160
|
+
playBlingCue(packageDir);
|
|
3158
3161
|
}
|
|
3159
3162
|
|
|
3160
3163
|
kPicker.key(['space'], () => {
|
|
@@ -3226,8 +3229,7 @@ export function createSetupTab(screen, services) {
|
|
|
3226
3229
|
_kPreviewProc = remoteProc;
|
|
3227
3230
|
remoteProc.on('exit', (code) => {
|
|
3228
3231
|
_kPreviewProc = null;
|
|
3229
|
-
|
|
3230
|
-
if (_kClosed) return;
|
|
3232
|
+
_stopKSpinnerWithFloor(() => {
|
|
3231
3233
|
if (code !== 0 && _isCjkVoice(voiceId)) {
|
|
3232
3234
|
// CJK voice failed on receiver — needs the language-specific misaki extra there.
|
|
3233
3235
|
// Still try to download the .pt file locally so the picker shows ✓.
|
|
@@ -3272,6 +3274,7 @@ export function createSetupTab(screen, services) {
|
|
|
3272
3274
|
screen.render();
|
|
3273
3275
|
setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
|
|
3274
3276
|
}
|
|
3277
|
+
});
|
|
3275
3278
|
});
|
|
3276
3279
|
remoteProc.on('error', () => {
|
|
3277
3280
|
_kPreviewProc = null;
|
|
@@ -15,6 +15,40 @@ import { spawn } from 'node:child_process';
|
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import { buildAudioEnv, detectWavPlayer, getAllWavPlayers } from '../audio-env.js';
|
|
17
17
|
import { SURNAME_POOL, uniquifyVoiceName } from '../../utils/voice-names.js';
|
|
18
|
+
import { voicesForProvider, ELEVENLABS_VOICES, KOKORO_VOICE_IDS, WINDOWS_SAPI_VOICES, MACOS_VOICES, kokoroGender } from '../../services/provider-voice-catalog.js';
|
|
19
|
+
import { getProvider as _catalogGetProvider } from '../../services/provider-catalog.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve display name / gender / provider label for a NON-Piper catalog voice
|
|
23
|
+
* (Kokoro or ElevenLabs) from the Provider Catalog SSOT. Returns null for Piper
|
|
24
|
+
* ids so getVoiceMeta falls through to its Piper resolution. This keeps the
|
|
25
|
+
* picker's labels/gender correct once non-Piper voices are listed — otherwise
|
|
26
|
+
* a raw ElevenLabs id (EXAVITQu4vr4xnSDxMaL) would render as its own name,
|
|
27
|
+
* gender '—', provider 'Piper'.
|
|
28
|
+
*/
|
|
29
|
+
function catalogVoiceMeta(voiceId) {
|
|
30
|
+
const el = ELEVENLABS_VOICES.find(v => v.id === voiceId);
|
|
31
|
+
if (el) return { displayName: el.name || voiceId, gender: el.gender || '—', provider: 'ElevenLabs' };
|
|
32
|
+
if (KOKORO_VOICE_IDS.includes(voiceId)) {
|
|
33
|
+
// Kokoro ids look like af_heart / am_michael; title-case for display.
|
|
34
|
+
const pretty = voiceId.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
35
|
+
return { displayName: pretty, gender: kokoroGender(voiceId) || '—', provider: 'Kokoro' };
|
|
36
|
+
}
|
|
37
|
+
// OS-native voices (Windows SAPI "Microsoft David Desktop", macOS "Samantha").
|
|
38
|
+
const sapi = WINDOWS_SAPI_VOICES.find(v => v.id === voiceId);
|
|
39
|
+
if (sapi) return { displayName: sapi.name, gender: sapi.gender || '—', provider: 'Windows SAPI' };
|
|
40
|
+
const mac = MACOS_VOICES.find(v => v.id === voiceId);
|
|
41
|
+
if (mac) return { displayName: mac.name, gender: mac.gender || '—', provider: 'macOS Say' };
|
|
42
|
+
// Single-voice / native providers whose voice id IS the provider id or alias
|
|
43
|
+
// (soprano, windows-sapi/sapi, macos-say/say). Without this they fall through to
|
|
44
|
+
// the Piper resolution below and get mislabeled "Piper" (the soprano bug).
|
|
45
|
+
const prov = _catalogGetProvider(voiceId);
|
|
46
|
+
if (prov && prov.engineId !== 'piper') {
|
|
47
|
+
const label = prov.displayName.replace(/\s+TTS$/, '');
|
|
48
|
+
return { displayName: label, gender: '—', provider: label };
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
18
52
|
|
|
19
53
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
54
|
|
|
@@ -603,6 +637,12 @@ export function getVoiceMeta(voiceId) {
|
|
|
603
637
|
// Lazy-load the catalog so callers from any tab get uniquified names
|
|
604
638
|
loadCatalog();
|
|
605
639
|
|
|
640
|
+
// Non-Piper catalog voices (Kokoro / ElevenLabs) resolve from the Provider
|
|
641
|
+
// Catalog SSOT — real name, real gender, correct provider label — before the
|
|
642
|
+
// Piper-only paths below (which would mislabel them).
|
|
643
|
+
const nonPiper = catalogVoiceMeta(voiceId);
|
|
644
|
+
if (nonPiper) { _metaCache.set(voiceId, nonPiper); return nonPiper; }
|
|
645
|
+
|
|
606
646
|
const ms = parseMultiSpeaker(voiceId);
|
|
607
647
|
if (ms.isMultiSpeaker) {
|
|
608
648
|
if (!ms.speakerName) {
|
|
@@ -967,19 +1007,35 @@ export function createVoicesTab(screen, services) {
|
|
|
967
1007
|
// NOTE: projectRoot is AgentVibes repo root (3 levels up from src/console/tabs/)
|
|
968
1008
|
const projectRoot = path.resolve(__dirname, '..', '..', '..');
|
|
969
1009
|
let activeProvider = '';
|
|
1010
|
+
// Capture the dir the provider was read from so we can forward it to
|
|
1011
|
+
// play-tts.sh as --project-dir. Without it the script re-resolves the
|
|
1012
|
+
// provider from its OWN cwd/package .claude (which has no tts-provider.txt),
|
|
1013
|
+
// falls back to piper-local, and synthesizes to a silent local device on a
|
|
1014
|
+
// headless/remote box — i.e. the preview appears to do nothing. Passing the
|
|
1015
|
+
// exact dir we matched keeps the JS check and the shell routing in agreement.
|
|
1016
|
+
let activeProjectDir = '';
|
|
970
1017
|
try {
|
|
971
|
-
const
|
|
972
|
-
process.env.CLAUDE_PROJECT_DIR
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1018
|
+
const providerDirs = [
|
|
1019
|
+
process.env.CLAUDE_PROJECT_DIR,
|
|
1020
|
+
process.cwd(),
|
|
1021
|
+
projectRoot,
|
|
1022
|
+
os.homedir(),
|
|
976
1023
|
].filter(Boolean);
|
|
977
|
-
for (const
|
|
978
|
-
|
|
1024
|
+
for (const d of providerDirs) {
|
|
1025
|
+
const p = path.join(d, '.claude', 'tts-provider.txt');
|
|
1026
|
+
if (fs.existsSync(p)) { activeProvider = fs.readFileSync(p, 'utf8').trim(); activeProjectDir = d; break; }
|
|
979
1027
|
}
|
|
980
1028
|
} catch {}
|
|
981
1029
|
|
|
982
|
-
|
|
1030
|
+
// Route through the engine-aware play-tts for ANY non-Piper provider — remote
|
|
1031
|
+
// transports (ssh-remote / agentvibes-receiver) AND local non-Piper engines
|
|
1032
|
+
// (kokoro / elevenlabs / soprano / macos). Only Piper (and unset = default)
|
|
1033
|
+
// uses the fast local piper path below; play-tts.sh/.ps1 dispatches to the
|
|
1034
|
+
// correct engine so Kokoro/ElevenLabs previews actually synthesize instead of
|
|
1035
|
+
// erroring against a nonexistent <id>.onnx piper model.
|
|
1036
|
+
const _isPiperLocal = (activeProvider === '' || activeProvider === 'piper' || activeProvider === 'windows-piper');
|
|
1037
|
+
if (!_isPiperLocal) {
|
|
1038
|
+
const _remote = _remoteProviders.includes(activeProvider);
|
|
983
1039
|
const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
|
|
984
1040
|
const phrase = `Hi, my name is ${getVoiceMeta(voiceId).displayName}.`;
|
|
985
1041
|
// Hooks live in the AgentVibes package (projectRoot), not the user's project dir.
|
|
@@ -994,12 +1050,14 @@ export function createVoicesTab(screen, services) {
|
|
|
994
1050
|
let proc;
|
|
995
1051
|
if (isWindows) {
|
|
996
1052
|
const playTts = path.join(hooksBase, '.claude', 'hooks-windows', 'play-tts.ps1');
|
|
997
|
-
|
|
1053
|
+
const _pdArgs = activeProjectDir ? ['-ProjectDir', activeProjectDir] : [];
|
|
1054
|
+
proc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTts, phrase, voiceId, ..._pdArgs], { // NOSONAR
|
|
998
1055
|
stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv,
|
|
999
1056
|
});
|
|
1000
1057
|
} else {
|
|
1001
1058
|
const playTts = path.join(hooksBase, '.claude', 'hooks', 'play-tts.sh');
|
|
1002
|
-
|
|
1059
|
+
const _pdArgs = activeProjectDir ? ['--project-dir', activeProjectDir] : [];
|
|
1060
|
+
proc = spawn('bash', [playTts, phrase, voiceId, ..._pdArgs], { // NOSONAR
|
|
1003
1061
|
stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: _spawnEnv,
|
|
1004
1062
|
cwd: process.cwd(),
|
|
1005
1063
|
});
|
|
@@ -1007,7 +1065,7 @@ export function createVoicesTab(screen, services) {
|
|
|
1007
1065
|
_playingProcess = proc;
|
|
1008
1066
|
_playingVoiceId = voiceId;
|
|
1009
1067
|
refreshDisplay();
|
|
1010
|
-
previewLine.setContent(
|
|
1068
|
+
previewLine.setContent(`{bright-magenta-fg}♪ Synthesizing${_remote ? ' on remote' : ''}...{/bright-magenta-fg}`);
|
|
1011
1069
|
screen.render();
|
|
1012
1070
|
let _stderrBuf = '';
|
|
1013
1071
|
if (proc.stderr) {
|
|
@@ -1678,10 +1736,16 @@ export function createVoicesTab(screen, services) {
|
|
|
1678
1736
|
// genderIconTag has invisible color tags — pad with literal spaces (1 visible char + 3 spaces = 4)
|
|
1679
1737
|
const gIcon = genderIconTag(gender);
|
|
1680
1738
|
if (!installed) {
|
|
1681
|
-
// Greyed-out row for uninstalled catalog voices
|
|
1682
|
-
|
|
1739
|
+
// Greyed-out row for uninstalled catalog voices. Use a mid blue-grey (not
|
|
1740
|
+
// bright-black) so the row stays legible when selected — bright-black on the
|
|
1741
|
+
// green selection bg is unreadable. Close the grey wrap around the icon so
|
|
1742
|
+
// its own colors show.
|
|
1743
|
+
return `{#90a4ae-fg} ${star} ${name}{/#90a4ae-fg}${gIcon} {#90a4ae-fg}${provider}{/#90a4ae-fg}`;
|
|
1683
1744
|
}
|
|
1684
|
-
|
|
1745
|
+
// No fg wrap on the name/provider: unselected rows inherit the list's item.fg
|
|
1746
|
+
// (labelFg); the selected row inherits selected.fg (#ffffff bold) so the
|
|
1747
|
+
// highlight is white-on-green and readable, instead of pale text on green.
|
|
1748
|
+
return ` ${star}${dot} ${name}${gIcon} ${provider}${isPrev ? ` ${_tl('voicePlaying')}` : ''}`;
|
|
1685
1749
|
});
|
|
1686
1750
|
}
|
|
1687
1751
|
|
|
@@ -1732,15 +1796,30 @@ export function createVoicesTab(screen, services) {
|
|
|
1732
1796
|
_metaCache.clear();
|
|
1733
1797
|
}
|
|
1734
1798
|
|
|
1735
|
-
//
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
//
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1799
|
+
// Provider-aware voice list. Piper (disk-discovered) keeps the installer
|
|
1800
|
+
// behavior — installed voices plus uninstalled catalog voices to download.
|
|
1801
|
+
// For Kokoro/ElevenLabs/soprano the voices come from the Provider Catalog
|
|
1802
|
+
// (via voicesForProvider) and are all available (nothing to "install"), so
|
|
1803
|
+
// mark them installed to suppress the download UI.
|
|
1804
|
+
// Prefer the explicitly-chosen Default TTS Engine (config.ttsEngine) over the
|
|
1805
|
+
// provider (which may be a transport like agentvibes-receiver, not an engine).
|
|
1806
|
+
const _vtProvider = configService?.getConfig?.()?.ttsEngine
|
|
1807
|
+
|| providerService?.getActiveProvider?.() || 'piper';
|
|
1808
|
+
const _vtIsDisk = (_vtProvider === 'piper' || _vtProvider === 'windows-piper');
|
|
1809
|
+
if (_vtIsDisk) {
|
|
1810
|
+
// Installed voices (from local disk)
|
|
1811
|
+
const installed = scanInstalledVoices();
|
|
1812
|
+
_installedSet = new Set(installed);
|
|
1813
|
+
|
|
1814
|
+
// Merge: installed voices first, then uninstalled catalog voices
|
|
1815
|
+
const catalogOnly = _catalogEntries
|
|
1816
|
+
.filter(c => !_installedSet.has(c.voiceId))
|
|
1817
|
+
.map(c => c.voiceId);
|
|
1818
|
+
_allVoices = [...installed, ...catalogOnly];
|
|
1819
|
+
} else {
|
|
1820
|
+
_allVoices = voicesForProvider(_vtProvider, { scanInstalledVoices, getVoiceMeta }).map(v => v.id);
|
|
1821
|
+
_installedSet = new Set(_allVoices);
|
|
1822
|
+
}
|
|
1744
1823
|
|
|
1745
1824
|
const active = providerService.getActiveVoiceId();
|
|
1746
1825
|
const favorites = getFavorites(configService);
|