agentvibes 5.9.0 → 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 (70) hide show
  1. package/.agentvibes/config.json +17 -12
  2. package/.agentvibes/install-manifest.json +98 -86
  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/config/audio-effects.cfg +3 -3
  7. package/.claude/config/personality.txt +1 -0
  8. package/.claude/config/reverb-level.txt +1 -1
  9. package/.claude/github-star-reminder.txt +1 -1
  10. package/.claude/hooks/audio-processor.sh +57 -18
  11. package/.claude/hooks/kokoro-installer.sh +117 -0
  12. package/.claude/hooks/kokoro-server.py +219 -0
  13. package/.claude/hooks/kokoro-tts.py +141 -0
  14. package/.claude/hooks/piper-download-voices.sh +39 -16
  15. package/.claude/hooks/piper-installer.sh +11 -6
  16. package/.claude/hooks/piper-voice-manager.sh +6 -6
  17. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +3 -1
  18. package/.claude/hooks/play-tts-elevenlabs.sh +360 -0
  19. package/.claude/hooks/play-tts-kokoro.sh +127 -0
  20. package/.claude/hooks/play-tts-piper.sh +20 -13
  21. package/.claude/hooks/play-tts-ssh-remote.sh +9 -2
  22. package/.claude/hooks/play-tts-termux-ssh.sh +3 -1
  23. package/.claude/hooks/play-tts.sh +78 -2
  24. package/.claude/hooks/provider-commands.sh +71 -22
  25. package/.claude/hooks/provider-manager.sh +15 -7
  26. package/.claude/hooks/voice-manager.sh +6 -0
  27. package/.claude/hooks-windows/kokoro-server.py +219 -0
  28. package/.claude/hooks-windows/kokoro-tts.py +107 -0
  29. package/.claude/hooks-windows/play-tts-kokoro.ps1 +191 -0
  30. package/.claude/hooks-windows/play-tts-windows-piper.ps1 +22 -16
  31. package/.claude/hooks-windows/play-tts.ps1 +29 -1
  32. package/README.md +12 -86
  33. package/RELEASE_NOTES.md +60 -0
  34. package/mcp-server/server.py +17 -7
  35. package/package.json +4 -3
  36. package/src/commands/install-mcp.js +730 -476
  37. package/src/console/app.js +28 -12
  38. package/src/console/audio-env.js +85 -1
  39. package/src/console/navigation.js +4 -0
  40. package/src/console/tabs/agents-tab.js +18 -12
  41. package/src/console/tabs/music-tab.js +7 -25
  42. package/src/console/tabs/receiver-tab.js +13 -13
  43. package/src/console/tabs/settings-tab.js +2 -2
  44. package/src/console/tabs/setup-tab.js +1708 -124
  45. package/src/console/tabs/voices-tab.js +76 -92
  46. package/src/console/widgets/format-utils.js +14 -2
  47. package/src/console/widgets/help-bar.js +55 -0
  48. package/src/console/widgets/personality-picker.js +2 -2
  49. package/src/console/widgets/reverb-picker.js +429 -41
  50. package/src/console/widgets/track-picker.js +60 -51
  51. package/src/i18n/de.js +204 -203
  52. package/src/i18n/en.js +1 -0
  53. package/src/i18n/es.js +204 -203
  54. package/src/i18n/fr.js +204 -203
  55. package/src/i18n/hi.js +204 -203
  56. package/src/i18n/ja.js +204 -203
  57. package/src/i18n/ko.js +204 -203
  58. package/src/i18n/pt.js +204 -203
  59. package/src/i18n/strings.js +54 -54
  60. package/src/i18n/zh-CN.js +204 -203
  61. package/src/installer.js +127 -34
  62. package/src/services/provider-service.js +178 -143
  63. package/src/services/provider-voice-catalog.js +126 -0
  64. package/src/services/tts-engine-service.js +53 -4
  65. package/src/utils/audio-duration-validator.js +341 -298
  66. package/src/utils/list-formatter.js +200 -194
  67. package/src/utils/platform-resolver.js +369 -0
  68. package/src/utils/preview-list-prompt.js +8 -0
  69. package/src/utils/provider-validator.js +79 -9
  70. package/templates/agentvibes-receiver.sh +6 -2
@@ -1,298 +1,341 @@
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
+ // 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
+
57
+ try {
58
+ // Verify file exists
59
+ if (!fs.existsSync(filePath)) {
60
+ done({
61
+ success: false,
62
+ duration: null,
63
+ error: 'File does not exist'
64
+ });
65
+ return;
66
+ }
67
+
68
+ // Check if ffprobe is available
69
+ const checkFFprobe = spawn('which', ['ffprobe']); // NOSONAR
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
+ });
92
+
93
+ checkFFprobe.on('close', (code) => {
94
+ clearTimeout(checkTimer);
95
+ if (settled) return;
96
+
97
+ if (code !== 0) {
98
+ done({
99
+ success: false,
100
+ duration: null,
101
+ error: 'ffprobe not found. Please install ffmpeg: sudo apt install ffmpeg (Linux) or brew install ffmpeg (macOS)'
102
+ });
103
+ return;
104
+ }
105
+
106
+ // Use ffprobe to get duration (part of ffmpeg)
107
+ // SECURITY: Use spawn with args array to prevent command injection
108
+ const ffprobe = spawn('ffprobe', [ // NOSONAR
109
+ '-v', 'error',
110
+ '-show_entries', 'format=duration',
111
+ '-of', 'default=noprint_wrappers=1:nokey=1',
112
+ filePath
113
+ ]);
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
+
127
+ let stdoutChunks = [];
128
+ let stderrChunks = [];
129
+
130
+ ffprobe.stdout.on('data', (data) => {
131
+ stdoutChunks.push(data);
132
+ });
133
+
134
+ ffprobe.stderr.on('data', (data) => {
135
+ stderrChunks.push(data);
136
+ });
137
+
138
+ ffprobe.on('close', (code) => {
139
+ clearTimeout(ffTimer);
140
+ if (settled) return;
141
+
142
+ const stdout = Buffer.concat(stdoutChunks).toString();
143
+ const stderr = Buffer.concat(stderrChunks).toString();
144
+
145
+ if (code !== 0) {
146
+ done({
147
+ success: false,
148
+ duration: null,
149
+ error: `ffprobe exited with code ${code}: ${stderr || 'Unknown error'}`
150
+ });
151
+ return;
152
+ }
153
+
154
+ const duration = parseFloat(stdout.trim());
155
+
156
+ if (isNaN(duration) || duration <= 0) {
157
+ done({
158
+ success: false,
159
+ duration: null,
160
+ error: 'Invalid duration value from ffprobe'
161
+ });
162
+ return;
163
+ }
164
+
165
+ done({
166
+ success: true,
167
+ duration,
168
+ error: null
169
+ });
170
+ });
171
+
172
+ ffprobe.on('error', (err) => {
173
+ clearTimeout(ffTimer);
174
+ done({
175
+ success: false,
176
+ duration: null,
177
+ error: `Failed to spawn ffprobe: ${err.message}. Please install ffmpeg.`
178
+ });
179
+ });
180
+ });
181
+
182
+ } catch (err) {
183
+ done({
184
+ success: false,
185
+ duration: null,
186
+ error: `Unexpected error: ${err.message}`
187
+ });
188
+ }
189
+ });
190
+ }
191
+
192
+ /**
193
+ * Validate audio duration against recommended limits
194
+ *
195
+ * Story 4.7 AC-11: Validate audio length and advise user
196
+ *
197
+ * @param {number} duration - Duration in seconds
198
+ * @returns {Object} Validation result:
199
+ * - isValid: boolean - True if within acceptable limits
200
+ * - level: string - 'ok' | 'warning' | 'error'
201
+ * - message: string - User-friendly message
202
+ * - recommendation: string|null - Recommendation for user
203
+ *
204
+ * @example
205
+ * const validation = validateDuration(45);
206
+ * // => { isValid: true, level: 'ok', message: 'Duration is ideal...', recommendation: null }
207
+ */
208
+ export function validateDuration(duration) {
209
+ if (typeof duration !== 'number' || isNaN(duration) || duration <= 0) {
210
+ return {
211
+ isValid: false,
212
+ level: 'error',
213
+ message: 'Invalid duration value',
214
+ recommendation: null
215
+ };
216
+ }
217
+
218
+ // Hard limit exceeded
219
+ if (duration > DURATION_LIMITS.MAX_ALLOWED) {
220
+ return {
221
+ isValid: false,
222
+ level: 'error',
223
+ message: `Audio is too long (${formatDuration(duration)}). Maximum allowed: ${DURATION_LIMITS.MAX_ALLOWED} seconds (${formatDuration(DURATION_LIMITS.MAX_ALLOWED)})`,
224
+ 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.'
225
+ };
226
+ }
227
+
228
+ // Warning: too long but acceptable
229
+ if (duration > DURATION_LIMITS.MAX_WARNING) {
230
+ return {
231
+ isValid: true,
232
+ level: 'warning',
233
+ message: `Audio is quite long (${formatDuration(duration)})`,
234
+ recommendation: `Consider using a shorter track (30-90 seconds recommended). Longer tracks use more memory and may slow down TTS processing.`
235
+ };
236
+ }
237
+
238
+ // Ideal range
239
+ if (duration >= DURATION_LIMITS.MIN_RECOMMENDED && duration <= DURATION_LIMITS.MAX_RECOMMENDED) {
240
+ return {
241
+ isValid: true,
242
+ level: 'ok',
243
+ message: `Duration is ideal (${formatDuration(duration)})`,
244
+ recommendation: null
245
+ };
246
+ }
247
+
248
+ // Warning: too short but acceptable
249
+ if (duration < DURATION_LIMITS.MIN_RECOMMENDED) {
250
+ return {
251
+ isValid: true,
252
+ level: 'warning',
253
+ message: `Audio is short (${formatDuration(duration)})`,
254
+ recommendation: `Consider using a longer track (30-90 seconds recommended). Short tracks will loop frequently and may sound repetitive during longer TTS messages.`
255
+ };
256
+ }
257
+
258
+ // Between recommended max and warning limit - acceptable
259
+ return {
260
+ isValid: true,
261
+ level: 'ok',
262
+ message: `Duration is acceptable (${formatDuration(duration)})`,
263
+ recommendation: null
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Format duration in seconds to human-readable string
269
+ *
270
+ * @param {number} seconds - Duration in seconds
271
+ * @returns {string} Formatted duration (e.g., "1:23", "2:45", "45s")
272
+ *
273
+ * @example
274
+ * formatDuration(45) // => "45s"
275
+ * formatDuration(90) // => "1:30"
276
+ * formatDuration(195) // => "3:15"
277
+ */
278
+ export function formatDuration(seconds) {
279
+ if (seconds < 60) {
280
+ return `${Math.round(seconds)}s`;
281
+ }
282
+
283
+ const minutes = Math.floor(seconds / 60);
284
+ const remainingSeconds = Math.round(seconds % 60);
285
+ return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
286
+ }
287
+
288
+ /**
289
+ * Get duration validation for audio file (convenience function)
290
+ *
291
+ * Combines getAudioDuration() and validateDuration() for one-step validation
292
+ *
293
+ * @param {string} filePath - Path to audio file
294
+ * @returns {Promise<Object>} Combined result:
295
+ * - success: boolean
296
+ * - duration: number|null
297
+ * - isValid: boolean
298
+ * - level: string
299
+ * - message: string
300
+ * - recommendation: string|null
301
+ * - error: string|null
302
+ *
303
+ * @example
304
+ * const result = await validateAudioDuration('/path/to/music.mp3');
305
+ * if (result.success && result.level === 'warning') {
306
+ * console.log(result.message);
307
+ * console.log(result.recommendation);
308
+ * }
309
+ */
310
+ export async function validateAudioDuration(filePath) {
311
+ const durationResult = await getAudioDuration(filePath);
312
+
313
+ if (!durationResult.success) {
314
+ return {
315
+ success: false,
316
+ duration: null,
317
+ isValid: false,
318
+ level: 'error',
319
+ message: durationResult.error,
320
+ recommendation: null,
321
+ error: durationResult.error
322
+ };
323
+ }
324
+
325
+ const validation = validateDuration(durationResult.duration);
326
+
327
+ return {
328
+ success: true,
329
+ duration: durationResult.duration,
330
+ ...validation,
331
+ error: null
332
+ };
333
+ }
334
+
335
+ export default {
336
+ getAudioDuration,
337
+ validateDuration,
338
+ validateAudioDuration,
339
+ formatDuration,
340
+ DURATION_LIMITS
341
+ };