agentvibes 5.0.0 → 5.1.1

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.
@@ -44,7 +44,7 @@ const COLORS = {
44
44
  dimFg: '#455a64',
45
45
  };
46
46
 
47
- const FOOTER_TEXT = '[↑↓/jk] Navigate [Space] Preview [Enter] Select/Install [F] Favorite [/] Search';
47
+ const FOOTER_TEXT = '[↑↓/jk] Navigate [Space] Preview [Enter] Select/Install [*] Favorite [/] Search';
48
48
  /**
49
49
  * Resolve the Piper voice storage directory using the same precedence as the
50
50
  * shell-side get_voice_storage_dir() in piper-voice-manager.sh:
@@ -106,8 +106,10 @@ function loadCatalog() {
106
106
  const libritts = data.libritts_speakers ?? {};
107
107
  for (const [id, entry] of Object.entries(libritts)) {
108
108
  _catalogEntries.push({
109
+ // voiceId stays raw so existing user configs continue to resolve
109
110
  voiceId: `en_US-libritts-high${MS_SEP}${entry.voice_name}`,
110
- displayName: entry.voice_name,
111
+ // displayName gets a deterministic surname so every speaker is unique
112
+ displayName: uniquifyVoiceName(entry.voice_name),
111
113
  gender: (entry.gender ?? '').charAt(0).toUpperCase() + (entry.gender ?? '').slice(1),
112
114
  model: 'en_US-libritts-high',
113
115
  type: 'libritts',
@@ -183,7 +185,77 @@ function patchLibriTTSSpeakerNames() {
183
185
 
184
186
  // Column widths for the multi-column voice list
185
187
  export const COL_NAME_W = 26;
186
- export const COL_GENDER_W = 10;
188
+ export const COL_GENDER_W = 4;
189
+
190
+ // Surname pool used to convert "Anna-2" → "Anna Carter" so every speaker
191
+ // in the LibriTTS catalog gets a unique, friendly display name. Indexed by
192
+ // the trailing -N suffix (1-based: no suffix → idx 0, "-2" → idx 1, ...).
193
+ const SURNAME_POOL = [
194
+ 'Bell', 'Carter', 'Davis', 'Ellis', 'Foster', 'Gray', 'Hayes', 'Irving',
195
+ 'Jones', 'Knox', 'Lane', 'Mason', 'Nash', 'Owens', 'Pierce', 'Quinn',
196
+ ];
197
+
198
+ /**
199
+ * Convert a raw catalog speaker name like "Anna" or "Anna-2" into a unique
200
+ * friendly display name by appending a deterministic surname.
201
+ * "Anna" → "Anna Bell"
202
+ * "Anna-2" → "Anna Carter"
203
+ * "Anna-16" → "Anna Quinn"
204
+ * "Cori Samuel" → "Cori Samuel" (already full name — left alone)
205
+ *
206
+ * @param {string} rawName
207
+ * @returns {string}
208
+ */
209
+ export function uniquifyVoiceName(rawName) {
210
+ if (!rawName) return rawName;
211
+ const m = rawName.match(/^(.+)-(\d+)$/);
212
+ if (m) {
213
+ const base = m[1];
214
+ const n = parseInt(m[2], 10);
215
+ // Only treat -N as a duplicate counter when N >= 2 (catalog convention).
216
+ // -0 / -1 would otherwise produce collisions or undefined surnames.
217
+ if (n >= 2) {
218
+ const idx = (n - 1) % SURNAME_POOL.length;
219
+ return `${base} ${SURNAME_POOL[idx]}`;
220
+ }
221
+ }
222
+ // Only append a surname if the name is a single word — otherwise it's
223
+ // already a full name (e.g. "Cori Samuel") and we leave it alone.
224
+ if (/\s/.test(rawName)) return rawName;
225
+ return `${rawName} ${SURNAME_POOL[0]}`;
226
+ }
227
+
228
+ /**
229
+ * Map a gender string to a single-character icon for compact display.
230
+ * "Female" → "♀"
231
+ * "Male" → "♂"
232
+ * anything else → "—"
233
+ *
234
+ * @param {string} gender
235
+ * @returns {string}
236
+ */
237
+ export function genderIcon(gender) {
238
+ if (gender === 'Female') return '♀';
239
+ if (gender === 'Male') return '♂';
240
+ return '—';
241
+ }
242
+
243
+ /**
244
+ * Same as genderIcon but wrapped in blessed color tags:
245
+ * Female → pink (magenta)
246
+ * Male → light blue (bright-cyan)
247
+ * other → no color
248
+ * The returned string is 1 visible char wide but contains color tags;
249
+ * callers should add padding spaces separately rather than using padEnd().
250
+ *
251
+ * @param {string} gender
252
+ * @returns {string}
253
+ */
254
+ export function genderIconTag(gender) {
255
+ if (gender === 'Female') return '{magenta-fg}♀{/magenta-fg}';
256
+ if (gender === 'Male') return '{bright-cyan-fg}♂{/bright-cyan-fg}';
257
+ return '—';
258
+ }
187
259
 
188
260
  // ---------------------------------------------------------------------------
189
261
  // Pure helpers — exported for testability
@@ -439,6 +511,9 @@ const _metaCache = new Map();
439
511
  export function getVoiceMeta(voiceId) {
440
512
  if (_metaCache.has(voiceId)) return _metaCache.get(voiceId);
441
513
 
514
+ // Lazy-load the catalog so callers from any tab get uniquified names
515
+ loadCatalog();
516
+
442
517
  const ms = parseMultiSpeaker(voiceId);
443
518
  if (ms.isMultiSpeaker) {
444
519
  if (!ms.speakerName) {
@@ -446,7 +521,19 @@ export function getVoiceMeta(voiceId) {
446
521
  _metaCache.set(voiceId, result);
447
522
  return result;
448
523
  }
449
- const displayName = ms.speakerName.replace(/_/g, ' ');
524
+ // Prefer the catalog entry (which has the uniquified name + gender)
525
+ const cat = _catalogMap.get(voiceId);
526
+ if (cat) {
527
+ const result = {
528
+ displayName: cat.displayName,
529
+ gender: cat.gender || inferGender(ms.speakerName, null),
530
+ provider: `Piper (${ms.model})`,
531
+ };
532
+ _metaCache.set(voiceId, result);
533
+ return result;
534
+ }
535
+ // Fallback for speakers not in the catalog (e.g. 16Speakers model)
536
+ const displayName = uniquifyVoiceName(ms.speakerName.replace(/_/g, ' '));
450
537
  const result = {
451
538
  displayName,
452
539
  gender: inferGender(ms.speakerName, null),
@@ -560,7 +647,7 @@ export function createVoicesTab(screen, services) {
560
647
  parent: box,
561
648
  top: 4,
562
649
  left: 6,
563
- content: `{#00897b-fg}${_tl('voicesColName').padEnd(COL_NAME_W)}${_tl('voicesColGender').padEnd(COL_GENDER_W)}${_tl('voicesColProvider')}{/#00897b-fg}`,
650
+ content: `{#00897b-fg}${_tl('voicesColName').padEnd(COL_NAME_W)}{/#00897b-fg}{magenta-fg}♀{/magenta-fg}/{bright-cyan-fg}♂{/bright-cyan-fg} {#00897b-fg}${_tl('voicesColProvider')}{/#00897b-fg}`,
564
651
  tags: true,
565
652
  style: { bg: COLORS.contentBg },
566
653
  });
@@ -621,7 +708,7 @@ export function createVoicesTab(screen, services) {
621
708
 
622
709
  // -------------------------------------------------------------------------
623
710
  // Hint text shown in previewLine when the list has focus and nothing is playing
624
- const HINT_TEXT = `{${COLORS.dimFg}-fg}[Space] preview [Enter] select as default voice{/${COLORS.dimFg}-fg}`;
711
+ const HINT_TEXT = '{white-fg}[Space] preview [Enter] select as default voice{/white-fg}';
625
712
  let _listFocused = false;
626
713
 
627
714
  // Inline selection hint appended to the currently highlighted voice row.
@@ -647,21 +734,42 @@ export function createVoicesTab(screen, services) {
647
734
  return g;
648
735
  }
649
736
 
650
- // Known limitation: blink (' █') and hint text can briefly interleave when
651
- // _vlTick fires between stripping and re-appending the hint. Accepted as cosmetic.
737
+ // Decoration helpers — strip blink cursor and (optionally) hint text from
738
+ // a list item's content. We use a precise approach instead of a generic
739
+ // regex over `bright-black-fg` blocks because uninstalled rows wrap their
740
+ // PROVIDER column in `bright-black-fg` too — a generic strip would erase it.
741
+ //
742
+ // _stripBlink(): only removes ` █` markers (safe to call on any row).
743
+ // _stripHint(): removes hint by anchoring to the exact hint text from
744
+ // _getRowHint(idx). Only safe on rows we know are hinted.
745
+ const _BLINK_RE = / █/g;
746
+ function _stripBlink(raw) {
747
+ return (raw ?? '').replace(_BLINK_RE, '');
748
+ }
749
+ function _stripHint(raw, idx) {
750
+ const hint = _getRowHint(idx);
751
+ if (!hint) return raw;
752
+ // The hint is appended to the end (possibly followed by blink); remove it.
753
+ const noBlink = (raw ?? '').replace(_BLINK_RE, '');
754
+ if (noBlink.endsWith(hint)) return noBlink.slice(0, -hint.length);
755
+ return noBlink;
756
+ }
757
+ function _stripDecorations(raw, idx) {
758
+ return _stripHint(_stripBlink(raw), idx);
759
+ }
760
+
652
761
  function _updateHint(idx) {
653
762
  const items = voiceList.items;
654
763
  const _pad = ' '.repeat(60);
764
+ // Restore previously hinted row to its clean base
655
765
  if (_hintIdx >= 0 && _hintIdx !== idx && items[_hintIdx]) {
656
- const hadBlink = (items[_hintIdx].content ?? '').endsWith(' █');
657
- items[_hintIdx].setContent((hadBlink ? _hintBase + ' █' : _hintBase) + _pad);
766
+ items[_hintIdx].setContent(_hintBase + _pad);
658
767
  }
659
768
  if (idx >= 0 && items[idx]) {
660
- let c = items[idx].content ?? '';
661
- const hasBlink = c.endsWith(' █');
662
- if (hasBlink) c = c.slice(0, -2);
663
- _hintBase = c;
664
- items[idx].setContent(c + _getRowHint(idx) + (hasBlink ? ' █' : ''));
769
+ // The row may already have a hint (from a prior _updateHint cycle)
770
+ // and/or a blink — strip both before capturing the clean base.
771
+ _hintBase = _stripDecorations(items[idx].content, idx);
772
+ items[idx].setContent(_hintBase + _getRowHint(idx));
665
773
  } else {
666
774
  _hintBase = '';
667
775
  }
@@ -1008,7 +1116,7 @@ export function createVoicesTab(screen, services) {
1008
1116
  left: 2,
1009
1117
  right: 2,
1010
1118
  tags: true,
1011
- content: `{${COLORS.dimFg}-fg}Press Preview to audition this voice{/${COLORS.dimFg}-fg}`,
1119
+ content: '{white-fg}Press Preview to audition this voice{/white-fg}',
1012
1120
  style: { bg: COLORS.contentBg },
1013
1121
  });
1014
1122
 
@@ -1079,7 +1187,7 @@ export function createVoicesTab(screen, services) {
1079
1187
  const isPlaying = _playingVoiceId === voiceId;
1080
1188
  _previewVoice(voiceId);
1081
1189
  modalStatus.setContent(isPlaying
1082
- ? `{${COLORS.dimFg}-fg}Stopped.{/${COLORS.dimFg}-fg}`
1190
+ ? '{white-fg}Stopped.{/white-fg}'
1083
1191
  : `{${COLORS.activeFg}-fg}♪ Playing: ${displayName}…{/${COLORS.activeFg}-fg}`
1084
1192
  );
1085
1193
  screen.render();
@@ -1354,11 +1462,13 @@ export function createVoicesTab(screen, services) {
1354
1462
  ? displayName.slice(0, COL_NAME_W - 1) + '…'
1355
1463
  : displayName.padEnd(COL_NAME_W);
1356
1464
 
1465
+ // genderIconTag has invisible color tags — pad with literal spaces (1 visible char + 3 spaces = 4)
1466
+ const gIcon = genderIconTag(gender);
1357
1467
  if (!installed) {
1358
- // Greyed-out row for uninstalled catalog voices
1359
- return `{bright-black-fg} ${star} ${name}${_tGender(gender).padEnd(COL_GENDER_W)}${provider}{/bright-black-fg}`;
1468
+ // Greyed-out row for uninstalled catalog voices — close grey wrap around the icon so its colors show
1469
+ return `{bright-black-fg} ${star} ${name}{/bright-black-fg}${gIcon} {bright-black-fg}${provider}{/bright-black-fg}`;
1360
1470
  }
1361
- return `{${COLORS.labelFg}-fg} ${star}${dot} ${name}${_tGender(gender).padEnd(COL_GENDER_W)}${provider}${isPrev ? ` ${_tl('voicePlaying')}` : ''}{/${COLORS.labelFg}-fg}`;
1471
+ return `{${COLORS.labelFg}-fg} ${star}${dot} ${name}{/${COLORS.labelFg}-fg}${gIcon} {${COLORS.labelFg}-fg}${provider}${isPrev ? ` ${_tl('voicePlaying')}` : ''}{/${COLORS.labelFg}-fg}`;
1362
1472
  });
1363
1473
  }
1364
1474
 
@@ -1511,7 +1621,7 @@ export function createVoicesTab(screen, services) {
1511
1621
  });
1512
1622
 
1513
1623
  // 'f' or '*' in voiceList toggles favorite
1514
- voiceList.key(['f', '*'], () => {
1624
+ voiceList.key(['*'], () => {
1515
1625
  const voices = _getFilteredVoices();
1516
1626
  const selected = voices[voiceList.selected];
1517
1627
  if (selected) {
@@ -1560,13 +1670,16 @@ export function createVoicesTab(screen, services) {
1560
1670
  _vlBlink.on = !_vlBlink.on;
1561
1671
  const items = voiceList.items;
1562
1672
  const cur = voiceList.selected ?? 0;
1673
+ // Clean old row — only strip blink (it's not the hinted row anymore,
1674
+ // and we don't want to accidentally erase its provider column).
1563
1675
  if (_vlBlink.sel !== cur && _vlBlink.sel >= 0 && items[_vlBlink.sel]) {
1564
- items[_vlBlink.sel].setContent((items[_vlBlink.sel].content ?? '').replace(/ █$/, ''));
1676
+ items[_vlBlink.sel].setContent(_stripBlink(items[_vlBlink.sel].content));
1565
1677
  }
1566
1678
  _vlBlink.sel = cur;
1567
1679
  if (items[cur]) {
1568
- const base = (items[cur].content ?? '').replace(/ █$/, '');
1569
- items[cur].setContent(_vlBlink.on ? `${base} █` : base);
1680
+ // Get content without blink but preserve hint
1681
+ const raw = _stripBlink(items[cur].content);
1682
+ items[cur].setContent(_vlBlink.on ? `${raw} █` : raw);
1570
1683
  }
1571
1684
  screen.render();
1572
1685
  }
@@ -1578,7 +1691,10 @@ export function createVoicesTab(screen, services) {
1578
1691
  _hintBase = '';
1579
1692
  _updateHint(_vlBlink.sel);
1580
1693
  const items = voiceList.items;
1581
- if (items[_vlBlink.sel]) items[_vlBlink.sel].setContent((items[_vlBlink.sel].content ?? '') + ' █');
1694
+ if (items[_vlBlink.sel]) {
1695
+ const raw = _stripBlink(items[_vlBlink.sel].content);
1696
+ items[_vlBlink.sel].setContent(raw + ' █');
1697
+ }
1582
1698
  if (!_playingVoiceId) previewLine.setContent(HINT_TEXT);
1583
1699
  screen.render();
1584
1700
  _vlBlink.interval = setInterval(_vlTick, 500);
@@ -1588,12 +1704,19 @@ export function createVoicesTab(screen, services) {
1588
1704
  if (!_playingVoiceId) previewLine.setContent('');
1589
1705
  if (_vlBlink.interval) { clearInterval(_vlBlink.interval); _vlBlink.interval = null; }
1590
1706
  const items = voiceList.items;
1707
+ // Clean up: strip blink from selected row, strip hint from hinted row.
1708
+ // Only the hinted row knows what its hint text was — use _stripDecorations
1709
+ // there. For other rows, only strip blink.
1591
1710
  const sel = voiceList.selected ?? 0;
1592
1711
  if (items[sel]) {
1593
- items[sel].setContent(sel === _hintIdx ? _hintBase : (items[sel].content ?? '').replace(/ █$/, ''));
1712
+ if (sel === _hintIdx) {
1713
+ items[sel].setContent(_stripDecorations(items[sel].content, sel));
1714
+ } else {
1715
+ items[sel].setContent(_stripBlink(items[sel].content));
1716
+ }
1594
1717
  }
1595
1718
  if (_hintIdx >= 0 && _hintIdx !== sel && items[_hintIdx]) {
1596
- items[_hintIdx].setContent(_hintBase);
1719
+ items[_hintIdx].setContent(_stripDecorations(items[_hintIdx].content, _hintIdx));
1597
1720
  }
1598
1721
  _hintIdx = -1;
1599
1722
  _hintBase = '';
@@ -1612,7 +1735,7 @@ export function createVoicesTab(screen, services) {
1612
1735
  });
1613
1736
 
1614
1737
  // Type-to-jump: press a letter to jump to first voice whose display name starts with it
1615
- const _voiceJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'f']);
1738
+ const _voiceJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u']);
1616
1739
  voiceList.on('keypress', (ch, key) => {
1617
1740
  if (!ch || key.ctrl || key.meta) return;
1618
1741
  const lower = ch.toLowerCase();
@@ -1665,7 +1788,7 @@ export function createVoicesTab(screen, services) {
1665
1788
  function refreshVoicesLabels() {
1666
1789
  voicesSectionHdr.setContent(`{#00897b-fg}${_tl('voicesHeader')}${'─'.repeat(58)}{/#00897b-fg}`);
1667
1790
  searchLabelText.setContent(_tl('searchLabel'));
1668
- colHeaderText.setContent(`{#00897b-fg}${_tl('voicesColName').padEnd(COL_NAME_W)}${_tl('voicesColGender').padEnd(COL_GENDER_W)}${_tl('voicesColProvider')}{/#00897b-fg}`);
1791
+ colHeaderText.setContent(`{#00897b-fg}${_tl('voicesColName').padEnd(COL_NAME_W)}{/#00897b-fg}{magenta-fg}♀{/magenta-fg}/{bright-cyan-fg}♂{/bright-cyan-fg} {#00897b-fg}${_tl('voicesColProvider')}{/#00897b-fg}`);
1669
1792
  voiceInfoHdr.setContent(`{#00897b-fg}${_tl('voicesInfoHeader')}${'─'.repeat(54)}{/#00897b-fg}`);
1670
1793
  switchBtn.setContent(_tl('voicesSwitchBtn'));
1671
1794
  favoriteBtn.setContent(_tl('voicesFavoriteBtn'));
@@ -1,89 +1,92 @@
1
- /**
2
- * AgentVibes TUI — Shared Format Utilities
3
- *
4
- * Pure formatting functions extracted from settings-tab.js to avoid
5
- * circular imports between widgets and tabs.
6
- */
7
-
8
- const TRACK_NAMES = Object.freeze({
9
- 'agentvibes_soft_flamenco_loop.mp3': '🎻 Soft Flamenco',
10
- 'agent_vibes_bachata_v1_loop.mp3': '🎺 Bachata',
11
- 'agent_vibes_salsa_v2_loop.mp3': '💃 Salsa',
12
- 'agent_vibes_cumbia_v1_loop.mp3': '🎸 Cumbia',
13
- 'agent_vibes_bossa_nova_v2_loop.mp3': '🌸 Bossa Nova',
14
- 'agent_vibes_japanese_city_pop_v1_loop.mp3': '🌆 Japanese City Pop',
15
- 'agent_vibes_chillwave_v2_loop.mp3': '🌊 Chillwave',
16
- 'agent_vibes_dark_chill_step_loop.mp3': '🌙 Dark Chill Step',
17
- 'agent_vibes_goa_trance_v2_loop.mp3': '🌀 Goa Trance',
18
- 'agent_vibes_harpsichord_v2_loop.mp3': '🎼 Harpsichord',
19
- 'agent_vibes_celtic_harp_v1_loop.mp3': '🎻 Celtic Harp',
20
- 'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3': '🌺 Hawaiian Slack Key Guitar',
21
- 'agent_vibes_arabic_v2_loop.mp3': '🎵 Arabic Oud',
22
- 'agent_vibes_ganawa_ambient_v2_loop.mp3': '🪘 Gnawa Ambient',
23
- 'agent_vibes_tabla_dream_pop_v1_loop.mp3': '🥁 Tabla Dream Pop',
24
- });
25
-
26
- /**
27
- * @param {string} track - filename (e.g. 'agentvibes_soft_flamenco_loop.mp3')
28
- * @returns {string}
29
- */
30
- export function formatTrackName(track) {
31
- if (!track) return 'None';
32
- if (TRACK_NAMES[track]) return TRACK_NAMES[track];
33
- return track
34
- .replace(/\.[^.]+$/, '')
35
- .replace(/^agentvibes_|^agent_vibes_/, '')
36
- .replace(/_v\d+_loop$|_loop$|_v\d+$/, '')
37
- .replace(/_/g, ' ')
38
- .replace(/\b\w/g, c => c.toUpperCase());
39
- }
40
-
41
- /**
42
- * Beautify a raw voice identifier for display in narrow table columns.
43
- *
44
- * Examples:
45
- * 16Speakers::Rose_Ibex → Rose Ibex
46
- * 16Speakers::Emily_Cripps → Emily Cripps
47
- * en_US-kusal-medium → Kusal
48
- * en_US-lessac-high Lessac
49
- * en_US-libritts_r-medium Libritts R
50
- * kristin Kristin
51
- *
52
- * @param {string} voice - raw voice identifier
53
- * @returns {string}
54
- */
55
- export function formatVoiceName(voice) {
56
- if (!voice) return '(global)';
57
-
58
- let name;
59
- if (voice.includes('::')) {
60
- // 16Speakers::Rose_Ibex → extract after '::'
61
- name = voice.split('::')[1];
62
- } else {
63
- const parts = voice.split('-');
64
- const QUALITIES = new Set(['high', 'medium', 'low']);
65
- if (parts.length >= 2 && /^[a-z]{2}_[A-Z]{2}$/.test(parts[0])) {
66
- // Strip locale prefix and quality suffix
67
- name = parts.slice(1).filter(p => !QUALITIES.has(p)).join(' ');
68
- } else {
69
- name = voice;
70
- }
71
- }
72
-
73
- // Replace underscores with spaces, title-case each word
74
- return name
75
- .replace(/_/g, ' ')
76
- .split(' ')
77
- .filter(Boolean)
78
- .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
79
- .join(' ') || '(global)';
80
- }
81
-
82
- /**
83
- * @param {string} preset - 'off' | 'light' | 'medium' | 'heavy' | 'cathedral'
84
- * @returns {string}
85
- */
86
- export function formatReverbState(preset) {
87
- const LABELS = { off: 'Off', light: 'Light (Small room)', medium: 'Medium (Conference room)', heavy: 'Heavy (Large hall)', cathedral: 'Cathedral (Epic space)' };
88
- return LABELS[preset] ?? LABELS.light;
89
- }
1
+ /**
2
+ * AgentVibes TUI — Shared Format Utilities
3
+ *
4
+ * Pure formatting functions extracted from settings-tab.js to avoid
5
+ * circular imports between widgets and tabs.
6
+ */
7
+
8
+ const TRACK_NAMES = Object.freeze({
9
+ 'agentvibes_soft_flamenco_loop.mp3': '🎻 Soft Flamenco',
10
+ 'agent_vibes_bachata_v1_loop.mp3': '🎺 Bachata',
11
+ 'agent_vibes_salsa_v2_loop.mp3': '💃 Salsa',
12
+ 'agent_vibes_cumbia_v1_loop.mp3': '🎸 Cumbia',
13
+ 'agent_vibes_bossa_nova_v2_loop.mp3': '🌸 Bossa Nova',
14
+ 'agent_vibes_japanese_city_pop_v1_loop.mp3': '🌆 Japanese City Pop',
15
+ 'agent_vibes_chillwave_v2_loop.mp3': '🌊 Chillwave',
16
+ 'agent_vibes_dark_chill_step_loop.mp3': '🌙 Dark Chill Step',
17
+ 'agent_vibes_goa_trance_v2_loop.mp3': '🌀 Goa Trance',
18
+ 'agent_vibes_harpsichord_v2_loop.mp3': '🎼 Harpsichord',
19
+ 'agent_vibes_celtic_harp_v1_loop.mp3': '🎻 Celtic Harp',
20
+ 'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3': '🌺 Hawaiian Slack Key Guitar',
21
+ 'agent_vibes_arabic_v2_loop.mp3': '🎵 Arabic Oud',
22
+ 'agent_vibes_ganawa_ambient_v2_loop.mp3': '🪘 Gnawa Ambient',
23
+ 'agent_vibes_tabla_dream_pop_v1_loop.mp3': '🥁 Tabla Dream Pop',
24
+ 'Late Night Hip Hop Groove.mp3': '🎤 Late Night Hip Hop Groove',
25
+ 'Drifting Down the Hall.mp3': '🌃 Drifting Down the Hall',
26
+ 'Midnight Charleston Stomp.mp3': '🎩 Midnight Charleston Stomp',
27
+ });
28
+
29
+ /**
30
+ * @param {string} track - filename (e.g. 'agentvibes_soft_flamenco_loop.mp3')
31
+ * @returns {string}
32
+ */
33
+ export function formatTrackName(track) {
34
+ if (!track) return 'None';
35
+ if (TRACK_NAMES[track]) return TRACK_NAMES[track];
36
+ return track
37
+ .replace(/\.[^.]+$/, '')
38
+ .replace(/^agentvibes_|^agent_vibes_/, '')
39
+ .replace(/_v\d+_loop$|_loop$|_v\d+$/, '')
40
+ .replace(/_/g, ' ')
41
+ .replace(/\b\w/g, c => c.toUpperCase());
42
+ }
43
+
44
+ /**
45
+ * Beautify a raw voice identifier for display in narrow table columns.
46
+ *
47
+ * Examples:
48
+ * 16Speakers::Rose_Ibex Rose Ibex
49
+ * 16Speakers::Emily_Cripps Emily Cripps
50
+ * en_US-kusal-medium Kusal
51
+ * en_US-lessac-high → Lessac
52
+ * en_US-libritts_r-medium → Libritts R
53
+ * kristin → Kristin
54
+ *
55
+ * @param {string} voice - raw voice identifier
56
+ * @returns {string}
57
+ */
58
+ export function formatVoiceName(voice) {
59
+ if (!voice) return '(global)';
60
+
61
+ let name;
62
+ if (voice.includes('::')) {
63
+ // 16Speakers::Rose_Ibex extract after '::'
64
+ name = voice.split('::')[1];
65
+ } else {
66
+ const parts = voice.split('-');
67
+ const QUALITIES = new Set(['high', 'medium', 'low']);
68
+ if (parts.length >= 2 && /^[a-z]{2}_[A-Z]{2}$/.test(parts[0])) {
69
+ // Strip locale prefix and quality suffix
70
+ name = parts.slice(1).filter(p => !QUALITIES.has(p)).join(' ');
71
+ } else {
72
+ name = voice;
73
+ }
74
+ }
75
+
76
+ // Replace underscores with spaces, title-case each word
77
+ return name
78
+ .replace(/_/g, ' ')
79
+ .split(' ')
80
+ .filter(Boolean)
81
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
82
+ .join(' ') || '(global)';
83
+ }
84
+
85
+ /**
86
+ * @param {string} preset - 'off' | 'light' | 'medium' | 'heavy' | 'cathedral'
87
+ * @returns {string}
88
+ */
89
+ export function formatReverbState(preset) {
90
+ const LABELS = { off: 'Off', light: 'Light (Small room)', medium: 'Medium (Conference room)', heavy: 'Heavy (Large hall)', cathedral: 'Cathedral (Epic space)' };
91
+ return LABELS[preset] ?? LABELS.light;
92
+ }