agentvibes 3.5.9 → 4.0.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.
- package/.agentvibes/bmad/bmad-voices-enabled.flag +0 -0
- package/.agentvibes/bmad/bmad-voices.md +69 -0
- package/.claude/config/audio-effects.cfg +1 -1
- package/.claude/config/background-music-position.txt +1 -27
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/audio-processor.sh +32 -17
- package/.claude/hooks/bmad-speak-enhanced.sh +5 -5
- package/.claude/hooks/bmad-speak.sh +4 -4
- package/.claude/hooks/bmad-voice-manager.sh +8 -8
- package/.claude/hooks/clawdbot-receiver-SECURE.sh +23 -25
- package/.claude/hooks/clawdbot-receiver.sh +28 -4
- package/.claude/hooks/language-manager.sh +1 -1
- package/.claude/hooks/path-resolver.sh +60 -0
- package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +90 -0
- package/.claude/hooks/play-tts-piper.sh +82 -24
- package/.claude/hooks/play-tts-ssh-remote.sh +13 -15
- package/.claude/hooks/play-tts.sh +16 -5
- package/.claude/hooks/session-start-tts.sh +26 -56
- package/.claude/hooks/soprano-gradio-synth.py +1 -1
- package/.claude/hooks/verbosity-manager.sh +10 -4
- package/.claude/settings.json +1 -1
- package/CLAUDE.md +129 -104
- package/README.md +418 -10
- package/RELEASE_NOTES.md +60 -1036
- package/bin/agentvibes-voice-browser.js +1827 -0
- package/bin/agentvibes.js +100 -0
- package/mcp-server/server.py +67 -3
- package/package.json +11 -2
- package/src/console/app.js +806 -0
- package/src/console/audio-env.js +123 -0
- package/src/console/brand-colors.js +13 -0
- package/src/console/footer-config.js +42 -0
- package/src/console/modals/.gitkeep +0 -0
- package/src/console/modals/modal-overlay.js +247 -0
- package/src/console/navigation.js +60 -0
- package/src/console/tabs/.gitkeep +0 -0
- package/src/console/tabs/agents-tab.js +369 -0
- package/src/console/tabs/help-tab.js +261 -0
- package/src/console/tabs/install-tab.js +990 -0
- package/src/console/tabs/music-tab.js +997 -0
- package/src/console/tabs/placeholder-tab.js +45 -0
- package/src/console/tabs/readme-tab.js +267 -0
- package/src/console/tabs/settings-tab.js +3949 -0
- package/src/console/tabs/voices-tab.js +1574 -0
- package/src/installer/music-file-input.js +304 -0
- package/src/installer.js +1353 -676
- package/src/services/.gitkeep +0 -0
- package/src/services/agent-voice-store.js +163 -0
- package/src/services/config-service.js +240 -0
- package/src/services/navigation-service.js +123 -0
- package/src/services/provider-service.js +132 -0
- package/src/services/verbosity-service.js +157 -0
- package/src/utils/audio-duration-validator.js +298 -0
- package/src/utils/audio-format-validator.js +277 -0
- package/src/utils/dependency-checker.js +3 -3
- package/src/utils/file-ownership-verifier.js +358 -0
- package/src/utils/music-file-validator.js +275 -0
- package/src/utils/preview-list-prompt.js +136 -0
- package/src/utils/provider-validator.js +144 -132
- package/src/utils/secure-music-storage.js +412 -0
- package/templates/agentvibes-receiver.sh +11 -7
- package/voice-assignments.json +8245 -0
- package/.claude/config/background-music-volume.txt +0 -1
- package/.claude/config/background-music.cfg +0 -1
- package/.claude/config/background-music.txt +0 -1
- package/.claude/config/tts-speech-rate.txt +0 -1
- package/.claude/config/tts-verbosity.txt +0 -1
- package/.claude/hooks/bmad-party-manager.sh +0 -225
- package/.claude/hooks/stop.sh +0 -38
- package/.claude/piper-voices-dir.txt +0 -1
- package/.mcp.json +0 -34
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentVibes VerbosityService
|
|
3
|
+
* Epic 10: Stories 10.1-10.4
|
|
4
|
+
*
|
|
5
|
+
* Centralises verbosity logic for 5 levels: minimal, low, medium, high, custom.
|
|
6
|
+
*
|
|
7
|
+
* Hook types:
|
|
8
|
+
* - 'prompt-submit' — fires when user submits a prompt
|
|
9
|
+
* - 'response-complete' — fires when Claude finishes responding
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Ordered verbosity levels from quietest to most verbose.
|
|
16
|
+
* @type {string[]}
|
|
17
|
+
*/
|
|
18
|
+
export const VERBOSITY_LEVELS = Object.freeze(['minimal', 'low', 'medium', 'high', 'custom']);
|
|
19
|
+
|
|
20
|
+
// Per-level shouldSpeak configuration (fixed levels only; custom reads from config)
|
|
21
|
+
const LEVEL_SPEAK = Object.freeze({
|
|
22
|
+
minimal: { 'prompt-submit': false, 'response-complete': true },
|
|
23
|
+
low: { 'prompt-submit': false, 'response-complete': true },
|
|
24
|
+
medium: { 'prompt-submit': true, 'response-complete': true },
|
|
25
|
+
high: { 'prompt-submit': true, 'response-complete': true },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Per-level static messages (null = use hook default message)
|
|
29
|
+
const LEVEL_MESSAGES = Object.freeze({
|
|
30
|
+
minimal: {
|
|
31
|
+
'prompt-submit': null,
|
|
32
|
+
'response-complete': 'Claude is ready for your input',
|
|
33
|
+
},
|
|
34
|
+
low: {
|
|
35
|
+
'prompt-submit': null,
|
|
36
|
+
'response-complete': null, // built dynamically: "Done. <summary>"
|
|
37
|
+
},
|
|
38
|
+
medium: { 'prompt-submit': null, 'response-complete': null },
|
|
39
|
+
high: { 'prompt-submit': null, 'response-complete': null },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
export class VerbosityService {
|
|
45
|
+
/**
|
|
46
|
+
* @param {object} configService - AgentVibes ConfigService instance
|
|
47
|
+
*/
|
|
48
|
+
constructor(configService) {
|
|
49
|
+
this._config = configService;
|
|
50
|
+
this._migrated = false; // tracks whether migration ran in this session
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns the current verbosity level.
|
|
55
|
+
* Defaults to 'high' if not configured or unrecognised.
|
|
56
|
+
* @returns {'minimal'|'low'|'medium'|'high'|'custom'}
|
|
57
|
+
*/
|
|
58
|
+
getLevel() {
|
|
59
|
+
const cfg = this._config.getConfig();
|
|
60
|
+
const level = cfg.verbosity;
|
|
61
|
+
return VERBOSITY_LEVELS.includes(level) ? level : 'high';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns whether TTS should speak for the given hook type.
|
|
66
|
+
*
|
|
67
|
+
* @param {'prompt-submit'|'response-complete'} hookType
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
shouldSpeak(hookType) {
|
|
71
|
+
const level = this.getLevel();
|
|
72
|
+
if (level === 'custom') {
|
|
73
|
+
return this._customShouldSpeak(hookType);
|
|
74
|
+
}
|
|
75
|
+
const table = LEVEL_SPEAK[level] ?? LEVEL_SPEAK.high;
|
|
76
|
+
return table[hookType] ?? true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns the message for the given hook type and context.
|
|
81
|
+
* Returns null to use the hook's own default message.
|
|
82
|
+
*
|
|
83
|
+
* @param {'prompt-submit'|'response-complete'} hookType
|
|
84
|
+
* @param {object} context - { summary?: string }
|
|
85
|
+
* @returns {string|null}
|
|
86
|
+
*/
|
|
87
|
+
getMessage(hookType, context) {
|
|
88
|
+
const level = this.getLevel();
|
|
89
|
+
if (level === 'high' || level === 'medium') return null;
|
|
90
|
+
|
|
91
|
+
if (level === 'low' && hookType === 'response-complete') {
|
|
92
|
+
const summary = context?.summary ?? '';
|
|
93
|
+
const trimmed = summary.slice(0, 30);
|
|
94
|
+
return trimmed.length > 0 ? `Done. ${trimmed}` : 'Done';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (level === 'custom') return null; // custom uses hook defaults
|
|
98
|
+
|
|
99
|
+
return LEVEL_MESSAGES[level]?.[hookType] ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Checks if migration is needed (old 'low' → 'medium').
|
|
104
|
+
* If needed and not already done, performs migration and returns true.
|
|
105
|
+
* @returns {boolean} true if migration was performed, false otherwise
|
|
106
|
+
*/
|
|
107
|
+
checkMigration() {
|
|
108
|
+
const cfg = this._config.getConfig();
|
|
109
|
+
if (cfg.verbosityMigrated) return false;
|
|
110
|
+
if (cfg.verbosity !== 'low') return false;
|
|
111
|
+
|
|
112
|
+
// Migrate: old 'low' users get 'medium' in new system
|
|
113
|
+
this._config.set('verbosity', 'medium');
|
|
114
|
+
this._config.set('verbosityMigrated', true);
|
|
115
|
+
this._migrated = true;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Returns true if a migration notice should be shown to the user.
|
|
121
|
+
* @returns {boolean}
|
|
122
|
+
*/
|
|
123
|
+
needsMigrationNotice() {
|
|
124
|
+
if (!this._migrated) return false;
|
|
125
|
+
const cfg = this._config.getConfig();
|
|
126
|
+
return !cfg.migrationNoticeDismissed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Marks the migration notice as dismissed.
|
|
131
|
+
*/
|
|
132
|
+
dismissMigrationNotice() {
|
|
133
|
+
this._config.set('migrationNoticeDismissed', true);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Private
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* shouldSpeak for CUSTOM level — reads per-hook toggles from config.customVerbosity.
|
|
141
|
+
* @param {string} hookType
|
|
142
|
+
* @returns {boolean}
|
|
143
|
+
*/
|
|
144
|
+
_customShouldSpeak(hookType) {
|
|
145
|
+
const cfg = this._config.getConfig();
|
|
146
|
+
const custom = cfg.customVerbosity ?? {};
|
|
147
|
+
const keyMap = {
|
|
148
|
+
'prompt-submit': 'promptSubmit',
|
|
149
|
+
'response-complete': 'responseComplete',
|
|
150
|
+
};
|
|
151
|
+
const key = keyMap[hookType];
|
|
152
|
+
if (key === undefined) return true;
|
|
153
|
+
return custom[key] !== false; // default true if not configured
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export default VerbosityService;
|
|
@@ -0,0 +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
|
+
};
|