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
|
@@ -23,6 +23,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
23
23
|
import {
|
|
24
24
|
scanInstalledVoices, getVoiceMeta, genderIconTag, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker,
|
|
25
25
|
} from './voices-tab.js';
|
|
26
|
+
import { voicesForProvider } from '../../services/provider-voice-catalog.js';
|
|
26
27
|
import { LanguageService } from '../../services/language-service.js';
|
|
27
28
|
import { SUPPORTED_LANGUAGES, t } from '../../i18n/strings.js';
|
|
28
29
|
import { buildAudioEnv, detectWavPlayer, detectRemoteLlm } from '../audio-env.js';
|
|
@@ -33,7 +34,7 @@ import { PERSONALITY_EMOJIS } from '../constants/personalities.js';
|
|
|
33
34
|
import { formatTrackName as _sharedFormatTrackName, formatReverbState as _sharedFormatReverbState } from '../widgets/format-utils.js';
|
|
34
35
|
import { showNotice as _showNoticeWidget } from '../widgets/notice.js';
|
|
35
36
|
import {
|
|
36
|
-
getAvailableEngines, checkEngineInstalled,
|
|
37
|
+
getAvailableEngines, getAllEngines, checkEngineInstalled, receiverProviderId,
|
|
37
38
|
} from '../../services/tts-engine-service.js';
|
|
38
39
|
|
|
39
40
|
const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
|
|
@@ -179,7 +180,7 @@ export function createSettingsTab(screen, services) {
|
|
|
179
180
|
const installed = engines.find(e => checkEngineInstalled(e.id));
|
|
180
181
|
return installed ? installed.name : '(none)';
|
|
181
182
|
}
|
|
182
|
-
const match =
|
|
183
|
+
const match = getAllEngines().find(e => e.id === engine);
|
|
183
184
|
return match ? match.name : engine;
|
|
184
185
|
},
|
|
185
186
|
desc: 'Global default — individual providers can override in Setup → Configure',
|
|
@@ -485,15 +486,128 @@ export function createSettingsTab(screen, services) {
|
|
|
485
486
|
|
|
486
487
|
// ── TTS Engine editor ────────────────────────────────────────────────────
|
|
487
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
|
+
|
|
488
599
|
function _editTtsEngine() {
|
|
489
600
|
navigationService?.openModal();
|
|
490
601
|
|
|
491
|
-
|
|
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();
|
|
492
606
|
const modal = blessed.list({
|
|
493
607
|
parent: screen,
|
|
494
608
|
top: 'center',
|
|
495
609
|
left: 'center',
|
|
496
|
-
width:
|
|
610
|
+
width: 54,
|
|
497
611
|
height: engines.length + 4,
|
|
498
612
|
border: { type: 'line' },
|
|
499
613
|
tags: true,
|
|
@@ -511,17 +625,51 @@ export function createSettingsTab(screen, services) {
|
|
|
511
625
|
});
|
|
512
626
|
modal.setFront();
|
|
513
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
|
+
|
|
514
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
|
+
}
|
|
515
642
|
const installed = checkEngineInstalled(e.id);
|
|
516
643
|
const status = installed ? '{green-fg}[OK]{/green-fg}' : '{yellow-fg}[N/A]{/yellow-fg}';
|
|
517
644
|
return ` ${e.name} ${status}`;
|
|
518
645
|
});
|
|
519
646
|
modal.setItems(items);
|
|
520
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
|
+
|
|
521
656
|
modal.key(['enter'], () => {
|
|
522
657
|
const sel = engines[modal.selected];
|
|
523
|
-
if (sel)
|
|
524
|
-
|
|
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);
|
|
525
673
|
});
|
|
526
674
|
modal.key(['escape', 'q'], _closeModal);
|
|
527
675
|
|
|
@@ -644,7 +792,17 @@ export function createSettingsTab(screen, services) {
|
|
|
644
792
|
if (_vpClosed) return;
|
|
645
793
|
const savedIdx = vpList.selected ?? 0;
|
|
646
794
|
const savedScroll = vpList.childBase ?? 0;
|
|
647
|
-
|
|
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);
|
|
648
806
|
// Sort by display name so the first-letter quick jump is intuitive
|
|
649
807
|
_allVoices.sort((a, b) => getVoiceMeta(a).displayName.localeCompare(
|
|
650
808
|
getVoiceMeta(b).displayName, undefined, { sensitivity: 'base' }));
|
|
@@ -662,17 +820,41 @@ export function createSettingsTab(screen, services) {
|
|
|
662
820
|
const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)]; // NOSONAR
|
|
663
821
|
|
|
664
822
|
if (_isWin) {
|
|
665
|
-
//
|
|
666
|
-
const
|
|
667
|
-
|
|
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
|
+
|
|
668
851
|
_previewVoiceId = voiceId;
|
|
669
852
|
if (!_vpClosed) {
|
|
670
853
|
vpPreviewLine.setContent(`{cyan-fg}♪ Playing: ${voiceId}...{/cyan-fg}`);
|
|
671
854
|
_refreshVP();
|
|
672
855
|
}
|
|
673
|
-
_previewProc = spawn('powershell',
|
|
674
|
-
|
|
675
|
-
], { stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv });
|
|
856
|
+
_previewProc = spawn('powershell', _psArgs, // NOSONAR
|
|
857
|
+
{ stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv });
|
|
676
858
|
_previewProc.on('exit', () => {
|
|
677
859
|
if (_previewVoiceId === voiceId) {
|
|
678
860
|
_previewVoiceId = null;
|
|
@@ -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
|
|
@@ -3175,14 +3156,8 @@ export function createSetupTab(screen, services) {
|
|
|
3175
3156
|
// Sound: "Ui sounds - Shimmering success" by Philip_Berger, CC0 (freesound
|
|
3176
3157
|
// #648212). See .claude/audio/ui/CREDITS.txt. Falls back to a system sound
|
|
3177
3158
|
// if the bundled asset is ever missing.
|
|
3178
|
-
const _blingWav = path.join(packageDir, '.claude', 'audio', 'ui', 'bling-success.wav');
|
|
3179
3159
|
function _playReadyCue() {
|
|
3180
|
-
|
|
3181
|
-
const { command, args } = buildBlingCommand(process.platform, _blingWav, fs.existsSync(_blingWav));
|
|
3182
|
-
const cue = spawn(command, args, { stdio: 'ignore', detached: true }); // NOSONAR
|
|
3183
|
-
cue.on('error', () => { /* best-effort cue; a spawn failure must not surface */ });
|
|
3184
|
-
cue.unref();
|
|
3185
|
-
} catch { /* the readiness cue is purely cosmetic — never break the preview */ }
|
|
3160
|
+
playBlingCue(packageDir);
|
|
3186
3161
|
}
|
|
3187
3162
|
|
|
3188
3163
|
kPicker.key(['space'], () => {
|
|
@@ -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) {
|
|
@@ -987,7 +1027,15 @@ export function createVoicesTab(screen, services) {
|
|
|
987
1027
|
}
|
|
988
1028
|
} catch {}
|
|
989
1029
|
|
|
990
|
-
|
|
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);
|
|
991
1039
|
const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
|
|
992
1040
|
const phrase = `Hi, my name is ${getVoiceMeta(voiceId).displayName}.`;
|
|
993
1041
|
// Hooks live in the AgentVibes package (projectRoot), not the user's project dir.
|
|
@@ -1017,7 +1065,7 @@ export function createVoicesTab(screen, services) {
|
|
|
1017
1065
|
_playingProcess = proc;
|
|
1018
1066
|
_playingVoiceId = voiceId;
|
|
1019
1067
|
refreshDisplay();
|
|
1020
|
-
previewLine.setContent(
|
|
1068
|
+
previewLine.setContent(`{bright-magenta-fg}♪ Synthesizing${_remote ? ' on remote' : ''}...{/bright-magenta-fg}`);
|
|
1021
1069
|
screen.render();
|
|
1022
1070
|
let _stderrBuf = '';
|
|
1023
1071
|
if (proc.stderr) {
|
|
@@ -1688,10 +1736,16 @@ export function createVoicesTab(screen, services) {
|
|
|
1688
1736
|
// genderIconTag has invisible color tags — pad with literal spaces (1 visible char + 3 spaces = 4)
|
|
1689
1737
|
const gIcon = genderIconTag(gender);
|
|
1690
1738
|
if (!installed) {
|
|
1691
|
-
// Greyed-out row for uninstalled catalog voices
|
|
1692
|
-
|
|
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}`;
|
|
1693
1744
|
}
|
|
1694
|
-
|
|
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')}` : ''}`;
|
|
1695
1749
|
});
|
|
1696
1750
|
}
|
|
1697
1751
|
|
|
@@ -1742,15 +1796,30 @@ export function createVoicesTab(screen, services) {
|
|
|
1742
1796
|
_metaCache.clear();
|
|
1743
1797
|
}
|
|
1744
1798
|
|
|
1745
|
-
//
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
//
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
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
|
+
}
|
|
1754
1823
|
|
|
1755
1824
|
const active = providerService.getActiveVoiceId();
|
|
1756
1825
|
const favorites = getFavorites(configService);
|
|
@@ -8,11 +8,19 @@
|
|
|
8
8
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
11
12
|
import { destroyList } from './destroy-list.js';
|
|
12
13
|
import { BRAND_PINK } from '../brand-colors.js';
|
|
13
14
|
import { renderHelpBar, selectorTitle } from './help-bar.js';
|
|
14
15
|
import { formatTrackName } from './format-utils.js';
|
|
15
16
|
import { buildAudioEnv, spawnMp3Player } from '../audio-env.js';
|
|
17
|
+
import { resolveMusicProvider, spawnMusicRemote } from '../music-preview.js';
|
|
18
|
+
import { playBlingCue } from '../bling.js';
|
|
19
|
+
|
|
20
|
+
// AgentVibes package/repo root — resolves bundled assets (bling cue) and is the
|
|
21
|
+
// package fallback when resolving the active provider for remote forwarding.
|
|
22
|
+
const _WIDGET_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const _PKG_ROOT = path.resolve(_WIDGET_DIR, '..', '..', '..');
|
|
16
24
|
|
|
17
25
|
const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
|
|
18
26
|
let blessed;
|
|
@@ -237,6 +245,10 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
|
|
|
237
245
|
const _spawnEnv = buildAudioEnv();
|
|
238
246
|
let _previewProc = null;
|
|
239
247
|
let _previewTrackId = null;
|
|
248
|
+
// Remote playback is fire-and-forget (the local sender exits in ms while the
|
|
249
|
+
// receiver keeps playing), so the toggle tracks the intended remote track
|
|
250
|
+
// here rather than relying on a live local process.
|
|
251
|
+
let _remotePreviewTrackId = null;
|
|
240
252
|
|
|
241
253
|
function _killPreview() {
|
|
242
254
|
if (_previewProc) {
|
|
@@ -244,9 +256,75 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
|
|
|
244
256
|
_previewProc = null;
|
|
245
257
|
}
|
|
246
258
|
_previewTrackId = null;
|
|
259
|
+
// Stop any remote preview so closing/selecting doesn't leave music playing
|
|
260
|
+
// on the receiver (the local equivalent of killing the player process).
|
|
261
|
+
if (_remotePreviewTrackId) {
|
|
262
|
+
const mp = resolveMusicProvider(_PKG_ROOT);
|
|
263
|
+
if (mp.remote) _sendRemote(mp, { stop: true });
|
|
264
|
+
_remotePreviewTrackId = null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Forward a music track to the receiver (or stop it), mirroring the Music
|
|
270
|
+
* tab's remote-preview behavior. Fire-and-forget; UI errors surface in the
|
|
271
|
+
* title status area. Returns true if the send was launched.
|
|
272
|
+
* @param {{remote:boolean, projectDir:string}} mp
|
|
273
|
+
* @param {{track?:string, stop?:boolean}} opts
|
|
274
|
+
*/
|
|
275
|
+
function _sendRemote(mp, { track = null, stop = false } = {}) {
|
|
276
|
+
let proc;
|
|
277
|
+
try {
|
|
278
|
+
proc = spawnMusicRemote({ packageRoot: _PKG_ROOT, projectDir: mp.projectDir, env: _spawnEnv, track, stop });
|
|
279
|
+
} catch {
|
|
280
|
+
if (!stop) {
|
|
281
|
+
_setStatus(' {red-fg}Remote preview failed{/red-fg} ');
|
|
282
|
+
setTimeout(() => { _setStatus(); }, 4000);
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
let _rerr = '';
|
|
287
|
+
if (proc.stderr) proc.stderr.on('data', d => { _rerr += d.toString(); });
|
|
288
|
+
proc.on('exit', (code) => {
|
|
289
|
+
if (stop || code === 0) return;
|
|
290
|
+
if (_remotePreviewTrackId === track) _remotePreviewTrackId = null;
|
|
291
|
+
const msg = _rerr.trim().split('\n').pop() || 'Remote preview failed';
|
|
292
|
+
_setStatus(` {red-fg}♪ ${msg}{/red-fg} `);
|
|
293
|
+
setTimeout(() => { _setStatus(); }, 4000);
|
|
294
|
+
});
|
|
295
|
+
proc.on('error', () => {
|
|
296
|
+
if (stop) return;
|
|
297
|
+
if (_remotePreviewTrackId === track) _remotePreviewTrackId = null;
|
|
298
|
+
_setStatus(' {red-fg}Remote preview failed{/red-fg} ');
|
|
299
|
+
setTimeout(() => { _setStatus(); }, 4000);
|
|
300
|
+
});
|
|
301
|
+
return true;
|
|
247
302
|
}
|
|
248
303
|
|
|
249
304
|
function _previewTrack(trackFile) {
|
|
305
|
+
// Transport provider: forward to the receiver instead of local playback
|
|
306
|
+
// (which is silent on a headless box) — parity with the Music tab.
|
|
307
|
+
const _mp = resolveMusicProvider(_PKG_ROOT);
|
|
308
|
+
if (_mp.remote) {
|
|
309
|
+
// Toggle off if same track → explicit music-stop signal
|
|
310
|
+
if (_remotePreviewTrackId === trackFile) {
|
|
311
|
+
_sendRemote(_mp, { stop: true });
|
|
312
|
+
_remotePreviewTrackId = null;
|
|
313
|
+
_setStatus();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// Bling first (fire-and-forget, plays locally like the voice preview),
|
|
317
|
+
// then forward the track to the receiver.
|
|
318
|
+
playBlingCue(_PKG_ROOT);
|
|
319
|
+
if (_sendRemote(_mp, { track: trackFile })) {
|
|
320
|
+
_remotePreviewTrackId = trackFile;
|
|
321
|
+
const rlabel = tracks.find(t => t.file === trackFile)?.label ?? trackFile;
|
|
322
|
+
_setStatus(` {bright-cyan-fg}♪ ${rlabel} (Space to stop){/bright-cyan-fg} `);
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Local playback ──────────────────────────────────────────────────────
|
|
250
328
|
// Toggle off if same track
|
|
251
329
|
if (_previewTrackId === trackFile) {
|
|
252
330
|
_killPreview();
|
|
@@ -266,6 +344,10 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
|
|
|
266
344
|
return;
|
|
267
345
|
}
|
|
268
346
|
|
|
347
|
+
// Bling first (fire-and-forget) — same readiness cue as the voice preview —
|
|
348
|
+
// then start local playback.
|
|
349
|
+
playBlingCue(_PKG_ROOT);
|
|
350
|
+
|
|
269
351
|
const proc = spawnMp3Player(trackPath, _spawnEnv);
|
|
270
352
|
if (!proc) {
|
|
271
353
|
_setStatus(' {red-fg}No MP3 player found{/red-fg} ');
|