agentvibes 5.10.1 → 5.11.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.
Files changed (160) hide show
  1. package/.agentvibes/config.json +15 -1
  2. package/.agentvibes/install-manifest.json +342 -0
  3. package/.claude/audio/ui/CREDITS.txt +16 -0
  4. package/.claude/audio/ui/bling-success.wav +0 -0
  5. package/.claude/commands/agent-vibes/receiver.md +64 -0
  6. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  7. package/.claude/commands/agent-vibes-rdp.md +24 -24
  8. package/.claude/config/audio-effects.cfg +5 -4
  9. package/.claude/config/audio-effects.cfg.sample +52 -52
  10. package/.claude/config/background-music-enabled.txt +1 -1
  11. package/.claude/config/background-music-position.txt +27 -0
  12. package/.claude/config/background-music-volume.txt +1 -0
  13. package/.claude/config/background-music.cfg +1 -0
  14. package/.claude/config/background-music.txt +1 -0
  15. package/.claude/config/language.txt +1 -0
  16. package/.claude/config/personality.txt +1 -0
  17. package/.claude/config/reverb-level.txt +1 -0
  18. package/.claude/config/tts-speech-rate.txt +1 -0
  19. package/.claude/config/tts-verbosity.txt +1 -0
  20. package/.claude/docs/TERMUX_SETUP.md +408 -408
  21. package/.claude/github-star-reminder.txt +1 -1
  22. package/.claude/hooks/audio-cache-utils.sh +0 -0
  23. package/.claude/hooks/audio-processor.sh +57 -18
  24. package/.claude/hooks/background-music-manager.sh +0 -0
  25. package/.claude/hooks/bmad-party-speak.sh +0 -0
  26. package/.claude/hooks/bmad-speak-enhanced.sh +0 -0
  27. package/.claude/hooks/bmad-speak.sh +0 -0
  28. package/.claude/hooks/bmad-tts-injector.sh +0 -0
  29. package/.claude/hooks/bmad-voice-manager.sh +0 -0
  30. package/.claude/hooks/clawdbot-receiver-SECURE.sh +0 -0
  31. package/.claude/hooks/clawdbot-receiver.sh +0 -0
  32. package/.claude/hooks/clean-audio-cache.sh +0 -0
  33. package/.claude/hooks/cleanup-cache.sh +0 -0
  34. package/.claude/hooks/configure-rdp-mode.sh +0 -0
  35. package/.claude/hooks/download-extra-voices.sh +0 -0
  36. package/.claude/hooks/effects-manager.sh +0 -0
  37. package/.claude/hooks/github-star-reminder.sh +0 -0
  38. package/.claude/hooks/kokoro-installer.sh +117 -0
  39. package/.claude/hooks/kokoro-server.py +219 -0
  40. package/.claude/hooks/kokoro-tts.py +141 -0
  41. package/.claude/hooks/language-manager.sh +0 -0
  42. package/.claude/hooks/learn-manager.sh +0 -0
  43. package/.claude/hooks/macos-voice-manager.sh +0 -0
  44. package/.claude/hooks/migrate-background-music.sh +0 -0
  45. package/.claude/hooks/migrate-to-agentvibes.sh +0 -0
  46. package/.claude/hooks/optimize-background-music.sh +0 -0
  47. package/.claude/hooks/path-resolver.sh +0 -0
  48. package/.claude/hooks/personality-manager.sh +0 -0
  49. package/.claude/hooks/piper-download-voices.sh +39 -16
  50. package/.claude/hooks/piper-installer.sh +11 -6
  51. package/.claude/hooks/piper-multispeaker-registry.sh +0 -0
  52. package/.claude/hooks/piper-voice-manager.sh +6 -6
  53. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +3 -1
  54. package/.claude/hooks/play-tts-agentvibes-receiver.sh +1 -0
  55. package/.claude/hooks/play-tts-elevenlabs.sh +360 -0
  56. package/.claude/hooks/play-tts-enhanced.sh +0 -0
  57. package/.claude/hooks/play-tts-kokoro.sh +127 -0
  58. package/.claude/hooks/play-tts-macos.sh +0 -0
  59. package/.claude/hooks/play-tts-piper.sh +0 -0
  60. package/.claude/hooks/play-tts-soprano.sh +0 -0
  61. package/.claude/hooks/play-tts-ssh-remote.sh +9 -2
  62. package/.claude/hooks/play-tts-termux-ssh.sh +3 -1
  63. package/.claude/hooks/play-tts-windows-receiver.sh +0 -0
  64. package/.claude/hooks/play-tts.sh +78 -2
  65. package/.claude/hooks/prepare-release.sh +0 -0
  66. package/.claude/hooks/provider-commands.sh +71 -22
  67. package/.claude/hooks/provider-manager.sh +15 -7
  68. package/.claude/hooks/replay-target-audio.sh +0 -0
  69. package/.claude/hooks/requirements.txt +6 -6
  70. package/.claude/hooks/sentiment-manager.sh +0 -0
  71. package/.claude/hooks/session-start-tts.sh +0 -0
  72. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  73. package/.claude/hooks/speed-manager.sh +0 -0
  74. package/.claude/hooks/stop-tts.sh +0 -0
  75. package/.claude/hooks/termux-installer.sh +0 -0
  76. package/.claude/hooks/translate-manager.sh +0 -0
  77. package/.claude/hooks/translator.py +237 -237
  78. package/.claude/hooks/tts-queue-worker.sh +0 -0
  79. package/.claude/hooks/tts-queue.sh +0 -0
  80. package/.claude/hooks/verbosity-manager.sh +0 -0
  81. package/.claude/hooks/voice-manager.sh +0 -0
  82. package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +119 -0
  83. package/.claude/hooks-windows/kokoro-server.py +219 -0
  84. package/.claude/hooks-windows/kokoro-tts.py +107 -0
  85. package/.claude/hooks-windows/play-tts-kokoro.ps1 +191 -0
  86. package/.claude/hooks-windows/play-tts.ps1 +29 -1
  87. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  88. package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +153 -0
  89. package/.claude/piper-voices-dir.txt +1 -0
  90. package/.claude/verbosity.txt +1 -1
  91. package/.clawdbot/README.md +105 -105
  92. package/.mcp.json +6 -19
  93. package/README.md +12 -86
  94. package/RELEASE_NOTES.md +60 -0
  95. package/WINDOWS-SETUP.md +208 -208
  96. package/bin/agent-vibes +39 -39
  97. package/bin/agentvibes-voice-browser.js +0 -0
  98. package/bin/agentvibes.js +0 -0
  99. package/bin/mcp-server.js +121 -121
  100. package/bin/mcp-server.sh +0 -0
  101. package/bin/test-bmad-pr +78 -78
  102. package/mcp-server/QUICK_START.md +203 -203
  103. package/mcp-server/README.md +345 -345
  104. package/mcp-server/WINDOWS_SETUP.md +0 -0
  105. package/mcp-server/examples/claude_desktop_config.json +11 -11
  106. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  107. package/mcp-server/examples/custom_instructions.md +169 -169
  108. package/mcp-server/install-deps.js +0 -0
  109. package/mcp-server/server.py +1807 -1807
  110. package/mcp-server/test_server.py +0 -0
  111. package/package.json +4 -3
  112. package/src/cli/list-personalities.js +110 -110
  113. package/src/cli/list-voices.js +114 -114
  114. package/src/commands/bmad-voices.js +394 -394
  115. package/src/commands/install-mcp.js +3 -3
  116. package/src/console/app.js +25 -9
  117. package/src/console/audio-env.js +85 -1
  118. package/src/console/brand-colors.js +13 -13
  119. package/src/console/constants/personalities.js +44 -44
  120. package/src/console/navigation.js +4 -0
  121. package/src/console/tabs/agents-tab.js +12 -6
  122. package/src/console/tabs/help-tab.js +314 -314
  123. package/src/console/tabs/music-tab.js +7 -25
  124. package/src/console/tabs/readme-tab.js +272 -272
  125. package/src/console/tabs/setup-tab.js +1704 -120
  126. package/src/console/tabs/voices-tab.js +72 -88
  127. package/src/console/widgets/destroy-list.js +25 -25
  128. package/src/console/widgets/format-utils.js +14 -2
  129. package/src/console/widgets/help-bar.js +55 -0
  130. package/src/console/widgets/notice.js +55 -55
  131. package/src/console/widgets/reverb-picker.js +429 -41
  132. package/src/console/widgets/track-picker.js +60 -51
  133. package/src/i18n/de.js +2 -1
  134. package/src/i18n/en.js +1 -0
  135. package/src/i18n/es.js +2 -1
  136. package/src/i18n/fr.js +2 -1
  137. package/src/i18n/hi.js +2 -1
  138. package/src/i18n/ja.js +2 -1
  139. package/src/i18n/ko.js +2 -1
  140. package/src/i18n/pt.js +2 -1
  141. package/src/i18n/strings.js +9 -9
  142. package/src/i18n/zh-CN.js +2 -1
  143. package/src/installer/language-screen.js +31 -31
  144. package/src/installer/music-file-input.js +304 -304
  145. package/src/installer.js +95 -7
  146. package/src/services/config-service.js +264 -264
  147. package/src/services/language-service.js +47 -47
  148. package/src/services/provider-service.js +35 -0
  149. package/src/services/provider-voice-catalog.js +126 -0
  150. package/src/services/tts-engine-service.js +51 -2
  151. package/src/utils/audio-duration-validator.js +53 -10
  152. package/src/utils/audio-format-validator.js +277 -277
  153. package/src/utils/dependency-checker.js +469 -469
  154. package/src/utils/file-ownership-verifier.js +358 -358
  155. package/src/utils/music-file-validator.js +285 -285
  156. package/src/utils/preview-list-prompt.js +144 -136
  157. package/src/utils/provider-validator.js +70 -0
  158. package/src/utils/secure-music-storage.js +412 -412
  159. package/templates/agentvibes-receiver.sh +235 -231
  160. package/templates/audio/welcome-music.mp3 +0 -0
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Provider voice catalogs — single source of truth for the static voice lists
3
+ * of the cloud/neural providers, plus a helper that returns the voice pool for
4
+ * whichever provider is currently active.
5
+ *
6
+ * Piper voices are NOT listed here (they are discovered on disk via
7
+ * scanInstalledVoices); this module covers the providers whose voices are a
8
+ * fixed catalog: ElevenLabs and Kokoro. Single-voice providers (e.g. soprano)
9
+ * resolve to a single synthetic entry.
10
+ *
11
+ * @module services/provider-voice-catalog
12
+ */
13
+
14
+ import { isSingleVoiceProvider } from './agent-voice-store.js';
15
+
16
+ /** ElevenLabs library voices (voice_id + display metadata). */
17
+ export const ELEVENLABS_VOICES = [
18
+ { id: 'EXAVITQu4vr4xnSDxMaL', name: 'Sarah', gender: 'Female', lang: 'en-US', desc: 'Mature, reassuring' },
19
+ { id: 'CwhRBWXzGAHq8TQ4Fs17', name: 'Roger', gender: 'Male', lang: 'en-US', desc: 'Laid-back, casual' },
20
+ { id: 'FGY2WhTYpPnrIDTdsKH5', name: 'Laura', gender: 'Female', lang: 'en-US', desc: 'Enthusiast, quirky' },
21
+ { id: 'IKne3meq5aSn9XLyUdCD', name: 'Charlie', gender: 'Male', lang: 'en-AU', desc: 'Deep, confident' },
22
+ { id: 'JBFqnCBsd6RMkjVDRZzb', name: 'George', gender: 'Male', lang: 'en-GB', desc: 'Warm storyteller' },
23
+ { id: 'N2lVS1w4EtoT3dr4eOWO', name: 'Callum', gender: 'Male', lang: 'en-US', desc: 'Husky trickster' },
24
+ { id: 'SAz9YHcvj6GT2YYXdXww', name: 'River', gender: '', lang: 'en-US', desc: 'Relaxed, neutral' },
25
+ { id: 'SOYHLrjzK2X1ezoPC6cr', name: 'Harry', gender: 'Male', lang: 'en-US', desc: 'Fierce warrior' },
26
+ { id: 'TX3LPaxmHKxFdv7VOQHJ', name: 'Liam', gender: 'Male', lang: 'en-US', desc: 'Energetic creator' },
27
+ { id: 'Xb7hH8MSUJpSbSDYk0k2', name: 'Alice', gender: 'Female', lang: 'en-GB', desc: 'Clear educator' },
28
+ { id: 'XrExE9yKIg1WjnnlVkGX', name: 'Matilda', gender: 'Female', lang: 'en-US', desc: 'Knowledgable, pro' },
29
+ { id: 'bIHbv24MWmeRgasZH58o', name: 'Will', gender: 'Male', lang: 'en-US', desc: 'Relaxed optimist' },
30
+ { id: 'cgSgspJ2msm6clMCkdW9', name: 'Jessica', gender: 'Female', lang: 'en-US', desc: 'Playful, bright' },
31
+ { id: 'cjVigY5qzO86Huf0OWal', name: 'Eric', gender: 'Male', lang: 'en-US', desc: 'Smooth, trustworthy' },
32
+ { id: 'hpp4J3VqNfWAUOO0d1Us', name: 'Bella', gender: 'Female', lang: 'en-US', desc: 'Professional, warm' },
33
+ { id: 'iP95p4xoKVk53GoZ742B', name: 'Chris', gender: 'Male', lang: 'en-US', desc: 'Charming, down-to-earth' },
34
+ { id: 'nPczCjzI2devNBz1zQrb', name: 'Brian', gender: 'Male', lang: 'en-US', desc: 'Deep, comforting' },
35
+ { id: 'onwK4e9ZLuTAKqWW03F9', name: 'Daniel', gender: 'Male', lang: 'en-GB', desc: 'Steady broadcaster' },
36
+ { id: 'pFZP5JQG7iQjIQuC4Bku', name: 'Lily', gender: 'Female', lang: 'en-GB', desc: 'Velvety actress' },
37
+ { id: 'pNInz6obpgDQGcFmaJgB', name: 'Adam', gender: 'Male', lang: 'en-US', desc: 'Dominant, firm' },
38
+ { id: 'pqHfZKP75CvOlQylNhV4', name: 'Bill', gender: 'Male', lang: 'en-US', desc: 'Wise, mature' },
39
+ // ── Added from the ElevenLabs voice library (raw library voice_ids) ───────
40
+ { id: 'HBDoL4wkcalemIO0nUAu', name: 'Emily', gender: 'Female', lang: 'en-GB', desc: 'RPG, immersive' },
41
+ { id: 'Mtmp3KhFIjYpWYRycDe3', name: 'John', gender: 'Male', lang: 'en-US', desc: 'Raspy surfer' },
42
+ { id: '8kgj5469z1URcH4MB2G4', name: 'Sakuya', gender: 'Female', lang: 'en-US', desc: 'Cheerful anime' },
43
+ { id: '2ajXGJNYBR0iNHpS4VZb', name: 'Rob', gender: 'Male', lang: 'en-GB', desc: 'Tough, gritty' },
44
+ { id: 'mMdTuD2nnFiCH88UdXSb', name: 'Jane', gender: 'Female', lang: 'en-US', desc: 'Cinematic villain' },
45
+ ];
46
+
47
+ /** Kokoro voice ids. Gender is encoded in the id: the 2nd char is f|m. */
48
+ export const KOKORO_VOICE_IDS = [
49
+ // American English
50
+ 'af_heart','af_alloy','af_aoede','af_bella','af_jessica','af_kore','af_nicole','af_nova','af_river','af_sarah','af_sky',
51
+ 'am_adam','am_echo','am_eric','am_fenrir','am_liam','am_michael','am_onyx','am_puck',
52
+ // British English
53
+ 'bf_alice','bf_emma','bf_isabella','bf_lily',
54
+ 'bm_daniel','bm_fable','bm_george','bm_lewis',
55
+ // Japanese
56
+ 'jf_alpha','jf_gongitsune','jf_nezumi','jf_tebukuro','jm_kumo',
57
+ // Mandarin Chinese
58
+ 'zf_xiaobei','zf_xiaoni','zf_xiaoxiao','zf_xiaoyi','zm_yunxi','zm_yunxia','zm_yunyang',
59
+ // Spanish
60
+ 'ef_dora','em_alex','em_santa',
61
+ // French
62
+ 'ff_siwis',
63
+ // Hindi
64
+ 'hf_alpha','hm_omega',
65
+ // Italian
66
+ 'if_sara','im_nicola',
67
+ // Brazilian Portuguese
68
+ 'pf_dora','pm_alex','pm_santa',
69
+ // Korean
70
+ 'kf_alpha','km_hyunsu',
71
+ ];
72
+
73
+ /**
74
+ * Infer a Kokoro voice's gender from its id. Kokoro ids follow `<lang><sex>_name`
75
+ * where the 2nd character is `f` (female) or `m` (male).
76
+ * @param {string} id
77
+ * @returns {'Female'|'Male'|''}
78
+ */
79
+ export function kokoroGender(id) {
80
+ const c = typeof id === 'string' && id.length > 1 ? id[1].toLowerCase() : '';
81
+ if (c === 'f') return 'Female';
82
+ if (c === 'm') return 'Male';
83
+ return '';
84
+ }
85
+
86
+ /**
87
+ * Return the voice pool for the currently-active provider as `{ id, gender }`
88
+ * entries, suitable for gender-aware auto-assignment.
89
+ *
90
+ * Piper voices are discovered on disk, so the caller injects `scanInstalledVoices`
91
+ * and `getVoiceMeta` (both live in the voices-tab module) to avoid a circular
92
+ * import here.
93
+ *
94
+ * @param {string} provider - active provider id (piper|kokoro|elevenlabs|soprano|…)
95
+ * @param {object} helpers
96
+ * @param {() => string[]} helpers.scanInstalledVoices - lists installed Piper voice ids
97
+ * @param {(id: string) => {gender?: string}} helpers.getVoiceMeta - Piper voice metadata
98
+ * @returns {Array<{id: string, gender: string}>}
99
+ */
100
+ export function voicesForProvider(provider, { scanInstalledVoices, getVoiceMeta } = {}) {
101
+ const p = (provider || 'piper').toLowerCase();
102
+
103
+ if (p === 'kokoro') {
104
+ return KOKORO_VOICE_IDS.map(id => ({ id, gender: kokoroGender(id) }));
105
+ }
106
+
107
+ if (p === 'elevenlabs') {
108
+ return ELEVENLABS_VOICES.map(v => ({ id: v.id, gender: v.gender || '' }));
109
+ }
110
+
111
+ // Single-voice providers (e.g. soprano): one synthetic entry named after the
112
+ // provider — every agent ends up sharing it, which is correct for these.
113
+ if (isSingleVoiceProvider(p)) {
114
+ return [{ id: p, gender: '' }];
115
+ }
116
+
117
+ // Piper (and any disk-discovered provider): read installed models.
118
+ if (typeof scanInstalledVoices === 'function') {
119
+ const metaOf = typeof getVoiceMeta === 'function' ? getVoiceMeta : () => ({});
120
+ return scanInstalledVoices().map(id => ({ id, gender: metaOf(id).gender || '' }));
121
+ }
122
+
123
+ return [];
124
+ }
125
+
126
+ export default { ELEVENLABS_VOICES, KOKORO_VOICE_IDS, kokoroGender, voicesForProvider };
@@ -8,6 +8,10 @@
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import path from 'node:path';
10
10
  import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const _hooksDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '.claude', 'hooks');
11
15
 
12
16
  const TTS_ENGINES = [
13
17
  {
@@ -16,10 +20,29 @@ const TTS_ENGINES = [
16
20
  ? 'winget install --id Rhasspy.Piper --accept-package-agreements --accept-source-agreements'
17
21
  : process.platform === 'darwin'
18
22
  ? 'brew install piper'
19
- : 'pip install piper-tts',
23
+ : `${path.join(_hooksDir, 'piper-installer.sh')} --non-interactive`,
24
+ // Structured form — robust against spaces in paths (do NOT split installCmd on ' ').
25
+ installSpec: process.platform === 'win32'
26
+ ? { cmd: 'winget', args: ['install', '--id', 'Rhasspy.Piper', '--accept-package-agreements', '--accept-source-agreements'] }
27
+ : process.platform === 'darwin'
28
+ ? { cmd: 'brew', args: ['install', 'piper'] }
29
+ : { cmd: path.join(_hooksDir, 'piper-installer.sh'), args: ['--non-interactive'] },
30
+ },
31
+ {
32
+ id: 'kokoro', name: 'Kokoro TTS', desc: 'Local neural TTS — 60+ voices, 8 languages, no API key', native: false,
33
+ installCmd: `${path.join(_hooksDir, 'kokoro-installer.sh')} --non-interactive`,
34
+ // Structured form — robust against spaces in paths (do NOT split installCmd on ' ').
35
+ installSpec: { cmd: path.join(_hooksDir, 'kokoro-installer.sh'), args: ['--non-interactive'] },
36
+ requiresApiKey: false,
20
37
  },
21
38
  {
22
- id: 'soprano', name: 'Soprano TTS', desc: 'Web-based TTS service with premium voices', native: false,
39
+ id: 'elevenlabs', name: 'ElevenLabs', desc: 'Premium cloud voices free tier available, 32+ languages', native: false,
40
+ installCmd: `echo "Set ELEVENLABS_API_KEY in your shell profile. Free key at elevenlabs.io"`,
41
+ requiresApiKey: true,
42
+ apiKeyEnvVar: 'ELEVENLABS_API_KEY',
43
+ },
44
+ {
45
+ id: 'soprano', name: 'Soprano TTS', desc: 'Local neural TTS via Gradio WebUI', native: false,
23
46
  installCmd: 'pip install soprano-tts',
24
47
  },
25
48
  { id: 'sapi', name: 'Windows SAPI', desc: 'Built-in Windows speech — no install needed', native: true, platform: 'win32' },
@@ -35,6 +58,32 @@ export function checkEngineInstalled(engineId) {
35
58
  if (!engine) return false;
36
59
  if (engine.native) return true; // Native engines are always available on their platform
37
60
 
61
+ // ElevenLabs: check API key in env or key file
62
+ if (engineId === 'elevenlabs') {
63
+ if (process.env.ELEVENLABS_API_KEY?.trim()) return true;
64
+ try {
65
+ const keyFile = path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt');
66
+ return fs.existsSync(keyFile) && fs.readFileSync(keyFile, 'utf8').trim().length > 0;
67
+ } catch { return false; }
68
+ }
69
+
70
+ // Kokoro: use find_spec (no import) to avoid the slow torch load that causes ETIMEDOUT.
71
+ // Try each platform-appropriate python command (Windows lacks `python3`).
72
+ if (engineId === 'kokoro') {
73
+ const pythonCommands = process.platform === 'win32'
74
+ ? ['py', 'python', 'python3']
75
+ : ['python3', 'python'];
76
+ for (const pythonCmd of pythonCommands) {
77
+ try {
78
+ execFileSync(pythonCmd, ['-c', "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec('kokoro') else 1)"], { stdio: 'ignore', timeout: 3000 }); // NOSONAR
79
+ return true;
80
+ } catch {
81
+ // try next python command
82
+ }
83
+ }
84
+ return false;
85
+ }
86
+
38
87
  // Check binary availability — soprano has two possible binaries
39
88
  const binaryMap = { piper: ['piper'], soprano: ['soprano-tts', 'soprano-webui'] };
40
89
  const binaries = binaryMap[engineId];
@@ -49,10 +49,15 @@ export const DURATION_LIMITS = {
49
49
  */
50
50
  export async function getAudioDuration(filePath) {
51
51
  return new Promise((resolve) => {
52
+ // Resolve exactly once: an 'error' event, a 'close' event, and a timeout can
53
+ // all race, and a double-resolve would mask the first (correct) result.
54
+ let settled = false;
55
+ const done = (result) => { if (!settled) { settled = true; resolve(result); } };
56
+
52
57
  try {
53
58
  // Verify file exists
54
59
  if (!fs.existsSync(filePath)) {
55
- resolve({
60
+ done({
56
61
  success: false,
57
62
  duration: null,
58
63
  error: 'File does not exist'
@@ -62,13 +67,35 @@ export async function getAudioDuration(filePath) {
62
67
 
63
68
  // Check if ffprobe is available
64
69
  const checkFFprobe = spawn('which', ['ffprobe']); // NOSONAR
65
- let ffprobeExists = false;
70
+
71
+ // `which` does not exist on Windows, so this spawn can fail outright.
72
+ // Without an 'error' listener Node re-throws as an unhandled error and
73
+ // crashes the caller; handle it and degrade gracefully instead.
74
+ const checkTimer = setTimeout(() => {
75
+ try { checkFFprobe.kill(); } catch { /* already exited */ }
76
+ done({
77
+ success: false,
78
+ duration: null,
79
+ error: 'ffprobe availability check timed out'
80
+ });
81
+ }, 5000);
82
+ checkTimer.unref?.();
83
+
84
+ checkFFprobe.on('error', (err) => {
85
+ clearTimeout(checkTimer);
86
+ done({
87
+ success: false,
88
+ duration: null,
89
+ error: `ffprobe not found: ${err.message}. Please install ffmpeg.`
90
+ });
91
+ });
66
92
 
67
93
  checkFFprobe.on('close', (code) => {
68
- ffprobeExists = (code === 0);
94
+ clearTimeout(checkTimer);
95
+ if (settled) return;
69
96
 
70
- if (!ffprobeExists) {
71
- resolve({
97
+ if (code !== 0) {
98
+ done({
72
99
  success: false,
73
100
  duration: null,
74
101
  error: 'ffprobe not found. Please install ffmpeg: sudo apt install ffmpeg (Linux) or brew install ffmpeg (macOS)'
@@ -85,6 +112,18 @@ export async function getAudioDuration(filePath) {
85
112
  filePath
86
113
  ]);
87
114
 
115
+ // A malformed or locked file can make ffprobe hang; cap it so the promise
116
+ // always settles instead of leaving the caller awaiting forever.
117
+ const ffTimer = setTimeout(() => {
118
+ try { ffprobe.kill(); } catch { /* already exited */ }
119
+ done({
120
+ success: false,
121
+ duration: null,
122
+ error: 'ffprobe timed out reading the file'
123
+ });
124
+ }, 10000);
125
+ ffTimer.unref?.();
126
+
88
127
  let stdoutChunks = [];
89
128
  let stderrChunks = [];
90
129
 
@@ -97,11 +136,14 @@ export async function getAudioDuration(filePath) {
97
136
  });
98
137
 
99
138
  ffprobe.on('close', (code) => {
139
+ clearTimeout(ffTimer);
140
+ if (settled) return;
141
+
100
142
  const stdout = Buffer.concat(stdoutChunks).toString();
101
143
  const stderr = Buffer.concat(stderrChunks).toString();
102
144
 
103
145
  if (code !== 0) {
104
- resolve({
146
+ done({
105
147
  success: false,
106
148
  duration: null,
107
149
  error: `ffprobe exited with code ${code}: ${stderr || 'Unknown error'}`
@@ -112,7 +154,7 @@ export async function getAudioDuration(filePath) {
112
154
  const duration = parseFloat(stdout.trim());
113
155
 
114
156
  if (isNaN(duration) || duration <= 0) {
115
- resolve({
157
+ done({
116
158
  success: false,
117
159
  duration: null,
118
160
  error: 'Invalid duration value from ffprobe'
@@ -120,7 +162,7 @@ export async function getAudioDuration(filePath) {
120
162
  return;
121
163
  }
122
164
 
123
- resolve({
165
+ done({
124
166
  success: true,
125
167
  duration,
126
168
  error: null
@@ -128,7 +170,8 @@ export async function getAudioDuration(filePath) {
128
170
  });
129
171
 
130
172
  ffprobe.on('error', (err) => {
131
- resolve({
173
+ clearTimeout(ffTimer);
174
+ done({
132
175
  success: false,
133
176
  duration: null,
134
177
  error: `Failed to spawn ffprobe: ${err.message}. Please install ffmpeg.`
@@ -137,7 +180,7 @@ export async function getAudioDuration(filePath) {
137
180
  });
138
181
 
139
182
  } catch (err) {
140
- resolve({
183
+ done({
141
184
  success: false,
142
185
  duration: null,
143
186
  error: `Unexpected error: ${err.message}`