agentvibes 4.2.0 → 4.4.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.
Files changed (219) hide show
  1. package/.agentvibes/bmad/bmad-voices.md +69 -69
  2. package/.agentvibes/config.json +12 -0
  3. package/.claude/activation-instructions +54 -54
  4. package/.claude/audio/tracks/README.md +52 -52
  5. package/.claude/commands/agent-vibes/add.md +21 -21
  6. package/.claude/commands/agent-vibes/agent-vibes.md +101 -101
  7. package/.claude/commands/agent-vibes/agent.md +79 -79
  8. package/.claude/commands/agent-vibes/background-music.md +111 -111
  9. package/.claude/commands/agent-vibes/bmad.md +198 -198
  10. package/.claude/commands/agent-vibes/clean.md +18 -18
  11. package/.claude/commands/agent-vibes/cleanup.md +18 -18
  12. package/.claude/commands/agent-vibes/commands.json +145 -145
  13. package/.claude/commands/agent-vibes/effects.md +97 -97
  14. package/.claude/commands/agent-vibes/get.md +9 -9
  15. package/.claude/commands/agent-vibes/hide.md +91 -91
  16. package/.claude/commands/agent-vibes/language.md +23 -23
  17. package/.claude/commands/agent-vibes/learn.md +67 -67
  18. package/.claude/commands/agent-vibes/list.md +13 -13
  19. package/.claude/commands/agent-vibes/mute.md +37 -37
  20. package/.claude/commands/agent-vibes/preview.md +17 -17
  21. package/.claude/commands/agent-vibes/provider.md +68 -68
  22. package/.claude/commands/agent-vibes/replay-target.md +14 -14
  23. package/.claude/commands/agent-vibes/sample.md +12 -12
  24. package/.claude/commands/agent-vibes/set-favorite-voice.md +84 -84
  25. package/.claude/commands/agent-vibes/set-pretext.md +65 -65
  26. package/.claude/commands/agent-vibes/set-speed.md +41 -41
  27. package/.claude/commands/agent-vibes/show.md +84 -84
  28. package/.claude/commands/agent-vibes/switch.md +87 -87
  29. package/.claude/commands/agent-vibes/target-voice.md +26 -26
  30. package/.claude/commands/agent-vibes/target.md +30 -30
  31. package/.claude/commands/agent-vibes/translate.md +68 -68
  32. package/.claude/commands/agent-vibes/unmute.md +45 -45
  33. package/.claude/commands/agent-vibes/verbosity.md +89 -89
  34. package/.claude/commands/agent-vibes/whoami.md +7 -7
  35. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  36. package/.claude/commands/agent-vibes-rdp.md +24 -24
  37. package/.claude/config/agentvibes.json +1 -0
  38. package/.claude/config/audio-effects.cfg +2 -2
  39. package/.claude/config/audio-effects.cfg.sample +52 -52
  40. package/.claude/config/background-music-volume.txt +1 -0
  41. package/.claude/config/intro-text.txt +1 -0
  42. package/.claude/config/piper-speech-rate.txt +4 -0
  43. package/.claude/config/piper-target-speech-rate.txt +1 -0
  44. package/.claude/config/reverb-level.txt +1 -0
  45. package/.claude/config/tts-speech-rate.txt +4 -0
  46. package/.claude/config/tts-target-speech-rate.txt +1 -0
  47. package/.claude/docs/TERMUX_SETUP.md +408 -408
  48. package/.claude/github-star-reminder.txt +1 -1
  49. package/.claude/hooks/README-TTS-QUEUE.md +135 -135
  50. package/.claude/hooks/audio-cache-utils.sh +246 -246
  51. package/.claude/hooks/audio-processor.sh +433 -433
  52. package/.claude/hooks/background-music-manager.sh +404 -404
  53. package/.claude/hooks/bmad-speak-enhanced.sh +165 -165
  54. package/.claude/hooks/bmad-speak.sh +269 -269
  55. package/.claude/hooks/bmad-tts-injector.sh +568 -568
  56. package/.claude/hooks/bmad-voice-manager.sh +928 -928
  57. package/.claude/hooks/clawdbot-receiver-SECURE.sh +129 -129
  58. package/.claude/hooks/clawdbot-receiver.sh +107 -107
  59. package/.claude/hooks/clean-audio-cache.sh +22 -22
  60. package/.claude/hooks/cleanup-cache.sh +106 -106
  61. package/.claude/hooks/configure-rdp-mode.sh +137 -137
  62. package/.claude/hooks/download-extra-voices.sh +244 -244
  63. package/.claude/hooks/effects-manager.sh +268 -268
  64. package/.claude/hooks/github-star-reminder.sh +154 -154
  65. package/.claude/hooks/language-manager.sh +362 -362
  66. package/.claude/hooks/learn-manager.sh +492 -492
  67. package/.claude/hooks/macos-voice-manager.sh +205 -205
  68. package/.claude/hooks/migrate-background-music.sh +125 -125
  69. package/.claude/hooks/migrate-to-agentvibes.sh +161 -161
  70. package/.claude/hooks/optimize-background-music.sh +87 -87
  71. package/.claude/hooks/path-resolver.sh +60 -60
  72. package/.claude/hooks/personality-manager.sh +448 -448
  73. package/.claude/hooks/piper-download-voices.sh +225 -225
  74. package/.claude/hooks/piper-installer.sh +292 -292
  75. package/.claude/hooks/piper-multispeaker-registry.sh +171 -171
  76. package/.claude/hooks/piper-voice-manager.sh +24 -3
  77. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +90 -90
  78. package/.claude/hooks/play-tts-enhanced.sh +105 -105
  79. package/.claude/hooks/play-tts-macos.sh +368 -368
  80. package/.claude/hooks/play-tts-piper.sh +679 -679
  81. package/.claude/hooks/play-tts-soprano.sh +356 -356
  82. package/.claude/hooks/play-tts-ssh-remote.sh +167 -167
  83. package/.claude/hooks/play-tts-termux-ssh.sh +169 -169
  84. package/.claude/hooks/play-tts.sh +301 -301
  85. package/.claude/hooks/prepare-release.sh +54 -54
  86. package/.claude/hooks/provider-commands.sh +617 -617
  87. package/.claude/hooks/provider-manager.sh +399 -399
  88. package/.claude/hooks/replay-target-audio.sh +95 -95
  89. package/.claude/hooks/requirements.txt +6 -6
  90. package/.claude/hooks/sentiment-manager.sh +201 -201
  91. package/.claude/hooks/session-start-tts.sh +81 -81
  92. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  93. package/.claude/hooks/speed-manager.sh +291 -291
  94. package/.claude/hooks/stop-tts.sh +84 -84
  95. package/.claude/hooks/termux-installer.sh +261 -261
  96. package/.claude/hooks/translate-manager.sh +341 -341
  97. package/.claude/hooks/translator.py +237 -237
  98. package/.claude/hooks/tts-queue-worker.sh +145 -145
  99. package/.claude/hooks/tts-queue.sh +165 -165
  100. package/.claude/hooks/verbosity-manager.sh +178 -178
  101. package/.claude/hooks/voice-manager.sh +548 -548
  102. package/.claude/hooks-windows/audio-cache-utils.ps1 +119 -119
  103. package/.claude/hooks-windows/background-music-manager.ps1 +348 -0
  104. package/.claude/hooks-windows/clean-audio-cache.ps1 +53 -0
  105. package/.claude/hooks-windows/download-extra-voices.ps1 +185 -0
  106. package/.claude/hooks-windows/effects-manager.ps1 +294 -0
  107. package/.claude/hooks-windows/language-manager.ps1 +193 -0
  108. package/.claude/hooks-windows/learn-manager.ps1 +241 -0
  109. package/.claude/hooks-windows/personality-manager.ps1 +266 -0
  110. package/.claude/hooks-windows/play-tts-piper.ps1 +209 -0
  111. package/.claude/hooks-windows/play-tts-sapi.ps1 +108 -0
  112. package/.claude/hooks-windows/play-tts-soprano.ps1 +159 -158
  113. package/.claude/hooks-windows/play-tts-windows-piper.ps1 +50 -5
  114. package/.claude/hooks-windows/play-tts-windows-sapi.ps1 +108 -108
  115. package/.claude/hooks-windows/play-tts.ps1 +344 -266
  116. package/.claude/hooks-windows/provider-manager.ps1 +29 -10
  117. package/.claude/hooks-windows/session-start-tts.ps1 +124 -124
  118. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  119. package/.claude/hooks-windows/speed-manager.ps1 +166 -0
  120. package/.claude/hooks-windows/verbosity-manager.ps1 +119 -0
  121. package/.claude/hooks-windows/voice-manager-windows.ps1 +92 -8
  122. package/.claude/output-styles/agent-vibes.md +202 -202
  123. package/.claude/personalities/angry.md +14 -14
  124. package/.claude/personalities/annoying.md +14 -14
  125. package/.claude/personalities/crass.md +14 -14
  126. package/.claude/personalities/dramatic.md +14 -14
  127. package/.claude/personalities/dry-humor.md +50 -50
  128. package/.claude/personalities/flirty.md +20 -20
  129. package/.claude/personalities/funny.md +14 -14
  130. package/.claude/personalities/grandpa.md +32 -32
  131. package/.claude/personalities/millennial.md +14 -14
  132. package/.claude/personalities/moody.md +14 -14
  133. package/.claude/personalities/normal.md +16 -16
  134. package/.claude/personalities/pirate.md +14 -14
  135. package/.claude/personalities/poetic.md +14 -14
  136. package/.claude/personalities/professional.md +14 -14
  137. package/.claude/personalities/rapper.md +55 -55
  138. package/.claude/personalities/robot.md +14 -14
  139. package/.claude/personalities/sarcastic.md +38 -38
  140. package/.claude/personalities/sassy.md +14 -14
  141. package/.claude/personalities/surfer-dude.md +14 -14
  142. package/.claude/personalities/zen.md +14 -14
  143. package/.claude/settings.json +15 -15
  144. package/.claude/verbosity.txt +1 -1
  145. package/.clawdbot/README.md +105 -105
  146. package/.clawdbot/skill/SKILL.md +241 -241
  147. package/.mcp.json +12 -0
  148. package/CLAUDE.md +170 -170
  149. package/README.md +2029 -2007
  150. package/RELEASE_NOTES.md +1310 -1203
  151. package/WINDOWS-SETUP.md +208 -208
  152. package/bin/agent-vibes +39 -39
  153. package/bin/agentvibes-voice-browser.js +1840 -1840
  154. package/bin/agentvibes.js +48 -2
  155. package/bin/mcp-server.js +121 -121
  156. package/bin/mcp-server.sh +206 -206
  157. package/bin/test-bmad-pr +78 -78
  158. package/mcp-server/QUICK_START.md +203 -203
  159. package/mcp-server/README.md +345 -345
  160. package/mcp-server/WINDOWS_SETUP.md +260 -260
  161. package/mcp-server/docs/troubleshooting-audio.md +313 -313
  162. package/mcp-server/examples/claude_desktop_config.json +11 -11
  163. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  164. package/mcp-server/examples/custom_instructions.md +169 -169
  165. package/mcp-server/install-deps.js +130 -130
  166. package/mcp-server/pyproject.toml +52 -52
  167. package/mcp-server/requirements.txt +2 -2
  168. package/mcp-server/server.py +1465 -1453
  169. package/mcp-server/test_server.py +395 -395
  170. package/mcp-server/test_windows_script_parity.py +336 -0
  171. package/package.json +110 -110
  172. package/setup-windows.ps1 +815 -815
  173. package/src/bmad-detector.js +71 -71
  174. package/src/cli/list-personalities.js +110 -110
  175. package/src/cli/list-voices.js +114 -114
  176. package/src/commands/bmad-voices.js +394 -394
  177. package/src/commands/install-mcp.js +476 -476
  178. package/src/console/app.js +824 -824
  179. package/src/console/audio-env.js +20 -1
  180. package/src/console/brand-colors.js +13 -13
  181. package/src/console/constants/personalities.js +44 -44
  182. package/src/console/footer-config.js +50 -50
  183. package/src/console/modals/modal-overlay.js +247 -247
  184. package/src/console/navigation.js +62 -62
  185. package/src/console/tabs/agents-tab.js +1684 -1516
  186. package/src/console/tabs/help-tab.js +261 -261
  187. package/src/console/tabs/install-tab.js +1007 -991
  188. package/src/console/tabs/music-tab.js +22 -8
  189. package/src/console/tabs/placeholder-tab.js +53 -53
  190. package/src/console/tabs/readme-tab.js +267 -267
  191. package/src/console/tabs/receiver-tab.js +1472 -1212
  192. package/src/console/tabs/settings-tab.js +208 -84
  193. package/src/console/tabs/voices-tab.js +100 -21
  194. package/src/console/widgets/destroy-list.js +25 -25
  195. package/src/console/widgets/format-utils.js +89 -89
  196. package/src/console/widgets/notice.js +55 -55
  197. package/src/console/widgets/personality-picker.js +185 -185
  198. package/src/console/widgets/reverb-picker.js +94 -94
  199. package/src/console/widgets/track-picker.js +285 -285
  200. package/src/installer/music-file-input.js +304 -304
  201. package/src/installer.js +5895 -5829
  202. package/src/services/agent-voice-store.js +423 -423
  203. package/src/services/config-service.js +264 -264
  204. package/src/services/navigation-service.js +123 -123
  205. package/src/services/provider-service.js +143 -132
  206. package/src/services/verbosity-service.js +157 -157
  207. package/src/utils/audio-duration-validator.js +298 -298
  208. package/src/utils/audio-format-validator.js +277 -277
  209. package/src/utils/dependency-checker.js +469 -466
  210. package/src/utils/file-ownership-verifier.js +358 -358
  211. package/src/utils/list-formatter.js +194 -194
  212. package/src/utils/music-file-validator.js +285 -285
  213. package/src/utils/preview-list-prompt.js +136 -136
  214. package/src/utils/provider-validator.js +96 -12
  215. package/src/utils/secure-music-storage.js +412 -412
  216. package/templates/agentvibes-receiver.sh +482 -482
  217. package/templates/audio/welcome-music.mp3 +0 -0
  218. package/voice-assignments.json +8244 -8244
  219. package/.claude/config/background-music-position.txt +0 -1
@@ -1,298 +1,298 @@
1
- /**
2
- * Audio Duration Validator - Validate audio file length for background music
3
- * Story 4.7: TTS Integration - Background Music Mixing
4
- *
5
- * Validates that custom background music files have appropriate duration:
6
- * - Recommended: 30-90 seconds (ideal for looping without repetition fatigue)
7
- * - Warning: < 30 seconds (may sound repetitive)
8
- * - Warning: > 120 seconds (may use excess memory, slow processing)
9
- *
10
- * @module audio-duration-validator
11
- * @requires child_process
12
- * @requires fs
13
- */
14
-
15
- import { spawn } from 'node:child_process';
16
- import fs from 'node:fs';
17
-
18
- /**
19
- * Recommended duration limits for background music
20
- *
21
- * Rationale:
22
- * - MIN_RECOMMENDED (30s): Research shows loops under 30s cause listener fatigue and annoyance
23
- * - MAX_RECOMMENDED (90s): Optimal range for music loops - long enough for variety, short enough to prevent memory issues
24
- * - MAX_WARNING (120s): Above 2 minutes, ffmpeg stream_loop becomes less efficient (memory overhead)
25
- * - MAX_ALLOWED (300s): Hard limit at 5 minutes to prevent excessive memory usage during TTS processing
26
- *
27
- * Source: Based on UX best practices for background audio in voice assistants and
28
- * technical constraints of ffmpeg stream looping with -stream_loop -1 flag
29
- */
30
- export const DURATION_LIMITS = {
31
- MIN_RECOMMENDED: 30, // 30 seconds - below this gets repetitive
32
- MAX_RECOMMENDED: 90, // 90 seconds - sweet spot for variety
33
- MAX_WARNING: 120, // 2 minutes - above this warn user
34
- MAX_ALLOWED: 300 // 5 minutes - hard limit to prevent issues
35
- };
36
-
37
- /**
38
- * Get audio file duration using ffprobe
39
- *
40
- * @param {string} filePath - Path to audio file
41
- * @returns {Promise<Object>} Result object:
42
- * - success: boolean
43
- * - duration: number|null - Duration in seconds
44
- * - error: string|null - Error message if failed
45
- *
46
- * @example
47
- * const result = await getAudioDuration('/path/to/music.mp3');
48
- * // => { success: true, duration: 45.5, error: null }
49
- */
50
- export async function getAudioDuration(filePath) {
51
- return new Promise((resolve) => {
52
- try {
53
- // Verify file exists
54
- if (!fs.existsSync(filePath)) {
55
- resolve({
56
- success: false,
57
- duration: null,
58
- error: 'File does not exist'
59
- });
60
- return;
61
- }
62
-
63
- // Check if ffprobe is available
64
- const checkFFprobe = spawn('which', ['ffprobe']);
65
- let ffprobeExists = false;
66
-
67
- checkFFprobe.on('close', (code) => {
68
- ffprobeExists = (code === 0);
69
-
70
- if (!ffprobeExists) {
71
- resolve({
72
- success: false,
73
- duration: null,
74
- error: 'ffprobe not found. Please install ffmpeg: sudo apt install ffmpeg (Linux) or brew install ffmpeg (macOS)'
75
- });
76
- return;
77
- }
78
-
79
- // Use ffprobe to get duration (part of ffmpeg)
80
- // SECURITY: Use spawn with args array to prevent command injection
81
- const ffprobe = spawn('ffprobe', [
82
- '-v', 'error',
83
- '-show_entries', 'format=duration',
84
- '-of', 'default=noprint_wrappers=1:nokey=1',
85
- filePath
86
- ]);
87
-
88
- let stdoutChunks = [];
89
- let stderrChunks = [];
90
-
91
- ffprobe.stdout.on('data', (data) => {
92
- stdoutChunks.push(data);
93
- });
94
-
95
- ffprobe.stderr.on('data', (data) => {
96
- stderrChunks.push(data);
97
- });
98
-
99
- ffprobe.on('close', (code) => {
100
- const stdout = Buffer.concat(stdoutChunks).toString();
101
- const stderr = Buffer.concat(stderrChunks).toString();
102
-
103
- if (code !== 0) {
104
- resolve({
105
- success: false,
106
- duration: null,
107
- error: `ffprobe exited with code ${code}: ${stderr || 'Unknown error'}`
108
- });
109
- return;
110
- }
111
-
112
- const duration = parseFloat(stdout.trim());
113
-
114
- if (isNaN(duration) || duration <= 0) {
115
- resolve({
116
- success: false,
117
- duration: null,
118
- error: 'Invalid duration value from ffprobe'
119
- });
120
- return;
121
- }
122
-
123
- resolve({
124
- success: true,
125
- duration,
126
- error: null
127
- });
128
- });
129
-
130
- ffprobe.on('error', (err) => {
131
- resolve({
132
- success: false,
133
- duration: null,
134
- error: `Failed to spawn ffprobe: ${err.message}. Please install ffmpeg.`
135
- });
136
- });
137
- });
138
-
139
- } catch (err) {
140
- resolve({
141
- success: false,
142
- duration: null,
143
- error: `Unexpected error: ${err.message}`
144
- });
145
- }
146
- });
147
- }
148
-
149
- /**
150
- * Validate audio duration against recommended limits
151
- *
152
- * Story 4.7 AC-11: Validate audio length and advise user
153
- *
154
- * @param {number} duration - Duration in seconds
155
- * @returns {Object} Validation result:
156
- * - isValid: boolean - True if within acceptable limits
157
- * - level: string - 'ok' | 'warning' | 'error'
158
- * - message: string - User-friendly message
159
- * - recommendation: string|null - Recommendation for user
160
- *
161
- * @example
162
- * const validation = validateDuration(45);
163
- * // => { isValid: true, level: 'ok', message: 'Duration is ideal...', recommendation: null }
164
- */
165
- export function validateDuration(duration) {
166
- if (typeof duration !== 'number' || isNaN(duration) || duration <= 0) {
167
- return {
168
- isValid: false,
169
- level: 'error',
170
- message: 'Invalid duration value',
171
- recommendation: null
172
- };
173
- }
174
-
175
- // Hard limit exceeded
176
- if (duration > DURATION_LIMITS.MAX_ALLOWED) {
177
- return {
178
- isValid: false,
179
- level: 'error',
180
- message: `Audio is too long (${formatDuration(duration)}). Maximum allowed: ${DURATION_LIMITS.MAX_ALLOWED} seconds (${formatDuration(DURATION_LIMITS.MAX_ALLOWED)})`,
181
- recommendation: 'Please use a shorter audio file or trim this one to under 5 minutes. Long background music can cause memory issues and slow TTS processing.'
182
- };
183
- }
184
-
185
- // Warning: too long but acceptable
186
- if (duration > DURATION_LIMITS.MAX_WARNING) {
187
- return {
188
- isValid: true,
189
- level: 'warning',
190
- message: `Audio is quite long (${formatDuration(duration)})`,
191
- recommendation: `Consider using a shorter track (30-90 seconds recommended). Longer tracks use more memory and may slow down TTS processing.`
192
- };
193
- }
194
-
195
- // Ideal range
196
- if (duration >= DURATION_LIMITS.MIN_RECOMMENDED && duration <= DURATION_LIMITS.MAX_RECOMMENDED) {
197
- return {
198
- isValid: true,
199
- level: 'ok',
200
- message: `Duration is ideal (${formatDuration(duration)})`,
201
- recommendation: null
202
- };
203
- }
204
-
205
- // Warning: too short but acceptable
206
- if (duration < DURATION_LIMITS.MIN_RECOMMENDED) {
207
- return {
208
- isValid: true,
209
- level: 'warning',
210
- message: `Audio is short (${formatDuration(duration)})`,
211
- recommendation: `Consider using a longer track (30-90 seconds recommended). Short tracks will loop frequently and may sound repetitive during longer TTS messages.`
212
- };
213
- }
214
-
215
- // Between recommended max and warning limit - acceptable
216
- return {
217
- isValid: true,
218
- level: 'ok',
219
- message: `Duration is acceptable (${formatDuration(duration)})`,
220
- recommendation: null
221
- };
222
- }
223
-
224
- /**
225
- * Format duration in seconds to human-readable string
226
- *
227
- * @param {number} seconds - Duration in seconds
228
- * @returns {string} Formatted duration (e.g., "1:23", "2:45", "45s")
229
- *
230
- * @example
231
- * formatDuration(45) // => "45s"
232
- * formatDuration(90) // => "1:30"
233
- * formatDuration(195) // => "3:15"
234
- */
235
- export function formatDuration(seconds) {
236
- if (seconds < 60) {
237
- return `${Math.round(seconds)}s`;
238
- }
239
-
240
- const minutes = Math.floor(seconds / 60);
241
- const remainingSeconds = Math.round(seconds % 60);
242
- return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
243
- }
244
-
245
- /**
246
- * Get duration validation for audio file (convenience function)
247
- *
248
- * Combines getAudioDuration() and validateDuration() for one-step validation
249
- *
250
- * @param {string} filePath - Path to audio file
251
- * @returns {Promise<Object>} Combined result:
252
- * - success: boolean
253
- * - duration: number|null
254
- * - isValid: boolean
255
- * - level: string
256
- * - message: string
257
- * - recommendation: string|null
258
- * - error: string|null
259
- *
260
- * @example
261
- * const result = await validateAudioDuration('/path/to/music.mp3');
262
- * if (result.success && result.level === 'warning') {
263
- * console.log(result.message);
264
- * console.log(result.recommendation);
265
- * }
266
- */
267
- export async function validateAudioDuration(filePath) {
268
- const durationResult = await getAudioDuration(filePath);
269
-
270
- if (!durationResult.success) {
271
- return {
272
- success: false,
273
- duration: null,
274
- isValid: false,
275
- level: 'error',
276
- message: durationResult.error,
277
- recommendation: null,
278
- error: durationResult.error
279
- };
280
- }
281
-
282
- const validation = validateDuration(durationResult.duration);
283
-
284
- return {
285
- success: true,
286
- duration: durationResult.duration,
287
- ...validation,
288
- error: null
289
- };
290
- }
291
-
292
- export default {
293
- getAudioDuration,
294
- validateDuration,
295
- validateAudioDuration,
296
- formatDuration,
297
- DURATION_LIMITS
298
- };
1
+ /**
2
+ * Audio Duration Validator - Validate audio file length for background music
3
+ * Story 4.7: TTS Integration - Background Music Mixing
4
+ *
5
+ * Validates that custom background music files have appropriate duration:
6
+ * - Recommended: 30-90 seconds (ideal for looping without repetition fatigue)
7
+ * - Warning: < 30 seconds (may sound repetitive)
8
+ * - Warning: > 120 seconds (may use excess memory, slow processing)
9
+ *
10
+ * @module audio-duration-validator
11
+ * @requires child_process
12
+ * @requires fs
13
+ */
14
+
15
+ import { spawn } from 'node:child_process';
16
+ import fs from 'node:fs';
17
+
18
+ /**
19
+ * Recommended duration limits for background music
20
+ *
21
+ * Rationale:
22
+ * - MIN_RECOMMENDED (30s): Research shows loops under 30s cause listener fatigue and annoyance
23
+ * - MAX_RECOMMENDED (90s): Optimal range for music loops - long enough for variety, short enough to prevent memory issues
24
+ * - MAX_WARNING (120s): Above 2 minutes, ffmpeg stream_loop becomes less efficient (memory overhead)
25
+ * - MAX_ALLOWED (300s): Hard limit at 5 minutes to prevent excessive memory usage during TTS processing
26
+ *
27
+ * Source: Based on UX best practices for background audio in voice assistants and
28
+ * technical constraints of ffmpeg stream looping with -stream_loop -1 flag
29
+ */
30
+ export const DURATION_LIMITS = {
31
+ MIN_RECOMMENDED: 30, // 30 seconds - below this gets repetitive
32
+ MAX_RECOMMENDED: 90, // 90 seconds - sweet spot for variety
33
+ MAX_WARNING: 120, // 2 minutes - above this warn user
34
+ MAX_ALLOWED: 300 // 5 minutes - hard limit to prevent issues
35
+ };
36
+
37
+ /**
38
+ * Get audio file duration using ffprobe
39
+ *
40
+ * @param {string} filePath - Path to audio file
41
+ * @returns {Promise<Object>} Result object:
42
+ * - success: boolean
43
+ * - duration: number|null - Duration in seconds
44
+ * - error: string|null - Error message if failed
45
+ *
46
+ * @example
47
+ * const result = await getAudioDuration('/path/to/music.mp3');
48
+ * // => { success: true, duration: 45.5, error: null }
49
+ */
50
+ export async function getAudioDuration(filePath) {
51
+ return new Promise((resolve) => {
52
+ try {
53
+ // Verify file exists
54
+ if (!fs.existsSync(filePath)) {
55
+ resolve({
56
+ success: false,
57
+ duration: null,
58
+ error: 'File does not exist'
59
+ });
60
+ return;
61
+ }
62
+
63
+ // Check if ffprobe is available
64
+ const checkFFprobe = spawn('which', ['ffprobe']);
65
+ let ffprobeExists = false;
66
+
67
+ checkFFprobe.on('close', (code) => {
68
+ ffprobeExists = (code === 0);
69
+
70
+ if (!ffprobeExists) {
71
+ resolve({
72
+ success: false,
73
+ duration: null,
74
+ error: 'ffprobe not found. Please install ffmpeg: sudo apt install ffmpeg (Linux) or brew install ffmpeg (macOS)'
75
+ });
76
+ return;
77
+ }
78
+
79
+ // Use ffprobe to get duration (part of ffmpeg)
80
+ // SECURITY: Use spawn with args array to prevent command injection
81
+ const ffprobe = spawn('ffprobe', [
82
+ '-v', 'error',
83
+ '-show_entries', 'format=duration',
84
+ '-of', 'default=noprint_wrappers=1:nokey=1',
85
+ filePath
86
+ ]);
87
+
88
+ let stdoutChunks = [];
89
+ let stderrChunks = [];
90
+
91
+ ffprobe.stdout.on('data', (data) => {
92
+ stdoutChunks.push(data);
93
+ });
94
+
95
+ ffprobe.stderr.on('data', (data) => {
96
+ stderrChunks.push(data);
97
+ });
98
+
99
+ ffprobe.on('close', (code) => {
100
+ const stdout = Buffer.concat(stdoutChunks).toString();
101
+ const stderr = Buffer.concat(stderrChunks).toString();
102
+
103
+ if (code !== 0) {
104
+ resolve({
105
+ success: false,
106
+ duration: null,
107
+ error: `ffprobe exited with code ${code}: ${stderr || 'Unknown error'}`
108
+ });
109
+ return;
110
+ }
111
+
112
+ const duration = parseFloat(stdout.trim());
113
+
114
+ if (isNaN(duration) || duration <= 0) {
115
+ resolve({
116
+ success: false,
117
+ duration: null,
118
+ error: 'Invalid duration value from ffprobe'
119
+ });
120
+ return;
121
+ }
122
+
123
+ resolve({
124
+ success: true,
125
+ duration,
126
+ error: null
127
+ });
128
+ });
129
+
130
+ ffprobe.on('error', (err) => {
131
+ resolve({
132
+ success: false,
133
+ duration: null,
134
+ error: `Failed to spawn ffprobe: ${err.message}. Please install ffmpeg.`
135
+ });
136
+ });
137
+ });
138
+
139
+ } catch (err) {
140
+ resolve({
141
+ success: false,
142
+ duration: null,
143
+ error: `Unexpected error: ${err.message}`
144
+ });
145
+ }
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Validate audio duration against recommended limits
151
+ *
152
+ * Story 4.7 AC-11: Validate audio length and advise user
153
+ *
154
+ * @param {number} duration - Duration in seconds
155
+ * @returns {Object} Validation result:
156
+ * - isValid: boolean - True if within acceptable limits
157
+ * - level: string - 'ok' | 'warning' | 'error'
158
+ * - message: string - User-friendly message
159
+ * - recommendation: string|null - Recommendation for user
160
+ *
161
+ * @example
162
+ * const validation = validateDuration(45);
163
+ * // => { isValid: true, level: 'ok', message: 'Duration is ideal...', recommendation: null }
164
+ */
165
+ export function validateDuration(duration) {
166
+ if (typeof duration !== 'number' || isNaN(duration) || duration <= 0) {
167
+ return {
168
+ isValid: false,
169
+ level: 'error',
170
+ message: 'Invalid duration value',
171
+ recommendation: null
172
+ };
173
+ }
174
+
175
+ // Hard limit exceeded
176
+ if (duration > DURATION_LIMITS.MAX_ALLOWED) {
177
+ return {
178
+ isValid: false,
179
+ level: 'error',
180
+ message: `Audio is too long (${formatDuration(duration)}). Maximum allowed: ${DURATION_LIMITS.MAX_ALLOWED} seconds (${formatDuration(DURATION_LIMITS.MAX_ALLOWED)})`,
181
+ recommendation: 'Please use a shorter audio file or trim this one to under 5 minutes. Long background music can cause memory issues and slow TTS processing.'
182
+ };
183
+ }
184
+
185
+ // Warning: too long but acceptable
186
+ if (duration > DURATION_LIMITS.MAX_WARNING) {
187
+ return {
188
+ isValid: true,
189
+ level: 'warning',
190
+ message: `Audio is quite long (${formatDuration(duration)})`,
191
+ recommendation: `Consider using a shorter track (30-90 seconds recommended). Longer tracks use more memory and may slow down TTS processing.`
192
+ };
193
+ }
194
+
195
+ // Ideal range
196
+ if (duration >= DURATION_LIMITS.MIN_RECOMMENDED && duration <= DURATION_LIMITS.MAX_RECOMMENDED) {
197
+ return {
198
+ isValid: true,
199
+ level: 'ok',
200
+ message: `Duration is ideal (${formatDuration(duration)})`,
201
+ recommendation: null
202
+ };
203
+ }
204
+
205
+ // Warning: too short but acceptable
206
+ if (duration < DURATION_LIMITS.MIN_RECOMMENDED) {
207
+ return {
208
+ isValid: true,
209
+ level: 'warning',
210
+ message: `Audio is short (${formatDuration(duration)})`,
211
+ recommendation: `Consider using a longer track (30-90 seconds recommended). Short tracks will loop frequently and may sound repetitive during longer TTS messages.`
212
+ };
213
+ }
214
+
215
+ // Between recommended max and warning limit - acceptable
216
+ return {
217
+ isValid: true,
218
+ level: 'ok',
219
+ message: `Duration is acceptable (${formatDuration(duration)})`,
220
+ recommendation: null
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Format duration in seconds to human-readable string
226
+ *
227
+ * @param {number} seconds - Duration in seconds
228
+ * @returns {string} Formatted duration (e.g., "1:23", "2:45", "45s")
229
+ *
230
+ * @example
231
+ * formatDuration(45) // => "45s"
232
+ * formatDuration(90) // => "1:30"
233
+ * formatDuration(195) // => "3:15"
234
+ */
235
+ export function formatDuration(seconds) {
236
+ if (seconds < 60) {
237
+ return `${Math.round(seconds)}s`;
238
+ }
239
+
240
+ const minutes = Math.floor(seconds / 60);
241
+ const remainingSeconds = Math.round(seconds % 60);
242
+ return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
243
+ }
244
+
245
+ /**
246
+ * Get duration validation for audio file (convenience function)
247
+ *
248
+ * Combines getAudioDuration() and validateDuration() for one-step validation
249
+ *
250
+ * @param {string} filePath - Path to audio file
251
+ * @returns {Promise<Object>} Combined result:
252
+ * - success: boolean
253
+ * - duration: number|null
254
+ * - isValid: boolean
255
+ * - level: string
256
+ * - message: string
257
+ * - recommendation: string|null
258
+ * - error: string|null
259
+ *
260
+ * @example
261
+ * const result = await validateAudioDuration('/path/to/music.mp3');
262
+ * if (result.success && result.level === 'warning') {
263
+ * console.log(result.message);
264
+ * console.log(result.recommendation);
265
+ * }
266
+ */
267
+ export async function validateAudioDuration(filePath) {
268
+ const durationResult = await getAudioDuration(filePath);
269
+
270
+ if (!durationResult.success) {
271
+ return {
272
+ success: false,
273
+ duration: null,
274
+ isValid: false,
275
+ level: 'error',
276
+ message: durationResult.error,
277
+ recommendation: null,
278
+ error: durationResult.error
279
+ };
280
+ }
281
+
282
+ const validation = validateDuration(durationResult.duration);
283
+
284
+ return {
285
+ success: true,
286
+ duration: durationResult.duration,
287
+ ...validation,
288
+ error: null
289
+ };
290
+ }
291
+
292
+ export default {
293
+ getAudioDuration,
294
+ validateDuration,
295
+ validateAudioDuration,
296
+ formatDuration,
297
+ DURATION_LIMITS
298
+ };