agentvibes 4.5.7 → 4.6.2

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.
@@ -31,6 +31,84 @@ import { spawn } from 'node:child_process';
31
31
  // Max pretext length to prevent excessively long TTS utterances
32
32
  const MAX_PRETEXT_LENGTH = 200;
33
33
 
34
+ /**
35
+ * Attach a blinking █ cursor to a set of blessed buttons.
36
+ * Works alongside existing focus/blur handlers (e.g. ►..◄ indicators).
37
+ * While a spinner is active on a button, blink is paused for that button.
38
+ * Returns { cleanup, startSpinner(btn, screen), stopSpinner(btn, screen) }.
39
+ */
40
+ export function attachBtnBlink(btns, screen) {
41
+ let _interval = null;
42
+ let _on = true;
43
+ let _active = null;
44
+ let _spinning = null; // button currently showing a spinner
45
+
46
+ const _SPIN = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
47
+ let _spinIdx = 0;
48
+ let _spinInterval = null;
49
+
50
+ // Store original label on each button at attach time — never derive from current content
51
+ btns.forEach(btn => { btn._blinkBase = btn.content; });
52
+
53
+ // Focused with indicator ch right after ► e.g. ►█Preview◄ / ► Preview◄ (same width)
54
+ function _focused(base, ch) { return `►${ch}${base}◄`; }
55
+
56
+ function _tick() {
57
+ if (!_active || _active === _spinning) return;
58
+ _on = !_on;
59
+ _active.setContent(_focused(_active._blinkBase, _on ? '█' : ' '));
60
+ screen.render();
61
+ }
62
+
63
+ btns.forEach(btn => {
64
+ btn.on('focus', () => {
65
+ _active = btn;
66
+ _on = true;
67
+ if (btn !== _spinning) {
68
+ btn.setContent(_focused(btn._blinkBase, '█'));
69
+ screen.render();
70
+ }
71
+ if (!_interval) _interval = setInterval(_tick, 500);
72
+ });
73
+ btn.on('blur', () => {
74
+ if (_active !== btn) return;
75
+ _active = null;
76
+ if (_interval) { clearInterval(_interval); _interval = null; _on = true; }
77
+ if (btn !== _spinning) {
78
+ btn.setContent(btn._blinkBase);
79
+ screen.render();
80
+ }
81
+ });
82
+ });
83
+
84
+ function startSpinner(btn) {
85
+ _spinning = btn;
86
+ _spinIdx = 0;
87
+ if (_spinInterval) clearInterval(_spinInterval);
88
+ _spinInterval = setInterval(() => {
89
+ _spinIdx = (_spinIdx + 1) % _SPIN.length;
90
+ btn.setContent(_active === btn
91
+ ? _focused(btn._blinkBase, _SPIN[_spinIdx])
92
+ : `${_SPIN[_spinIdx]}${btn._blinkBase}`);
93
+ screen.render();
94
+ }, 80);
95
+ }
96
+
97
+ function stopSpinner(btn) {
98
+ if (_spinInterval) { clearInterval(_spinInterval); _spinInterval = null; }
99
+ _spinning = null;
100
+ btn.setContent(_active === btn ? _focused(btn._blinkBase, '█') : btn._blinkBase);
101
+ screen.render();
102
+ }
103
+
104
+ function cleanup() {
105
+ if (_interval) { clearInterval(_interval); _interval = null; }
106
+ if (_spinInterval){ clearInterval(_spinInterval); _spinInterval = null; }
107
+ }
108
+
109
+ return { cleanup, startSpinner, stopSpinner };
110
+ }
111
+
34
112
  const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
35
113
 
36
114
  let blessed;
@@ -547,7 +625,7 @@ ${_tl('bmadDesc')}
547
625
  top: 'center',
548
626
  left: 'center',
549
627
  width: 72,
550
- height: 18,
628
+ height: 19,
551
629
  border: { type: 'line' },
552
630
  tags: true,
553
631
  label: _modalTitle(`${agent.icon || '🧙'} ${agent.displayName} (${agent.title || 'Agent'})`),
@@ -569,9 +647,13 @@ ${_tl('bmadDesc')}
569
647
  const emoji = PERSONALITY_EMOJIS[p] || '';
570
648
  return `${emoji} ${p === 'none' ? 'None' : p.charAt(0).toUpperCase() + p.slice(1)}`;
571
649
  }},
572
- { key: 'music', label: 'Music', getValue: () => {
650
+ { key: 'musicTrack', label: 'Music Track', getValue: () => {
651
+ if (!draft.backgroundMusic.enabled) return '(disabled)';
652
+ return formatTrackName(draft.backgroundMusic.track) || '(none)';
653
+ }},
654
+ { key: 'musicVol', label: 'Music Vol', getValue: () => {
573
655
  if (!draft.backgroundMusic.enabled) return '(disabled)';
574
- return `${formatTrackName(draft.backgroundMusic.track)} Vol:${draft.backgroundMusic.volume}%`;
656
+ return `${draft.backgroundMusic.volume ?? 20}%`;
575
657
  }},
576
658
  ];
577
659
 
@@ -612,7 +694,7 @@ ${_tl('bmadDesc')}
612
694
  left: 2,
613
695
  right: 2,
614
696
  tags: true,
615
- content: '{#455a64-fg}[↑↓] Navigate fields [Enter] Edit field [Space] Sample [Esc] Cancel{/#455a64-fg}',
697
+ content: '{white-fg}[↑↓] Navigate [Enter] Edit [Tab] → Preview/Save [Esc] Cancel{/white-fg}',
616
698
  style: { bg: COLORS.contentBg },
617
699
  });
618
700
 
@@ -634,22 +716,20 @@ ${_tl('bmadDesc')}
634
716
  hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
635
717
  },
636
718
  });
637
- btn.on('focus', () => {
638
- const raw = btn.content.replace(/[►◄]/g, '').trim();
639
- btn.setContent(`►${raw}◄`);
640
- screen.render();
641
- });
642
- btn.on('blur', () => {
643
- const raw = btn.content.replace(/[►◄]/g, '').trim();
644
- btn.setContent(raw);
645
- screen.render();
646
- });
719
+ // Focus indicator handled by attachBtnBlink
647
720
  btn.key(['enter', 'space'], () => onClick());
648
721
  btn.on('click', () => onClick());
649
722
  return btn;
650
723
  }
651
724
 
652
- const saveBtn = _modalBtn('Save', 4, () => {
725
+ const previewBtn = _modalBtn('Preview', 4, () => {
726
+ _sampleAgentWithDraft({ ...agent }, draft, () => {
727
+ btnBlink.stopSpinner(previewBtn);
728
+ });
729
+ btnBlink.startSpinner(previewBtn);
730
+ });
731
+
732
+ const saveBtn = _modalBtn('Save', 18, () => {
653
733
  // Only save fields that differ from global
654
734
  const toSave = {};
655
735
  if (draft.voice && draft.voice !== globalCfg.voice) toSave.voice = draft.voice;
@@ -668,20 +748,26 @@ ${_tl('bmadDesc')}
668
748
  _showSavedToast(agent.displayName);
669
749
  });
670
750
 
671
- const resetAllBtn = _modalBtn('Reset to Defaults', 14, () => {
751
+ const resetAllBtn = _modalBtn('Reset to Defaults', 26, () => {
672
752
  voiceStore.resetAgentProfile(agent.id);
673
753
  _closeModal();
674
754
  refreshDisplay();
675
755
  });
676
756
 
677
- const cancelBtn = _modalBtn('Cancel', 38, _closeModal);
757
+ const cancelBtn = _modalBtn('Cancel', 50, _closeModal);
758
+
759
+ // Blinking █ cursor + preview spinner — reusable across all modal buttons
760
+ const btnBlink = attachBtnBlink([previewBtn, saveBtn, resetAllBtn, cancelBtn], screen);
678
761
 
679
762
  function _closeModal() {
680
763
  if (_closed) return;
681
764
  _closed = true;
682
765
  _killPreview();
766
+ btnBlink.cleanup();
683
767
  navigationService?.closeModal();
684
768
  destroyList(modal, screen);
769
+ agentList.focus();
770
+ screen.render();
685
771
  }
686
772
 
687
773
  // Field editing via Enter
@@ -735,10 +821,9 @@ ${_tl('bmadDesc')}
735
821
  });
736
822
  break;
737
823
 
738
- case 'music':
739
- openTrackPicker(screen, draft.backgroundMusic.track, draft.backgroundMusic.volume, (track, volume) => {
824
+ case 'musicTrack':
825
+ openTrackPicker(screen, draft.backgroundMusic.track, draft.backgroundMusic.volume, (track) => {
740
826
  draft.backgroundMusic.track = track;
741
- draft.backgroundMusic.volume = volume;
742
827
  draft.backgroundMusic.enabled = true;
743
828
  fieldList.setItems(_fieldItems());
744
829
  fieldList.select(idx);
@@ -747,6 +832,20 @@ ${_tl('bmadDesc')}
747
832
  }, () => {
748
833
  fieldList.focus();
749
834
  screen.render();
835
+ }, { skipVolume: true });
836
+ break;
837
+
838
+ case 'musicVol':
839
+ openVolumeInput(screen, draft.backgroundMusic.volume, (volume) => {
840
+ draft.backgroundMusic.volume = volume;
841
+ if (draft.backgroundMusic.track) draft.backgroundMusic.enabled = true;
842
+ fieldList.setItems(_fieldItems());
843
+ fieldList.select(idx);
844
+ fieldList.focus();
845
+ screen.render();
846
+ }, () => {
847
+ fieldList.focus();
848
+ screen.render();
750
849
  });
751
850
  break;
752
851
  }
@@ -761,15 +860,50 @@ ${_tl('bmadDesc')}
761
860
 
762
861
  // Escape = close
763
862
  fieldList.key(['escape', 'q'], _closeModal);
863
+ previewBtn.key(['escape'], _closeModal);
764
864
  saveBtn.key(['escape'], _closeModal);
765
865
  resetAllBtn.key(['escape'], _closeModal);
766
866
  cancelBtn.key(['escape'], _closeModal);
767
867
 
768
- // Tab navigation within modal
769
- fieldList.key(['tab'], () => { saveBtn.focus(); screen.render(); });
770
- saveBtn.key(['tab'], () => { resetAllBtn.focus(); screen.render(); });
771
- resetAllBtn.key(['tab'], () => { cancelBtn.focus(); screen.render(); });
772
- cancelBtn.key(['tab'], () => { fieldList.focus(); screen.render(); });
868
+ // Tab + arrow navigation within modal
869
+ fieldList.key(['tab'], () => { previewBtn.focus(); screen.render(); });
870
+
871
+ // Wrap: down on last field → focus first button; up on first field → focus first button
872
+ // One extra arrow press at boundary moves to button row.
873
+ // Track previous selection so arriving at boundary doesn't immediately jump.
874
+ let _prevFieldSel = 0;
875
+ fieldList.key(['down'], () => {
876
+ const cur = fieldList.selected ?? 0;
877
+ if (cur === FIELDS.length - 1 && _prevFieldSel === FIELDS.length - 1) {
878
+ previewBtn.focus(); screen.render();
879
+ }
880
+ _prevFieldSel = cur;
881
+ });
882
+ fieldList.key(['up'], () => {
883
+ const cur = fieldList.selected ?? 0;
884
+ if (cur === 0 && _prevFieldSel === 0) {
885
+ previewBtn.focus(); screen.render();
886
+ }
887
+ _prevFieldSel = cur;
888
+ });
889
+
890
+ // Wrap: up on buttons → back to field list (last/first field respectively)
891
+ previewBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
892
+ saveBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
893
+ resetAllBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
894
+ cancelBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
895
+
896
+ previewBtn.key(['tab', 'right'], () => { saveBtn.focus(); screen.render(); });
897
+ previewBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
898
+
899
+ saveBtn.key(['tab', 'right'], () => { resetAllBtn.focus(); screen.render(); });
900
+ saveBtn.key(['left'], () => { previewBtn.focus(); screen.render(); });
901
+
902
+ resetAllBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
903
+ resetAllBtn.key(['left'], () => { saveBtn.focus(); screen.render(); });
904
+
905
+ cancelBtn.key(['tab', 'right'], () => { fieldList.focus(); screen.render(); });
906
+ cancelBtn.key(['left'], () => { resetAllBtn.focus(); screen.render(); });
773
907
 
774
908
  fieldList.focus();
775
909
  screen.render();
@@ -1047,8 +1181,8 @@ ${_tl('bmadDesc')}
1047
1181
  // -------------------------------------------------------------------------
1048
1182
  // Sample agent with a draft profile (no save) — same full pipeline
1049
1183
 
1050
- function _sampleAgentWithDraft(agent, draft) {
1051
- _sampleWithFullProfile(agent, draft);
1184
+ function _sampleAgentWithDraft(agent, draft, onComplete) {
1185
+ _sampleWithFullProfile(agent, draft, onComplete);
1052
1186
  }
1053
1187
 
1054
1188
  // -------------------------------------------------------------------------
@@ -1056,7 +1190,7 @@ ${_tl('bmadDesc')}
1056
1190
  // Writes a temp agent profile JSON, then calls the enhanced TTS pipeline
1057
1191
  // which applies voice + reverb + background music.
1058
1192
 
1059
- function _sampleWithFullProfile(agent, profile) {
1193
+ function _sampleWithFullProfile(agent, profile, onComplete) {
1060
1194
  _killPreview();
1061
1195
  const gen = ++_playGeneration;
1062
1196
 
@@ -1071,9 +1205,9 @@ ${_tl('bmadDesc')}
1071
1205
  const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1072
1206
 
1073
1207
  if (isWindows) {
1074
- // On Windows, synthesize with piper.exe directly then play the wav,
1075
- // avoiding bash/wsl.exe which opens a visible console window.
1076
- _sampleWithPiperDirect(gen, voiceId, phrase);
1208
+ // On Windows, call play-tts.ps1 via PowerShell with draft settings patched
1209
+ // in so reverb, background music, and personality are applied.
1210
+ _sampleWithFullEffectsWindows(gen, agent, profile, phrase, onComplete);
1077
1211
  } else {
1078
1212
  // On Linux/macOS/WSL, use play-tts.sh
1079
1213
  const _spawnEnv = buildAudioEnv();
@@ -1178,6 +1312,77 @@ ${_tl('bmadDesc')}
1178
1312
  });
1179
1313
  }
1180
1314
 
1315
+ /** Windows full-effects preview: temporarily patches config files then calls play-tts.ps1 */
1316
+ function _sampleWithFullEffectsWindows(gen, agent, profile, phrase, onComplete) {
1317
+ const _spawnEnv = buildAudioEnv();
1318
+ const homeDir = process.env.USERPROFILE || os.homedir();
1319
+ // Use project-local .claude if it exists, else global ~/.claude
1320
+ const claudeDir = fs.existsSync(path.join(_projectRoot, '.claude'))
1321
+ ? path.join(_projectRoot, '.claude')
1322
+ : path.join(homeDir, '.claude');
1323
+ const configDir = path.join(claudeDir, 'config');
1324
+ const hooksDir = path.join(claudeDir, 'hooks-windows');
1325
+ const playTts = path.join(hooksDir, 'play-tts.ps1');
1326
+ if (!fs.existsSync(playTts)) { _sampleWithPiperDirect(gen, profile.voice || '', phrase); return; }
1327
+
1328
+ // Files to temporarily patch
1329
+ const personalityFile = path.join(configDir, 'personality.txt');
1330
+ const reverbFile = path.join(configDir, 'reverb-level.txt');
1331
+ const bgEnabledFile = path.join(configDir, 'background-music-enabled.txt');
1332
+ const audioEffectsCfg = path.join(configDir, 'audio-effects.cfg');
1333
+
1334
+ // Save originals
1335
+ const _read = f => { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } };
1336
+ const origPersonality = _read(personalityFile);
1337
+ const origReverb = _read(reverbFile);
1338
+ const origBgEnabled = _read(bgEnabledFile);
1339
+ const origAudioEffects = _read(audioEffectsCfg);
1340
+
1341
+ const bgMusic = profile.backgroundMusic;
1342
+ let tempCfgLine = '';
1343
+
1344
+ try {
1345
+ if (profile.personality && profile.personality !== 'none')
1346
+ fs.writeFileSync(personalityFile, profile.personality);
1347
+ if (profile.reverbPreset)
1348
+ fs.writeFileSync(reverbFile, profile.reverbPreset);
1349
+ if (bgMusic?.enabled && bgMusic?.track) {
1350
+ fs.writeFileSync(bgEnabledFile, 'true');
1351
+ const vol = ((bgMusic.volume ?? 20) / 100).toFixed(2);
1352
+ tempCfgLine = `${agent.id}||${bgMusic.track}|${vol}`;
1353
+ fs.writeFileSync(audioEffectsCfg, `${tempCfgLine}\n${origAudioEffects || ''}`);
1354
+ }
1355
+ } catch { /* degrade gracefully */ }
1356
+
1357
+ const voiceId = profile.voice || '';
1358
+ const psArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTts, phrase];
1359
+ if (voiceId) psArgs.push(voiceId);
1360
+
1361
+ const proc = spawn('powershell', psArgs, {
1362
+ stdio: 'ignore', detached: false, windowsHide: true,
1363
+ // CLAUDE_PROJECT_DIR tells play-tts.ps1 to use the project's .claude/config
1364
+ // rather than falling back to ~/.claude where our patches don't exist.
1365
+ env: { ..._spawnEnv, AGENTVIBES_AGENT_NAME: agent.id, CLAUDE_PROJECT_DIR: _projectRoot },
1366
+ });
1367
+ _playingProcess = proc;
1368
+
1369
+ function _restore() {
1370
+ try {
1371
+ if (origPersonality !== null) fs.writeFileSync(personalityFile, origPersonality);
1372
+ else try { fs.unlinkSync(personalityFile); } catch {}
1373
+ if (origReverb !== null) fs.writeFileSync(reverbFile, origReverb);
1374
+ if (bgMusic?.enabled && bgMusic?.track) {
1375
+ if (origBgEnabled !== null) fs.writeFileSync(bgEnabledFile, origBgEnabled);
1376
+ else try { fs.unlinkSync(bgEnabledFile); } catch {}
1377
+ if (origAudioEffects !== null) fs.writeFileSync(audioEffectsCfg, origAudioEffects);
1378
+ }
1379
+ } catch {}
1380
+ }
1381
+
1382
+ proc.on('exit', () => { if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); } _restore(); if (onComplete) onComplete(); });
1383
+ proc.on('error', () => { if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); } _restore(); if (onComplete) onComplete(); });
1384
+ }
1385
+
1181
1386
  // -------------------------------------------------------------------------
1182
1387
  // Auto-assign helpers
1183
1388
 
@@ -1199,9 +1404,10 @@ ${_tl('bmadDesc')}
1199
1404
  grace: 'Female', heather: 'Female', ivy: 'Female', jane: 'Female',
1200
1405
  jenny: 'Female', julia: 'Female', kate: 'Female', laura: 'Female',
1201
1406
  lily: 'Female', maria: 'Female', mary: 'Female', nina: 'Female',
1202
- olivia: 'Female', paige: 'Female', rachel: 'Female', sally: 'Female',
1203
- sara: 'Female', sarah: 'Female', sophie: 'Female', tina: 'Female',
1204
- wendy: 'Female', zoe: 'Female',
1407
+ olivia: 'Female', paige: 'Female', quinn: 'Female', rachel: 'Female',
1408
+ sally: 'Female', sara: 'Female', sarah: 'Female', sophie: 'Female',
1409
+ tina: 'Female', wendy: 'Female', zoe: 'Female',
1410
+ freya: 'Female', saga: 'Female',
1205
1411
  // Male
1206
1412
  alan: 'Male', barry: 'Male', bob: 'Male', carl: 'Male',
1207
1413
  charlie: 'Male', dan: 'Male', david: 'Male', eric: 'Male',
@@ -974,6 +974,7 @@ export function createInstallTab(screen, services) {
974
974
  screen.key(['left'], () => {
975
975
  if (box.hidden || _checking) return;
976
976
  if (_screen === 4) return;
977
+ if (_screen === 5) return; // Screen 5: ← handled by button nav
977
978
  if (_screen > 0) {
978
979
  _screen--;
979
980
  _showCurrentScreen();
@@ -190,12 +190,13 @@ export const COL_GENDER_W = 10;
190
190
 
191
191
  // Well-known piper dataset → gender
192
192
  const GENDER_MAP = {
193
+ // Single-speaker datasets
193
194
  amy: 'Female', kristin: 'Female', jenny: 'Female', cori: 'Female',
194
195
  aria: 'Female', glados: 'Female', litvyak: 'Female', hfc_female: 'Female',
195
196
  ljspeech: 'Female',
196
197
  alan: 'Male', joe: 'Male', john: 'Male', ryan: 'Male', lessac: 'Male',
197
198
  kusal: 'Male', hfc_male: 'Male', danny: 'Male', arctic: 'Male',
198
- l2arctic: 'Male', libritts: 'Male', libritts_r: 'Male',
199
+ l2arctic: 'Male',
199
200
  // 16Speakers multi-speaker model (names from speaker_id_map)
200
201
  cori_samuel: 'Female', kara_shallenberg: 'Female', kristin_hughes: 'Female',
201
202
  maria_kasper: 'Female', rose_ibex: 'Female', owlivia: 'Female',
@@ -203,6 +204,21 @@ const GENDER_MAP = {
203
204
  mike_pelton: 'Male', mark_nelson: 'Male', michael_scherer: 'Male',
204
205
  james_k_white: 'Male', progressingamerica: 'Male', steve_c: 'Male',
205
206
  paul_hampton: 'Male', martin_clifton: 'Male',
207
+ // LibriTTS / common first names used as multi-speaker speaker IDs
208
+ anna: 'Female', bella: 'Female', chloe: 'Female', donna: 'Female',
209
+ ella: 'Female', faith: 'Female', gina: 'Female', holly: 'Female',
210
+ ivy: 'Female', jane: 'Female', kelly: 'Female', laura: 'Female',
211
+ mary: 'Female', nina: 'Female', olivia: 'Female', penny: 'Female',
212
+ rachel: 'Female', sarah: 'Female', tara: 'Female', uma: 'Female',
213
+ vera: 'Female', wendy: 'Female', yara: 'Female', zoe: 'Female',
214
+ betty: 'Female', cindy: 'Female', debra: 'Female', erica: 'Female',
215
+ faye: 'Female', gloria: 'Female', quinn: 'Female',
216
+ alex: 'Male', ben: 'Male', carl: 'Male', dan: 'Male', evan: 'Male',
217
+ frank: 'Male', greg: 'Male', hank: 'Male', ivan: 'Male', jake: 'Male',
218
+ kevin: 'Male', leo: 'Male', mike: 'Male', nathan: 'Male', oscar: 'Male',
219
+ paul: 'Male', rick: 'Male', sam: 'Male', tom: 'Male', victor: 'Male',
220
+ will: 'Male', xavier: 'Male', zach: 'Male', adam: 'Male', brad: 'Male',
221
+ colin: 'Male', derek: 'Male', ethan: 'Male', felix: 'Male',
206
222
  };
207
223
 
208
224
  // Well-known piper dataset → nice display name
@@ -229,9 +245,15 @@ export function inferGender(voiceId, dataset) {
229
245
  // Explicit in name
230
246
  if (id.includes('_female') || ds.includes('female')) return 'Female';
231
247
  if (id.includes('_male') || ds.includes('male')) return 'Male';
232
- // Lookup by dataset, name segment, or full id (for multi-speaker names)
233
- const key = ds || (id.split('-')[1] ?? '');
234
- return GENDER_MAP[key] ?? GENDER_MAP[id] ?? '—';
248
+ // Dataset lookup first
249
+ if (ds && GENDER_MAP[ds]) return GENDER_MAP[ds];
250
+ // For multi-speaker speaker names like "Anna-9", strip trailing "-N" suffix
251
+ // then look up the base name (e.g. "anna")
252
+ const baseName = id.replace(/-\d+$/, '');
253
+ if (GENDER_MAP[baseName]) return GENDER_MAP[baseName];
254
+ // Fall back to middle segment of voice ID (e.g. "ryan" from "en_US-ryan-high")
255
+ const segment = id.split('-')[1] ?? '';
256
+ return GENDER_MAP[segment] ?? GENDER_MAP[id] ?? '—';
235
257
  }
236
258
 
237
259
  /**
@@ -321,7 +343,18 @@ export function parseMultiSpeaker(voiceId) {
321
343
  const jsonPath = path.join(PIPER_VOICES_DIR, model + '.onnx.json');
322
344
  try {
323
345
  const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
324
- const speakerId = data.speaker_id_map?.[speakerName] ?? null;
346
+ let speakerId = data.speaker_id_map?.[speakerName] ?? null;
347
+ // Fallback: if the .onnx.json still has raw p-names (not yet patched),
348
+ // look up the numeric speaker ID from voice-assignments.json catalog.
349
+ if (speakerId == null && model === 'en_US-libritts-high') {
350
+ try {
351
+ const catalogPath = path.resolve(__dirname, '..', '..', '..', 'voice-assignments.json');
352
+ const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
353
+ const speakers = catalog.libritts_speakers ?? {};
354
+ const entry = Object.entries(speakers).find(([, e]) => e.voice_name === speakerName);
355
+ if (entry) speakerId = parseInt(entry[0], 10);
356
+ } catch { /* non-fatal */ }
357
+ }
325
358
  return { model, speakerId, speakerName, isMultiSpeaker: true };
326
359
  } catch {
327
360
  return { model, speakerId: null, speakerName, isMultiSpeaker: true };
@@ -42,30 +42,48 @@ export function openVolumeInput(screen, currentVol, onConfirm, onClose) {
42
42
  parent: screen,
43
43
  top: 'center',
44
44
  left: 'center',
45
- width: 38,
46
- height: 8,
45
+ width: 44,
46
+ height: 11,
47
47
  border: { type: 'line' },
48
48
  tags: true,
49
49
  label: _modalTitle('Music Volume'),
50
50
  style: { border: { fg: 'bright-cyan' } },
51
51
  });
52
52
 
53
- const barText = blessed.text({
53
+ blessed.text({
54
54
  parent: box,
55
55
  top: 1,
56
56
  left: 2,
57
- width: 32,
57
+ width: 38,
58
+ tags: true,
59
+ content: '{cyan-fg}Use ← → arrow keys to adjust volume{/cyan-fg}',
60
+ });
61
+
62
+ const barText = blessed.text({
63
+ parent: box,
64
+ top: 3,
65
+ left: 2,
66
+ width: 38,
58
67
  tags: true,
59
68
  content: '',
60
69
  });
61
70
 
62
- const hint = blessed.text({
71
+ blessed.text({
63
72
  parent: box,
64
73
  top: 5,
65
- left: 1,
66
- width: 34,
74
+ left: 2,
75
+ width: 38,
67
76
  tags: true,
68
- content: '{#455a64-fg}[←→] ±5 [1-9] type [Enter] OK [Esc] Cancel{/#455a64-fg}',
77
+ content: '{white-fg}[← →] ±5 [0-9] number [Esc] Cancel{/white-fg}',
78
+ });
79
+
80
+ blessed.text({
81
+ parent: box,
82
+ top: 7,
83
+ left: 2,
84
+ width: 38,
85
+ tags: true,
86
+ content: '{white-fg}[Enter] Confirm then {bold}{cyan-fg}[Tab]{/cyan-fg}{/bold} → Save{/white-fg}',
69
87
  });
70
88
 
71
89
  function _renderBar() {
@@ -73,10 +91,13 @@ export function openVolumeInput(screen, currentVol, onConfirm, onClose) {
73
91
  const empty = 20 - filled;
74
92
  const bar = '{bright-cyan-fg}' + '█'.repeat(filled) + '{/bright-cyan-fg}' +
75
93
  '{#263238-fg}' + '░'.repeat(empty) + '{/#263238-fg}';
76
- barText.setContent(`{#90a4ae-fg}Volume:{/#90a4ae-fg} ${bar} {bold}${vol}%{/bold}`);
94
+ barText.setContent(`{white-fg}Volume:{/white-fg} ${bar} {bold}${vol}%{/bold}`);
77
95
  screen.render();
78
96
  }
79
97
  _renderBar();
98
+ // Take focus so fieldList's key handlers don't fire while this dialog is open
99
+ box.focus();
100
+ screen.render();
80
101
 
81
102
  // Capture keypress directly on screen to avoid input mode issues
82
103
  let _digits = '';
@@ -99,8 +120,12 @@ export function openVolumeInput(screen, currentVol, onConfirm, onClose) {
99
120
  screen.removeListener('keypress', _onKey);
100
121
  box.destroy();
101
122
  screen.render();
102
- if (confirm && onConfirm) onConfirm(vol);
103
- if (onClose) onClose();
123
+ // Defer callbacks so the Enter keypress finishes propagating before fieldList
124
+ // regains focus — otherwise the same Enter event re-opens the track picker.
125
+ setTimeout(() => {
126
+ if (confirm && onConfirm) onConfirm(vol);
127
+ if (onClose) onClose();
128
+ }, 0);
104
129
  }
105
130
  }
106
131
 
@@ -121,7 +146,7 @@ const BUILT_IN_TRACKS = [
121
146
  * @param {Function} onSelect - called with (trackFile, volume)
122
147
  * @param {Function} [onClose] - called after modal fully closes
123
148
  */
124
- export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, onClose) {
149
+ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, onClose, options = {}) {
125
150
  const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
126
151
  let tracks;
127
152
  try {
@@ -271,17 +296,24 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
271
296
  if (selected) _previewTrack(selected.file);
272
297
  });
273
298
 
274
- // Enter = select track, then prompt for volume
299
+ // Enter = select track; if skipVolume, return track only, otherwise prompt for volume
275
300
  list.key(['enter'], () => {
276
301
  const selected = tracks[list.selected];
277
302
  if (!selected) return;
278
- // Close the track list first (without firing onClose yet), then open volume input
279
303
  _killPreview();
280
304
  if (list._label2) list._label2.destroy();
281
- destroyList(list, screen, null);
282
- openVolumeInput(screen, currentVolume ?? 20, (volume) => {
283
- onSelect(selected.file, volume);
284
- }, onClose);
305
+ if (options.skipVolume) {
306
+ destroyList(list, screen, null);
307
+ setTimeout(() => {
308
+ onSelect(selected.file);
309
+ if (onClose) onClose();
310
+ }, 0);
311
+ } else {
312
+ destroyList(list, screen, null);
313
+ openVolumeInput(screen, currentVolume ?? 20, (volume) => {
314
+ onSelect(selected.file, volume);
315
+ }, onClose);
316
+ }
285
317
  });
286
318
 
287
319
  list.key(['escape', 'q'], () => {