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.
- package/.agentvibes/config.json +3 -38
- package/.claude/config/audio-effects.cfg +1 -1
- package/.claude/config/background-music-enabled.txt +1 -1
- package/.claude/config/background-music-position.txt +6 -6
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/play-tts-ssh-remote.sh +119 -42
- package/.claude/hooks/play-tts-windows-receiver.sh +31 -0
- package/.claude/hooks/stop.sh +2 -27
- package/.claude/hooks-windows/play-tts-windows-sapi.ps1 +108 -108
- package/.claude/hooks-windows/play-tts.ps1 +58 -8
- package/.claude/piper-voices-dir.txt +1 -1
- package/.clawdbot/skill/README.md +326 -0
- package/.mcp.json +17 -27
- package/README.md +15 -2
- package/RELEASE_NOTES.md +64 -0
- package/bin/agent-vibes +39 -39
- package/package.json +1 -1
- package/src/bmad-detector.js +71 -71
- package/src/cli/list-personalities.js +110 -110
- package/src/cli/list-voices.js +114 -114
- package/src/commands/bmad-voices.js +394 -394
- package/src/commands/install-mcp.js +476 -476
- package/src/console/brand-colors.js +13 -13
- package/src/console/constants/personalities.js +44 -44
- package/src/console/modals/modal-overlay.js +247 -247
- package/src/console/navigation.js +5 -1
- package/src/console/tabs/agents-tab.js +5 -5
- package/src/console/tabs/help-tab.js +314 -314
- package/src/console/tabs/readme-tab.js +272 -272
- package/src/console/tabs/setup-tab.js +32 -17
- package/src/console/tabs/voices-tab.js +2 -2
- package/src/console/widgets/destroy-list.js +25 -25
- package/src/console/widgets/notice.js +55 -55
- package/src/console/widgets/personality-picker.js +213 -213
- package/src/console/widgets/reverb-picker.js +97 -97
- package/src/console/widgets/track-picker.js +1 -1
- package/src/i18n/de.js +202 -202
- package/src/i18n/es.js +202 -202
- package/src/i18n/fr.js +202 -202
- package/src/i18n/hi.js +202 -202
- package/src/i18n/ja.js +202 -202
- package/src/i18n/ko.js +202 -202
- package/src/i18n/pt.js +202 -202
- package/src/i18n/strings.js +54 -54
- package/src/i18n/zh-CN.js +202 -202
- package/src/installer/language-screen.js +31 -31
- package/src/installer/music-file-input.js +304 -304
- package/src/services/agent-voice-store.js +420 -423
- package/src/services/config-service.js +264 -264
- package/src/services/language-service.js +47 -47
- package/src/services/llm-provider-service.js +11 -4
- package/src/services/navigation-service.js +34 -10
- package/src/services/provider-service.js +143 -143
- package/src/utils/audio-duration-validator.js +298 -298
- package/src/utils/audio-format-validator.js +277 -277
- package/src/utils/dependency-checker.js +469 -469
- package/src/utils/file-ownership-verifier.js +358 -358
- package/src/utils/list-formatter.js +194 -194
- package/src/utils/music-file-validator.js +285 -285
- package/src/utils/preview-list-prompt.js +136 -136
- package/src/utils/secure-music-storage.js +412 -412
- package/.agentvibes/LITE-MODE.md +0 -236
- package/.agentvibes/README.md +0 -136
- package/.agentvibes/backup/session-start-tts.sh.20251210_212814 +0 -141
- package/.agentvibes/backups/agents/analyst_20260204_144958.md +0 -78
- package/.agentvibes/backups/agents/architect_20260204_144958.md +0 -72
- package/.agentvibes/backups/agents/dev_20260204_144958.md +0 -74
- package/.agentvibes/backups/agents/pm_20260204_144958.md +0 -72
- package/.agentvibes/backups/agents/quick-flow-solo-dev_20260204_144958.md +0 -64
- package/.agentvibes/backups/agents/sm_20260204_144958.md +0 -87
- package/.agentvibes/backups/agents/tea_20260204_144958.md +0 -79
- package/.agentvibes/backups/agents/tech-writer_20260204_144958.md +0 -82
- package/.agentvibes/backups/agents/ux-designer_20260204_144958.md +0 -80
- package/.agentvibes/config/README-personality-defaults.md +0 -162
- package/.agentvibes/config/agentvibes.json +0 -1
- package/.agentvibes/config/mode.txt +0 -1
- package/.agentvibes/config/personality-voice-defaults.default.json +0 -21
- package/.agentvibes/config/save-audio.txt +0 -1
- package/.agentvibes/config/voice-metadata.json +0 -160
- package/.agentvibes/hooks/help.sh +0 -191
- package/.agentvibes/hooks/post-tool-use-lite.sh +0 -111
- package/.agentvibes/hooks/save-audio-manager.sh +0 -162
- package/.agentvibes/hooks/session-start-full-optimized.sh +0 -102
- package/.agentvibes/hooks/session-start-full.sh +0 -142
- package/.agentvibes/hooks/session-start-lite-v2.sh +0 -34
- package/.agentvibes/hooks/session-start-lite.sh +0 -29
- package/.agentvibes/hooks/stop-lite.sh +0 -115
- package/.agentvibes/hooks/switch-mode.sh +0 -215
- package/.agentvibes/output-styles/audio-summary.md +0 -30
- package/.claude/audio/voice-samples/piper/alan.wav +0 -0
- package/.claude/audio/voice-samples/piper/amy.wav +0 -0
- package/.claude/audio/voice-samples/piper/charlotte.wav +0 -0
- package/.claude/audio/voice-samples/piper/joe.wav +0 -0
- package/.claude/audio/voice-samples/piper/john.wav +0 -0
- package/.claude/audio/voice-samples/piper/katherine.wav +0 -0
- package/.claude/audio/voice-samples/piper/kristin.wav +0 -0
- package/.claude/audio/voice-samples/piper/linda.wav +0 -0
- package/.claude/audio/voice-samples/piper/marcus.wav +0 -0
- package/.claude/audio/voice-samples/piper/ryan.wav +0 -0
- package/.claude/hooks/post-response.sh +0 -41
- 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
|
+
};
|