agentvibes 5.6.0 → 5.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.
Files changed (101) hide show
  1. package/.agentvibes/config.json +3 -38
  2. package/.claude/config/audio-effects.cfg +1 -1
  3. package/.claude/config/background-music-enabled.txt +1 -1
  4. package/.claude/config/background-music-position.txt +6 -6
  5. package/.claude/github-star-reminder.txt +1 -1
  6. package/.claude/hooks/play-tts-ssh-remote.sh +119 -42
  7. package/.claude/hooks/play-tts-windows-receiver.sh +31 -0
  8. package/.claude/hooks/stop.sh +2 -27
  9. package/.claude/hooks-windows/play-tts-windows-sapi.ps1 +108 -108
  10. package/.claude/hooks-windows/play-tts.ps1 +58 -8
  11. package/.claude/piper-voices-dir.txt +1 -1
  12. package/.clawdbot/skill/README.md +326 -0
  13. package/.mcp.json +17 -27
  14. package/README.md +15 -2
  15. package/RELEASE_NOTES.md +64 -0
  16. package/bin/agent-vibes +39 -39
  17. package/package.json +1 -1
  18. package/src/bmad-detector.js +71 -71
  19. package/src/cli/list-personalities.js +110 -110
  20. package/src/cli/list-voices.js +114 -114
  21. package/src/commands/bmad-voices.js +394 -394
  22. package/src/commands/install-mcp.js +476 -476
  23. package/src/console/brand-colors.js +13 -13
  24. package/src/console/constants/personalities.js +44 -44
  25. package/src/console/modals/modal-overlay.js +247 -247
  26. package/src/console/navigation.js +5 -1
  27. package/src/console/tabs/agents-tab.js +5 -5
  28. package/src/console/tabs/help-tab.js +314 -314
  29. package/src/console/tabs/readme-tab.js +272 -272
  30. package/src/console/tabs/setup-tab.js +32 -17
  31. package/src/console/tabs/voices-tab.js +2 -2
  32. package/src/console/widgets/destroy-list.js +25 -25
  33. package/src/console/widgets/notice.js +55 -55
  34. package/src/console/widgets/personality-picker.js +213 -213
  35. package/src/console/widgets/reverb-picker.js +97 -97
  36. package/src/console/widgets/track-picker.js +1 -1
  37. package/src/i18n/de.js +202 -202
  38. package/src/i18n/es.js +202 -202
  39. package/src/i18n/fr.js +202 -202
  40. package/src/i18n/hi.js +202 -202
  41. package/src/i18n/ja.js +202 -202
  42. package/src/i18n/ko.js +202 -202
  43. package/src/i18n/pt.js +202 -202
  44. package/src/i18n/strings.js +54 -54
  45. package/src/i18n/zh-CN.js +202 -202
  46. package/src/installer/language-screen.js +31 -31
  47. package/src/installer/music-file-input.js +304 -304
  48. package/src/services/agent-voice-store.js +420 -423
  49. package/src/services/config-service.js +264 -264
  50. package/src/services/language-service.js +47 -47
  51. package/src/services/llm-provider-service.js +11 -4
  52. package/src/services/navigation-service.js +34 -10
  53. package/src/services/provider-service.js +143 -143
  54. package/src/utils/audio-duration-validator.js +298 -298
  55. package/src/utils/audio-format-validator.js +277 -277
  56. package/src/utils/dependency-checker.js +469 -469
  57. package/src/utils/file-ownership-verifier.js +358 -358
  58. package/src/utils/list-formatter.js +194 -194
  59. package/src/utils/music-file-validator.js +285 -285
  60. package/src/utils/preview-list-prompt.js +136 -136
  61. package/src/utils/secure-music-storage.js +412 -412
  62. package/.agentvibes/LITE-MODE.md +0 -236
  63. package/.agentvibes/README.md +0 -136
  64. package/.agentvibes/backup/session-start-tts.sh.20251210_212814 +0 -141
  65. package/.agentvibes/backups/agents/analyst_20260204_144958.md +0 -78
  66. package/.agentvibes/backups/agents/architect_20260204_144958.md +0 -72
  67. package/.agentvibes/backups/agents/dev_20260204_144958.md +0 -74
  68. package/.agentvibes/backups/agents/pm_20260204_144958.md +0 -72
  69. package/.agentvibes/backups/agents/quick-flow-solo-dev_20260204_144958.md +0 -64
  70. package/.agentvibes/backups/agents/sm_20260204_144958.md +0 -87
  71. package/.agentvibes/backups/agents/tea_20260204_144958.md +0 -79
  72. package/.agentvibes/backups/agents/tech-writer_20260204_144958.md +0 -82
  73. package/.agentvibes/backups/agents/ux-designer_20260204_144958.md +0 -80
  74. package/.agentvibes/config/README-personality-defaults.md +0 -162
  75. package/.agentvibes/config/agentvibes.json +0 -1
  76. package/.agentvibes/config/mode.txt +0 -1
  77. package/.agentvibes/config/personality-voice-defaults.default.json +0 -21
  78. package/.agentvibes/config/save-audio.txt +0 -1
  79. package/.agentvibes/config/voice-metadata.json +0 -160
  80. package/.agentvibes/hooks/help.sh +0 -191
  81. package/.agentvibes/hooks/post-tool-use-lite.sh +0 -111
  82. package/.agentvibes/hooks/save-audio-manager.sh +0 -162
  83. package/.agentvibes/hooks/session-start-full-optimized.sh +0 -102
  84. package/.agentvibes/hooks/session-start-full.sh +0 -142
  85. package/.agentvibes/hooks/session-start-lite-v2.sh +0 -34
  86. package/.agentvibes/hooks/session-start-lite.sh +0 -29
  87. package/.agentvibes/hooks/stop-lite.sh +0 -115
  88. package/.agentvibes/hooks/switch-mode.sh +0 -215
  89. package/.agentvibes/output-styles/audio-summary.md +0 -30
  90. package/.claude/audio/voice-samples/piper/alan.wav +0 -0
  91. package/.claude/audio/voice-samples/piper/amy.wav +0 -0
  92. package/.claude/audio/voice-samples/piper/charlotte.wav +0 -0
  93. package/.claude/audio/voice-samples/piper/joe.wav +0 -0
  94. package/.claude/audio/voice-samples/piper/john.wav +0 -0
  95. package/.claude/audio/voice-samples/piper/katherine.wav +0 -0
  96. package/.claude/audio/voice-samples/piper/kristin.wav +0 -0
  97. package/.claude/audio/voice-samples/piper/linda.wav +0 -0
  98. package/.claude/audio/voice-samples/piper/marcus.wav +0 -0
  99. package/.claude/audio/voice-samples/piper/ryan.wav +0 -0
  100. package/.claude/hooks/post-response.sh +0 -41
  101. package/bin/ensure-soprano-running.sh +0 -43
@@ -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
+ };