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/cli/list-voices.js
CHANGED
|
@@ -1,114 +1,218 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Voice List Display - Beautiful multi-column voice listing
|
|
4
|
-
* Called by voice-manager.sh to display voices with boxen formatting
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { formatVoicesList } from '../utils/list-formatter.js';
|
|
8
|
-
import fs from 'fs';
|
|
9
|
-
import path from 'path';
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Voice List Display - Beautiful multi-column voice listing
|
|
4
|
+
* Called by voice-manager.sh to display voices with boxen formatting
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { formatVoicesList } from '../utils/list-formatter.js';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import { execFileSync } from 'child_process';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import {
|
|
14
|
+
KOKORO_VOICE_IDS,
|
|
15
|
+
kokoroGender,
|
|
16
|
+
ELEVENLABS_VOICES,
|
|
17
|
+
} from '../services/provider-voice-catalog.js';
|
|
18
|
+
import {
|
|
19
|
+
listVoices as catalogListVoices,
|
|
20
|
+
getProvider,
|
|
21
|
+
} from '../services/provider-catalog.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get Piper voices from voice directory
|
|
25
|
+
*/
|
|
26
|
+
function getPiperVoices(voiceDir, currentVoice) {
|
|
27
|
+
const voices = [];
|
|
28
|
+
|
|
29
|
+
if (!fs.existsSync(voiceDir)) {
|
|
30
|
+
return voices;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const files = fs.readdirSync(voiceDir);
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
if (file.endsWith('.onnx')) {
|
|
36
|
+
const voiceName = path.basename(file, '.onnx');
|
|
37
|
+
voices.push({
|
|
38
|
+
name: voiceName,
|
|
39
|
+
lang: extractLanguage(voiceName),
|
|
40
|
+
current: voiceName === currentVoice
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return voices.sort((a, b) => a.name.localeCompare(b.name));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get macOS voices using say -v ?
|
|
50
|
+
*/
|
|
51
|
+
function getMacOSVoices(currentVoice) {
|
|
52
|
+
const voices = [];
|
|
53
|
+
|
|
54
|
+
if (os.platform() !== 'darwin') {
|
|
55
|
+
return voices;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const output = execFileSync('say', ['-v', '?'], { encoding: 'utf8' }); // NOSONAR - Safe: checking macOS say voices from system PATH
|
|
60
|
+
const lines = output.split('\n');
|
|
61
|
+
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
if (!line.trim()) continue;
|
|
64
|
+
|
|
65
|
+
const parts = line.trim().split(/\s+/);
|
|
66
|
+
if (parts.length >= 2) {
|
|
67
|
+
const voiceName = parts[0];
|
|
68
|
+
const lang = parts[1];
|
|
69
|
+
|
|
70
|
+
voices.push({
|
|
71
|
+
name: voiceName,
|
|
72
|
+
lang,
|
|
73
|
+
current: voiceName === currentVoice
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// say command failed
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return voices;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Kokoro voice ids follow `<lang><sex>_name`. The first char is the language.
|
|
86
|
+
* Map it to a human-readable language label for the listing.
|
|
87
|
+
*/
|
|
88
|
+
const KOKORO_LANG_LABELS = {
|
|
89
|
+
a: 'en-US', b: 'en-GB', j: 'ja', z: 'zh', e: 'es',
|
|
90
|
+
f: 'fr', h: 'hi', i: 'it', p: 'pt-BR', k: 'ko',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get Kokoro voices from the canonical catalog (KOKORO_VOICE_IDS).
|
|
95
|
+
* Kokoro's voice set is a fixed catalog, not discovered on disk.
|
|
96
|
+
*/
|
|
97
|
+
function getKokoroVoices(currentVoice) {
|
|
98
|
+
return KOKORO_VOICE_IDS.map((id) => ({
|
|
99
|
+
name: id,
|
|
100
|
+
lang: KOKORO_LANG_LABELS[id[0]] || '',
|
|
101
|
+
gender: kokoroGender(id),
|
|
102
|
+
current: id === currentVoice,
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get ElevenLabs voices from the canonical catalog (ELEVENLABS_VOICES).
|
|
108
|
+
* The listing shows the friendly name; matching against currentVoice accepts
|
|
109
|
+
* either the friendly name or the raw voice_id.
|
|
110
|
+
*/
|
|
111
|
+
function getElevenLabsVoices(currentVoice) {
|
|
112
|
+
return ELEVENLABS_VOICES.map((v) => ({
|
|
113
|
+
name: v.name,
|
|
114
|
+
lang: v.lang || '',
|
|
115
|
+
gender: v.gender || '',
|
|
116
|
+
current: v.name === currentVoice || v.id === currentVoice,
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get Soprano voices from the canonical catalog. Soprano is voiceModel `single`
|
|
122
|
+
* (design §3.1): exactly one canonical voice, soprano-default — no picker.
|
|
123
|
+
*/
|
|
124
|
+
function getSopranoVoices(currentVoice) {
|
|
125
|
+
return catalogListVoices('soprano').map((v) => ({
|
|
126
|
+
name: v.id,
|
|
127
|
+
lang: '',
|
|
128
|
+
gender: v.gender || '',
|
|
129
|
+
current: v.id === currentVoice || currentVoice === '' || currentVoice === 'soprano',
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Extract language code from voice name
|
|
135
|
+
*/
|
|
136
|
+
function extractLanguage(voiceName) {
|
|
137
|
+
const match = voiceName.match(/^([a-z]{2}_[A-Z]{2})/);
|
|
138
|
+
return match ? match[1] : '';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Discovered providers list-voices enumerates HERE, each via its own platform
|
|
143
|
+
* discovery path (piper: `*.onnx` disk glob; macos: `say -v ?`). Other discovered
|
|
144
|
+
* providers (windows-piper / windows-sapi) are enumerated by the Windows lister,
|
|
145
|
+
* not this Unix/darwin CLI, so they fall to the honest "no voice list" label —
|
|
146
|
+
* preserving pre-AVI-S9.5 output. Adding a record with a static/name-to-id/single
|
|
147
|
+
* voiceModel adds a list arm for free (no edit here needed).
|
|
148
|
+
*/
|
|
149
|
+
const LISTABLE_DISCOVERED = new Set(['piper', 'macos']);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resolve a provider token to its voice list + display label by iterating the
|
|
153
|
+
* Provider Catalog (src/services/provider-catalog.js) and branching ONLY on the
|
|
154
|
+
* record's `voiceModel` — replacing the former four hardcoded per-provider
|
|
155
|
+
* equality branches (AVI-S9.5 / design row 19). Unknown tokens keep the honest
|
|
156
|
+
* "no voice list available" label from AVI-S8.1.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} provider
|
|
159
|
+
* @param {string} currentVoice
|
|
160
|
+
* @param {string} voiceDir
|
|
161
|
+
* @returns {{ voices: object[], providerName: string }}
|
|
162
|
+
*/
|
|
163
|
+
function selectVoices(provider, currentVoice, voiceDir) {
|
|
164
|
+
const record = getProvider(provider);
|
|
165
|
+
if (!record) {
|
|
166
|
+
return { voices: [], providerName: `${provider} (no voice list available)` };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Label preserves the pre-existing strings exactly (macOS uses "TTS", not the
|
|
170
|
+
// catalog display name "macOS Say"); all others equal record.displayName.
|
|
171
|
+
const providerName = record.id === 'macos' ? 'macOS TTS' : record.displayName;
|
|
172
|
+
|
|
173
|
+
switch (record.voiceModel) {
|
|
174
|
+
case 'static': // kokoro
|
|
175
|
+
return { voices: getKokoroVoices(currentVoice), providerName };
|
|
176
|
+
case 'name-to-id': // elevenlabs
|
|
177
|
+
return { voices: getElevenLabsVoices(currentVoice), providerName };
|
|
178
|
+
case 'single': // soprano
|
|
179
|
+
return { voices: getSopranoVoices(currentVoice), providerName };
|
|
180
|
+
case 'discovered':
|
|
181
|
+
default:
|
|
182
|
+
if (record.id === 'piper') return { voices: getPiperVoices(voiceDir, currentVoice), providerName };
|
|
183
|
+
if (record.id === 'macos') return { voices: getMacOSVoices(currentVoice), providerName };
|
|
184
|
+
// A discovered provider without a discovery path on this platform: label
|
|
185
|
+
// it honestly instead of rendering an empty list under a wrong provider.
|
|
186
|
+
return { voices: [], providerName: `${provider} (no voice list available)` };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Main function
|
|
192
|
+
*/
|
|
193
|
+
function main() {
|
|
194
|
+
const args = process.argv.slice(2);
|
|
195
|
+
|
|
196
|
+
// Parse arguments
|
|
197
|
+
const provider = args[0] || 'piper';
|
|
198
|
+
const currentVoice = args[1] || '';
|
|
199
|
+
const voiceDir = args[2] || '';
|
|
200
|
+
|
|
201
|
+
const { voices, providerName } = selectVoices(provider, currentVoice, voiceDir);
|
|
202
|
+
|
|
203
|
+
// Display with boxen
|
|
204
|
+
const output = formatVoicesList(voices, {
|
|
205
|
+
provider: providerName,
|
|
206
|
+
columns: 2,
|
|
207
|
+
showUsage: true
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
console.log(output);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Only run when invoked as a CLI (keeps selectVoices importable by tests).
|
|
214
|
+
const _invokedDirectly = process.argv[1] &&
|
|
215
|
+
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
216
|
+
if (_invokedDirectly) main();
|
|
217
|
+
|
|
218
|
+
export { selectVoices, LISTABLE_DISCOVERED };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentVibes — Shared "bling" readiness cue.
|
|
3
|
+
*
|
|
4
|
+
* A short, fire-and-forget chime played the instant a preview is committed, so
|
|
5
|
+
* silence during synthesis / an SSH round-trip never reads as a hang. Used by
|
|
6
|
+
* BOTH the voice picker (setup-tab.js) and the music-preview surfaces
|
|
7
|
+
* (music-tab.js, track-picker.js) so they can't drift.
|
|
8
|
+
*
|
|
9
|
+
* `buildBlingCommand` was originally defined in setup-tab.js; it was moved here
|
|
10
|
+
* and is re-exported from setup-tab.js for backward compatibility (existing
|
|
11
|
+
* imports and tests keep working).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the fire-and-forget "preview ready" cue command for a platform. Pure
|
|
20
|
+
* (no spawning here, so it is unit-testable). Plays the bundled CC0 wav when
|
|
21
|
+
* present, else falls back to a system sound (Windows) / freedesktop cue or
|
|
22
|
+
* terminal bell (POSIX). stdio is ignored by the caller, so the POSIX bell is
|
|
23
|
+
* redirected to /dev/tty rather than the discarded stdout.
|
|
24
|
+
* @param {string} platform - process.platform value
|
|
25
|
+
* @param {string} wavPath - absolute path to the bling wav
|
|
26
|
+
* @param {boolean} haveWav - whether wavPath exists on disk
|
|
27
|
+
* @returns {{command: string, args: string[]}}
|
|
28
|
+
*/
|
|
29
|
+
export function buildBlingCommand(platform, wavPath, haveWav) {
|
|
30
|
+
if (platform === 'win32') {
|
|
31
|
+
const ps = haveWav
|
|
32
|
+
? `Add-Type -AssemblyName System.Windows.Forms; (New-Object System.Media.SoundPlayer('${wavPath.replace(/'/g, "''")}')).PlaySync()`
|
|
33
|
+
: '[System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 700';
|
|
34
|
+
return { command: 'powershell', args: ['-NoProfile', '-Command', ps] };
|
|
35
|
+
}
|
|
36
|
+
if (haveWav) {
|
|
37
|
+
// Pass wavPath as a positional arg ($1) so the path is never interpolated
|
|
38
|
+
// into the shell string (prevents injection / breakage on special chars).
|
|
39
|
+
const sh = 'paplay "$1" 2>/dev/null || aplay -q "$1" 2>/dev/null || printf "\\a" > /dev/tty 2>/dev/null';
|
|
40
|
+
return { command: 'bash', args: ['-c', sh, '--', wavPath] };
|
|
41
|
+
}
|
|
42
|
+
const sh = 'paplay /usr/share/sounds/freedesktop/stereo/message.oga 2>/dev/null || printf "\\a" > /dev/tty 2>/dev/null';
|
|
43
|
+
return { command: 'bash', args: ['-c', sh] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the bundled CC0 bling wav for a package root.
|
|
48
|
+
* Sound: "Ui sounds - Shimmering success" by Philip_Berger, CC0 (freesound
|
|
49
|
+
* #648212). See .claude/audio/ui/CREDITS.txt.
|
|
50
|
+
* @param {string} packageRoot - AgentVibes package/repo root
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function resolveBlingWav(packageRoot) {
|
|
54
|
+
return path.join(packageRoot, '.claude', 'audio', 'ui', 'bling-success.wav');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Play the readiness cue, fire-and-forget. Detached + unref'd + errors
|
|
59
|
+
* swallowed, so it never blocks the UI or fails a preview. A missing wav falls
|
|
60
|
+
* back to a platform system sound via buildBlingCommand.
|
|
61
|
+
* @param {string} packageRoot - AgentVibes package/repo root
|
|
62
|
+
*/
|
|
63
|
+
export function playBlingCue(packageRoot) {
|
|
64
|
+
try {
|
|
65
|
+
const wav = resolveBlingWav(packageRoot);
|
|
66
|
+
const { command, args } = buildBlingCommand(process.platform, wav, fs.existsSync(wav));
|
|
67
|
+
const cue = spawn(command, args, { stdio: 'ignore', detached: true }); // NOSONAR
|
|
68
|
+
cue.on('error', () => { /* best-effort cue; a spawn failure must not surface */ });
|
|
69
|
+
cue.unref();
|
|
70
|
+
} catch { /* the readiness cue is purely cosmetic — never break the preview */ }
|
|
71
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentVibes — Shared music-preview transport helpers.
|
|
3
|
+
*
|
|
4
|
+
* Both music-preview surfaces (the Music tab and the track-picker widget) must
|
|
5
|
+
* make the same remote-vs-local decision: a transport provider routes audio to
|
|
6
|
+
* a remote receiver, so a local MP3 preview would be silent on a headless box
|
|
7
|
+
* and must be forwarded instead. This module holds the provider-resolve and the
|
|
8
|
+
* remote-forward spawn so the two surfaces can't drift.
|
|
9
|
+
*
|
|
10
|
+
* The caller owns UI feedback (status text, error messages) and attaches its own
|
|
11
|
+
* exit/error/stderr handlers to the returned child process.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
// Transport providers route audio to a remote receiver; local MP3 playback is
|
|
20
|
+
// silent on a headless/remote box, so track previews must be forwarded instead.
|
|
21
|
+
export const REMOTE_PROVIDERS = ['ssh-remote', 'agentvibes-receiver'];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the active provider and the project dir it was read from, using the
|
|
25
|
+
* same search order as the voice pickers (CLAUDE_PROJECT_DIR → cwd → package →
|
|
26
|
+
* home). Returns { remote, projectDir } so a remote preview can forward the
|
|
27
|
+
* track to the receiver and tell the sender which .claude dir to resolve.
|
|
28
|
+
* @param {string} packageRoot - AgentVibes package/repo root (package fallback)
|
|
29
|
+
* @returns {{ remote: boolean, projectDir: string }}
|
|
30
|
+
*/
|
|
31
|
+
export function resolveMusicProvider(packageRoot) {
|
|
32
|
+
const dirs = [process.env.CLAUDE_PROJECT_DIR, process.cwd(), packageRoot, os.homedir()].filter(Boolean);
|
|
33
|
+
for (const d of dirs) {
|
|
34
|
+
const p = path.join(d, '.claude', 'tts-provider.txt');
|
|
35
|
+
try {
|
|
36
|
+
if (fs.existsSync(p)) {
|
|
37
|
+
const provider = fs.readFileSync(p, 'utf8').trim();
|
|
38
|
+
return { remote: REMOTE_PROVIDERS.includes(provider), projectDir: d };
|
|
39
|
+
}
|
|
40
|
+
} catch { /* next */ }
|
|
41
|
+
}
|
|
42
|
+
return { remote: false, projectDir: '' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the SSH sender script path, preferring the package-bundled copy and
|
|
47
|
+
* falling back to the project's .claude/hooks copy.
|
|
48
|
+
* @param {string} packageRoot
|
|
49
|
+
* @param {string} projectDir
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function resolveRemoteSender(packageRoot, projectDir) {
|
|
53
|
+
const pkgSender = path.resolve(packageRoot, '.claude', 'hooks', 'play-tts-ssh-remote.sh');
|
|
54
|
+
return fs.existsSync(pkgSender)
|
|
55
|
+
? pkgSender
|
|
56
|
+
: path.join(projectDir, '.claude', 'hooks', 'play-tts-ssh-remote.sh');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Spawn the SSH sender to forward a music track to the receiver, or stop the
|
|
61
|
+
* current one. Fire-and-forget (the sender exits after handing the payload to
|
|
62
|
+
* SSH; the receiver plays/stops asynchronously). Returns the child process so
|
|
63
|
+
* the caller can wire its own exit/error/stderr handlers for UI feedback.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} opts
|
|
66
|
+
* @param {string} opts.packageRoot
|
|
67
|
+
* @param {string} opts.projectDir
|
|
68
|
+
* @param {object} opts.env - base spawn env (e.g. buildAudioEnv())
|
|
69
|
+
* @param {string} [opts.track] - track filename to play (ignored when stop)
|
|
70
|
+
* @param {boolean} [opts.stop] - send an explicit music-stop signal instead
|
|
71
|
+
* @returns {import('node:child_process').ChildProcess}
|
|
72
|
+
*/
|
|
73
|
+
export function spawnMusicRemote({ packageRoot, projectDir, env, track = null, stop = false }) {
|
|
74
|
+
const senderPath = resolveRemoteSender(packageRoot, projectDir);
|
|
75
|
+
const spawnEnv = { ...env, CLAUDE_PROJECT_DIR: projectDir };
|
|
76
|
+
if (stop) spawnEnv.AGENTVIBES_MUSIC_STOP = '1';
|
|
77
|
+
else spawnEnv.AGENTVIBES_MUSIC_ONLY = track;
|
|
78
|
+
return spawn('bash', [senderPath, '', ''], { stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: spawnEnv }); // NOSONAR
|
|
79
|
+
}
|
|
@@ -11,43 +11,20 @@
|
|
|
11
11
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
-
import os from 'node:os';
|
|
15
|
-
import { spawn } from 'node:child_process';
|
|
16
14
|
import { fileURLToPath } from 'node:url';
|
|
17
15
|
import { buildAudioEnv, spawnMp3Player } from '../audio-env.js';
|
|
16
|
+
import { resolveMusicProvider, spawnMusicRemote } from '../music-preview.js';
|
|
17
|
+
import { playBlingCue } from '../bling.js';
|
|
18
18
|
import { t } from '../../i18n/strings.js';
|
|
19
19
|
|
|
20
20
|
const _MUSIC_TAB_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
21
21
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
// Transport providers route audio to a remote receiver; local MP3 playback is
|
|
28
|
-
// silent on a headless/remote box, so track previews must be forwarded instead.
|
|
29
|
-
const _REMOTE_PROVIDERS = ['ssh-remote', 'agentvibes-receiver'];
|
|
22
|
+
// AgentVibes package/repo root — used to resolve bundled assets (tracks dir,
|
|
23
|
+
// bling cue) and as the package fallback when resolving the active provider.
|
|
24
|
+
const _PKG_ROOT = path.resolve(_MUSIC_TAB_DIR, '..', '..', '..');
|
|
30
25
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
* same search order as the voice pickers (CLAUDE_PROJECT_DIR → cwd → package →
|
|
34
|
-
* home). Returns { remote, projectDir } so a remote preview can forward the
|
|
35
|
-
* track to the receiver and tell the sender which .claude dir to resolve.
|
|
36
|
-
*/
|
|
37
|
-
function _resolveMusicProvider() {
|
|
38
|
-
const projectRoot = path.resolve(_MUSIC_TAB_DIR, '..', '..', '..');
|
|
39
|
-
const dirs = [process.env.CLAUDE_PROJECT_DIR, process.cwd(), projectRoot, os.homedir()].filter(Boolean);
|
|
40
|
-
for (const d of dirs) {
|
|
41
|
-
const p = path.join(d, '.claude', 'tts-provider.txt');
|
|
42
|
-
try {
|
|
43
|
-
if (fs.existsSync(p)) {
|
|
44
|
-
const provider = fs.readFileSync(p, 'utf8').trim();
|
|
45
|
-
return { remote: _REMOTE_PROVIDERS.includes(provider), projectDir: d };
|
|
46
|
-
}
|
|
47
|
-
} catch { /* next */ }
|
|
48
|
-
}
|
|
49
|
-
return { remote: false, projectDir: '' };
|
|
50
|
-
}
|
|
26
|
+
// Package-relative tracks dir — used as fallback when cwd has no .claude/audio/tracks/
|
|
27
|
+
const _PKG_TRACKS_DIR = path.join(_PKG_ROOT, '.claude', 'audio', 'tracks');
|
|
51
28
|
|
|
52
29
|
const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
|
|
53
30
|
|
|
@@ -591,16 +568,9 @@ export function createMusicTab(screen, services) {
|
|
|
591
568
|
* @param {{track?:string, stop?:boolean}} opts
|
|
592
569
|
*/
|
|
593
570
|
function _sendMusicRemote(mp, { track = null, stop = false } = {}) {
|
|
594
|
-
const _pkgSender = path.resolve(_MUSIC_TAB_DIR, '..', '..', '..', '.claude', 'hooks', 'play-tts-ssh-remote.sh');
|
|
595
|
-
const senderPath = fs.existsSync(_pkgSender)
|
|
596
|
-
? _pkgSender
|
|
597
|
-
: path.join(mp.projectDir, '.claude', 'hooks', 'play-tts-ssh-remote.sh');
|
|
598
|
-
const env = { ..._spawnEnv, CLAUDE_PROJECT_DIR: mp.projectDir };
|
|
599
|
-
if (stop) env.AGENTVIBES_MUSIC_STOP = '1';
|
|
600
|
-
else env.AGENTVIBES_MUSIC_ONLY = track;
|
|
601
571
|
let rproc;
|
|
602
572
|
try {
|
|
603
|
-
rproc =
|
|
573
|
+
rproc = spawnMusicRemote({ packageRoot: _PKG_ROOT, projectDir: mp.projectDir, env: _spawnEnv, track, stop });
|
|
604
574
|
} catch {
|
|
605
575
|
previewLine.setContent('{red-fg}Remote music preview failed{/red-fg}');
|
|
606
576
|
screen.render();
|
|
@@ -646,7 +616,7 @@ export function createMusicTab(screen, services) {
|
|
|
646
616
|
// is silent on a headless box). The receiver auto-stops any prior track when
|
|
647
617
|
// a new one arrives; pressing Space on the currently-playing track sends an
|
|
648
618
|
// explicit stop (toggle off).
|
|
649
|
-
const _mp =
|
|
619
|
+
const _mp = resolveMusicProvider(_PKG_ROOT);
|
|
650
620
|
if (_mp.remote) {
|
|
651
621
|
if (_remotePlayingTrackId === trackId) {
|
|
652
622
|
_sendMusicRemote(_mp, { stop: true });
|
|
@@ -655,6 +625,9 @@ export function createMusicTab(screen, services) {
|
|
|
655
625
|
screen.render();
|
|
656
626
|
return;
|
|
657
627
|
}
|
|
628
|
+
// Bling first (fire-and-forget, plays locally) — same readiness cue as the
|
|
629
|
+
// voice preview — then forward the track to the receiver.
|
|
630
|
+
playBlingCue(_PKG_ROOT);
|
|
658
631
|
const rlabel = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
|
|
659
632
|
if (_sendMusicRemote(_mp, { track: trackId })) {
|
|
660
633
|
_remotePlayingTrackId = trackId;
|
|
@@ -678,6 +651,10 @@ export function createMusicTab(screen, services) {
|
|
|
678
651
|
_killPlayingProcess();
|
|
679
652
|
_playingTrackId = null;
|
|
680
653
|
|
|
654
|
+
// Bling first (fire-and-forget) — same readiness cue as the voice preview —
|
|
655
|
+
// then start local playback.
|
|
656
|
+
playBlingCue(_PKG_ROOT);
|
|
657
|
+
|
|
681
658
|
const proc = spawnMp3Player(trackPath, _spawnEnv);
|
|
682
659
|
if (!proc) {
|
|
683
660
|
const installHint = process.platform === 'win32'
|