coursecode 0.1.37 → 0.1.38
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/framework/scripts/generate-narration.js +16 -182
- package/lib/authoring-api.js +124 -0
- package/lib/build-linter.js +88 -0
- package/lib/mcp-prompts.js +51 -0
- package/lib/mcp-server.js +10 -1
- package/lib/narration-parser.js +204 -0
- package/package.json +1 -1
|
@@ -32,9 +32,15 @@
|
|
|
32
32
|
|
|
33
33
|
import fs from 'fs';
|
|
34
34
|
import path from 'path';
|
|
35
|
-
import crypto from 'crypto';
|
|
36
35
|
import { fileURLToPath } from 'url';
|
|
37
36
|
import { getActiveProvider, printProviderHelp, listProviders as _listProviders } from './tts-providers/index.js';
|
|
37
|
+
import {
|
|
38
|
+
parseSlideNarration as parseSlideNarrationShared,
|
|
39
|
+
hashContent,
|
|
40
|
+
loadNarrationCache,
|
|
41
|
+
narrationCacheKey,
|
|
42
|
+
VOICE_SETTING_KEYS as _SHARED_VOICE_KEYS
|
|
43
|
+
} from '../../lib/narration-parser.js';
|
|
38
44
|
|
|
39
45
|
const __filename = fileURLToPath(import.meta.url);
|
|
40
46
|
const __dirname = path.dirname(__filename);
|
|
@@ -48,8 +54,8 @@ const AUDIO_DIR = path.join(ASSETS_DIR, 'audio');
|
|
|
48
54
|
const SLIDES_DIR = path.join(COURSE_DIR, 'slides');
|
|
49
55
|
const CACHE_FILE = path.join(SCORM_TEMPLATE_DIR, '.narration-cache.json');
|
|
50
56
|
|
|
51
|
-
// Reserved keys for voice settings (not narration content)
|
|
52
|
-
const VOICE_SETTING_KEYS =
|
|
57
|
+
// Reserved keys for voice settings (not narration content) — re-exported from shared module
|
|
58
|
+
const VOICE_SETTING_KEYS = _SHARED_VOICE_KEYS;
|
|
53
59
|
|
|
54
60
|
// Parse command line arguments
|
|
55
61
|
const args = process.argv.slice(2);
|
|
@@ -202,195 +208,23 @@ function categorizeAndFilterSources(sources) {
|
|
|
202
208
|
return result;
|
|
203
209
|
}
|
|
204
210
|
|
|
205
|
-
/**
|
|
206
|
-
* Load the narration cache
|
|
207
|
-
*/
|
|
211
|
+
/** Load the narration cache (delegates to shared module) */
|
|
208
212
|
function loadCache() {
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
|
|
212
|
-
} catch {
|
|
213
|
-
return {};
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
return {};
|
|
213
|
+
return loadNarrationCache(CACHE_FILE);
|
|
217
214
|
}
|
|
218
215
|
|
|
219
|
-
/**
|
|
220
|
-
* Save the narration cache
|
|
221
|
-
*/
|
|
216
|
+
/** Save the narration cache */
|
|
222
217
|
function saveCache(cache) {
|
|
223
218
|
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
|
|
224
219
|
}
|
|
225
220
|
|
|
226
|
-
/**
|
|
227
|
-
* Calculate MD5 hash of content
|
|
228
|
-
*/
|
|
229
|
-
function hashContent(content) {
|
|
230
|
-
return crypto.createHash('md5').update(content).digest('hex');
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Extract narration export from a slide file using static analysis.
|
|
237
|
-
* This avoids importing the module (which would fail due to browser-only dependencies).
|
|
238
|
-
*
|
|
239
|
-
* Supports:
|
|
240
|
-
* Simple string: export const narration = `text`;
|
|
241
|
-
* Object with text: export const narration = { text: `...`, voice_id: '...' };
|
|
242
|
-
* Multi-key object: export const narration = { slide: `...`, 'modal-id': `...`, 'tab-id': `...` };
|
|
243
|
-
*
|
|
244
|
-
* Returns array of narration items with key, text, settings, outputPath
|
|
245
|
-
*/
|
|
221
|
+
/** Wrapper that injects this script's AUDIO_DIR into the shared parser. */
|
|
246
222
|
function parseSlideNarration(filePath, baseName) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
// Remove block comments (/* ... */) to avoid matching examples in JSDoc
|
|
250
|
-
content = content.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
251
|
-
|
|
252
|
-
// Remove single-line comments (// ...)
|
|
253
|
-
content = content.replace(/\/\/.*$/gm, '');
|
|
254
|
-
|
|
255
|
-
// Try to match the full narration export - look for the complete object/value
|
|
256
|
-
// This regex matches from 'export const narration =' until we hit another export, async, function, etc.
|
|
257
|
-
const exportMatch = content.match(/export\s+const\s+narration\s*=\s*([\s\S]*?);(?=\s*(?:export|async\s+function|function|const|let|var|class|\/\/|\/\*|$))/);
|
|
258
|
-
|
|
259
|
-
if (!exportMatch) {
|
|
260
|
-
return null;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const exportValue = exportMatch[1].trim();
|
|
264
|
-
|
|
265
|
-
// Case 1: Simple template literal - export const narration = `text`;
|
|
266
|
-
if (exportValue.startsWith('`') && exportValue.endsWith('`')) {
|
|
267
|
-
const text = exportValue.slice(1, -1).trim();
|
|
268
|
-
return [{
|
|
269
|
-
key: 'slide',
|
|
270
|
-
text,
|
|
271
|
-
settings: {},
|
|
272
|
-
outputPath: path.join(AUDIO_DIR, `${baseName}.mp3`)
|
|
273
|
-
}];
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Case 2: Simple quoted string - export const narration = "text" or 'text'
|
|
277
|
-
if ((exportValue.startsWith('"') && exportValue.endsWith('"')) ||
|
|
278
|
-
(exportValue.startsWith("'") && exportValue.endsWith("'"))) {
|
|
279
|
-
const text = exportValue.slice(1, -1).trim();
|
|
280
|
-
return [{
|
|
281
|
-
key: 'slide',
|
|
282
|
-
text,
|
|
283
|
-
settings: {},
|
|
284
|
-
outputPath: path.join(AUDIO_DIR, `${baseName}.mp3`)
|
|
285
|
-
}];
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Case 3: Object - parse keys and values
|
|
289
|
-
if (exportValue.startsWith('{')) {
|
|
290
|
-
return parseNarrationObject(exportValue, baseName);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
return null;
|
|
223
|
+
return parseSlideNarrationShared(filePath, baseName, AUDIO_DIR);
|
|
294
224
|
}
|
|
295
225
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
* Handles: { slide: `...`, 'modal-id': `...`, text: `...`, voice_id: '...' }
|
|
299
|
-
*/
|
|
300
|
-
function parseNarrationObject(objectStr, baseName) {
|
|
301
|
-
const results = [];
|
|
302
|
-
let globalSettings = {};
|
|
303
|
-
|
|
304
|
-
// Extract voice settings (support both ElevenLabs and common formats)
|
|
305
|
-
const settingPatterns = [
|
|
306
|
-
{ key: 'voice_id', regex: /voice_id\s*:\s*['"]([^'"]+)['"]/ },
|
|
307
|
-
{ key: 'voice', regex: /voice\s*:\s*['"]([^'"]+)['"]/ },
|
|
308
|
-
{ key: 'model_id', regex: /model_id\s*:\s*['"]([^'"]+)['"]/ },
|
|
309
|
-
{ key: 'model', regex: /model\s*:\s*['"]([^'"]+)['"]/ },
|
|
310
|
-
{ key: 'stability', regex: /stability\s*:\s*([\d.]+)/ },
|
|
311
|
-
{ key: 'similarity_boost', regex: /similarity_boost\s*:\s*([\d.]+)/ },
|
|
312
|
-
{ key: 'speed', regex: /speed\s*:\s*([\d.]+)/ },
|
|
313
|
-
{ key: 'rate', regex: /rate\s*:\s*['"]([^'"]+)['"]/ },
|
|
314
|
-
{ key: 'pitch', regex: /pitch\s*:\s*['"]([^'"]+)['"]/ },
|
|
315
|
-
{ key: 'style', regex: /style\s*:\s*['"]([^'"]+)['"]/ }
|
|
316
|
-
];
|
|
317
|
-
|
|
318
|
-
for (const { key, regex } of settingPatterns) {
|
|
319
|
-
const match = objectStr.match(regex);
|
|
320
|
-
if (match) globalSettings[key] = match[1];
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Check for old format: { text: `...` } (single narration with settings)
|
|
324
|
-
const singleTextMatch = objectStr.match(/^\s*\{\s*text\s*:\s*`([\s\S]*?)`/);
|
|
325
|
-
if (singleTextMatch && !objectStr.match(/slide\s*:/)) {
|
|
326
|
-
return [{
|
|
327
|
-
key: 'slide',
|
|
328
|
-
text: singleTextMatch[1].trim(),
|
|
329
|
-
settings: globalSettings,
|
|
330
|
-
outputPath: path.join(AUDIO_DIR, `${baseName}.mp3`)
|
|
331
|
-
}];
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Multi-key format: { slide: `...`, 'key': `...` }
|
|
335
|
-
// Match patterns like: slide: `text` or 'modal-id': `text` or "tab-id": `text`
|
|
336
|
-
const keyValueRegex = /(?:(['"])([\w-]+)\1|(\w+))\s*:\s*`([\s\S]*?)`/g;
|
|
337
|
-
let match;
|
|
338
|
-
|
|
339
|
-
while ((match = keyValueRegex.exec(objectStr)) !== null) {
|
|
340
|
-
const key = match[2] || match[3]; // Quoted key or unquoted key
|
|
341
|
-
const text = match[4].trim();
|
|
342
|
-
|
|
343
|
-
// Skip voice setting keys
|
|
344
|
-
if (VOICE_SETTING_KEYS.includes(key)) continue;
|
|
345
|
-
|
|
346
|
-
// Skip 'text' key in old format (already handled above)
|
|
347
|
-
if (key === 'text') continue;
|
|
348
|
-
|
|
349
|
-
// Determine output filename
|
|
350
|
-
let outputPath;
|
|
351
|
-
if (key === 'slide') {
|
|
352
|
-
outputPath = path.join(AUDIO_DIR, `${baseName}.mp3`);
|
|
353
|
-
} else {
|
|
354
|
-
outputPath = path.join(AUDIO_DIR, `${baseName}--${key}.mp3`);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
results.push({
|
|
358
|
-
key,
|
|
359
|
-
text,
|
|
360
|
-
settings: { ...globalSettings },
|
|
361
|
-
outputPath
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// Also match quoted string values: 'key': "text" or 'key': 'text'
|
|
366
|
-
const quotedValueRegex = /(?:(['"])([\w-]+)\1|(\w+))\s*:\s*(['"])([\s\S]*?)\4/g;
|
|
367
|
-
while ((match = quotedValueRegex.exec(objectStr)) !== null) {
|
|
368
|
-
const key = match[2] || match[3];
|
|
369
|
-
const text = match[5].trim();
|
|
370
|
-
|
|
371
|
-
if (VOICE_SETTING_KEYS.includes(key)) continue;
|
|
372
|
-
if (key === 'text') continue;
|
|
373
|
-
|
|
374
|
-
// Check if we already have this key (from template literal match)
|
|
375
|
-
if (results.some(r => r.key === key)) continue;
|
|
376
|
-
|
|
377
|
-
let outputPath;
|
|
378
|
-
if (key === 'slide') {
|
|
379
|
-
outputPath = path.join(AUDIO_DIR, `${baseName}.mp3`);
|
|
380
|
-
} else {
|
|
381
|
-
outputPath = path.join(AUDIO_DIR, `${baseName}--${key}.mp3`);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
results.push({
|
|
385
|
-
key,
|
|
386
|
-
text,
|
|
387
|
-
settings: { ...globalSettings },
|
|
388
|
-
outputPath
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
return results.length > 0 ? results : null;
|
|
393
|
-
}
|
|
226
|
+
// Removed: original inline parseSlideNarration / parseNarrationObject implementations
|
|
227
|
+
// now live in lib/narration-parser.js so the build linter can reuse them.
|
|
394
228
|
|
|
395
229
|
/**
|
|
396
230
|
* Main execution
|
package/lib/authoring-api.js
CHANGED
|
@@ -1110,3 +1110,127 @@ export async function buildCourse(options = {}) {
|
|
|
1110
1110
|
});
|
|
1111
1111
|
}
|
|
1112
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* Run the narration generator script and return a structured result.
|
|
1115
|
+
*
|
|
1116
|
+
* Spawns `framework/scripts/generate-narration.js` from the course root so it
|
|
1117
|
+
* picks up the same .env, course-config, and slide files a manual run would.
|
|
1118
|
+
*
|
|
1119
|
+
* @param {object} options
|
|
1120
|
+
* @param {boolean} [options.dryRun=false] - Show what would be generated without calling TTS
|
|
1121
|
+
* @param {boolean} [options.force=false] - Regenerate everything, ignoring the cache
|
|
1122
|
+
* @param {string} [options.slide] - Limit to a single slide ID
|
|
1123
|
+
* @returns {Promise<{success, dryRun, summary, generated, skipped, errors, warnings, output, duration}>}
|
|
1124
|
+
*/
|
|
1125
|
+
export async function generateNarration(options = {}) {
|
|
1126
|
+
const { dryRun = false, force = false, slide } = options;
|
|
1127
|
+
const startTime = Date.now();
|
|
1128
|
+
|
|
1129
|
+
let courseRoot;
|
|
1130
|
+
try {
|
|
1131
|
+
courseRoot = getCourseRoot();
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
return {
|
|
1134
|
+
success: false,
|
|
1135
|
+
error: error.message,
|
|
1136
|
+
errors: [error.message],
|
|
1137
|
+
warnings: [],
|
|
1138
|
+
output: '',
|
|
1139
|
+
duration: '0s'
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const scriptPath = path.join(courseRoot, 'framework', 'scripts', 'generate-narration.js');
|
|
1144
|
+
if (!fs.existsSync(scriptPath)) {
|
|
1145
|
+
const msg = `Narration script not found at ${scriptPath}. Run \`coursecode upgrade\` to install framework scripts.`;
|
|
1146
|
+
return {
|
|
1147
|
+
success: false,
|
|
1148
|
+
error: msg,
|
|
1149
|
+
errors: [msg],
|
|
1150
|
+
warnings: [],
|
|
1151
|
+
output: '',
|
|
1152
|
+
duration: '0s'
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const args = [scriptPath];
|
|
1157
|
+
if (force) args.push('--force');
|
|
1158
|
+
if (dryRun) args.push('--dry-run');
|
|
1159
|
+
if (slide) args.push('--slide', slide);
|
|
1160
|
+
|
|
1161
|
+
return new Promise((resolve) => {
|
|
1162
|
+
const child = spawn('node', args, {
|
|
1163
|
+
cwd: courseRoot,
|
|
1164
|
+
env: process.env,
|
|
1165
|
+
shell: false,
|
|
1166
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
let stdout = '';
|
|
1170
|
+
let stderr = '';
|
|
1171
|
+
|
|
1172
|
+
// Cap captured output to keep MCP responses manageable
|
|
1173
|
+
const MAX_OUTPUT = 64 * 1024;
|
|
1174
|
+
child.stdout.on('data', (d) => {
|
|
1175
|
+
if (stdout.length < MAX_OUTPUT) stdout += d.toString();
|
|
1176
|
+
});
|
|
1177
|
+
child.stderr.on('data', (d) => {
|
|
1178
|
+
if (stderr.length < MAX_OUTPUT) stderr += d.toString();
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
child.on('close', (code) => {
|
|
1182
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
|
|
1183
|
+
const output = stdout + (stderr ? `\n${stderr}` : '');
|
|
1184
|
+
|
|
1185
|
+
// Parse summary line: "✨ Complete: 3 generated, 5 unchanged, 1 errors"
|
|
1186
|
+
const summaryMatch = output.match(/Complete:\s*(.+?)(?:\n|$)/);
|
|
1187
|
+
const summary = summaryMatch ? summaryMatch[1].trim() : null;
|
|
1188
|
+
|
|
1189
|
+
const numFromSummary = (label) => {
|
|
1190
|
+
if (!summary) return 0;
|
|
1191
|
+
const m = summary.match(new RegExp(`(\\d+)\\s+${label}`));
|
|
1192
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
const errors = [];
|
|
1196
|
+
const warnings = [];
|
|
1197
|
+
for (const line of output.split('\n')) {
|
|
1198
|
+
const trimmed = line.trim();
|
|
1199
|
+
if (!trimmed) continue;
|
|
1200
|
+
if (trimmed.startsWith('❌') || trimmed.startsWith('Error:')) errors.push(trimmed);
|
|
1201
|
+
else if (trimmed.startsWith('⚠️')) warnings.push(trimmed);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
resolve({
|
|
1205
|
+
success: code === 0,
|
|
1206
|
+
dryRun,
|
|
1207
|
+
summary,
|
|
1208
|
+
generated: numFromSummary('generated'),
|
|
1209
|
+
skipped: numFromSummary('unchanged'),
|
|
1210
|
+
noNarration: numFromSummary('no export'),
|
|
1211
|
+
errors,
|
|
1212
|
+
warnings,
|
|
1213
|
+
output: output.slice(-8000), // Tail for MCP response (full available in stdout)
|
|
1214
|
+
duration,
|
|
1215
|
+
message: code === 0
|
|
1216
|
+
? `Narration ${dryRun ? '(dry-run) ' : ''}complete in ${duration}${summary ? `: ${summary}` : ''}`
|
|
1217
|
+
: `Narration failed (exit ${code}). ${errors.length} error(s).`
|
|
1218
|
+
});
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
child.on('error', (error) => {
|
|
1222
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
|
|
1223
|
+
resolve({
|
|
1224
|
+
success: false,
|
|
1225
|
+
dryRun,
|
|
1226
|
+
error: error.message,
|
|
1227
|
+
errors: [error.message],
|
|
1228
|
+
warnings: [],
|
|
1229
|
+
output: '',
|
|
1230
|
+
duration,
|
|
1231
|
+
message: `Narration failed: ${error.message}`
|
|
1232
|
+
});
|
|
1233
|
+
});
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
|
package/lib/build-linter.js
CHANGED
|
@@ -14,6 +14,12 @@ import path from 'path';
|
|
|
14
14
|
import { parseSlideSource, extractAssessment } from './course-parser.js';
|
|
15
15
|
import { getEngagementTrackingMap, getRegisteredComponentTypes } from './schema-extractor.js';
|
|
16
16
|
import { getValidCssClasses, lintCssSelectors } from './css-index.js';
|
|
17
|
+
import {
|
|
18
|
+
parseSlideNarration,
|
|
19
|
+
loadNarrationCache,
|
|
20
|
+
narrationCacheKey,
|
|
21
|
+
classifyNarrationFreshness
|
|
22
|
+
} from './narration-parser.js';
|
|
17
23
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
18
24
|
import {
|
|
19
25
|
flattenStructure,
|
|
@@ -104,6 +110,12 @@ export async function lintCourse(courseConfig, coursePath) {
|
|
|
104
110
|
await validateSlide(slide, coursePath, objectiveIds, errors, warnings, interactionIdRegistry, validCssIndex);
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
// Narration freshness check (opt-out via courseConfig.lint.narrationFreshness === false)
|
|
114
|
+
const narrationFreshnessEnabled = courseConfig.lint?.narrationFreshness !== false;
|
|
115
|
+
if (narrationFreshnessEnabled) {
|
|
116
|
+
checkNarrationFreshness(slides, coursePath, warnings);
|
|
117
|
+
}
|
|
118
|
+
|
|
107
119
|
return { errors, warnings };
|
|
108
120
|
}
|
|
109
121
|
|
|
@@ -398,6 +410,82 @@ function collectJsFiles(dir, result) {
|
|
|
398
410
|
}
|
|
399
411
|
}
|
|
400
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Check that generated narration audio is in sync with the narration text in
|
|
415
|
+
* each slide. Emits warnings when:
|
|
416
|
+
* - The .mp3 exists but the cached hash differs from current text+settings (stale)
|
|
417
|
+
* - Narration text is defined but no .mp3 exists (missing)
|
|
418
|
+
*
|
|
419
|
+
* Silent when:
|
|
420
|
+
* - The slide has no narration export
|
|
421
|
+
* - No audio file exists AND no cache entry (treat as "narration not yet
|
|
422
|
+
* generated" — author may not have configured TTS yet)
|
|
423
|
+
* - The narration cache file is absent (can't determine staleness; avoids
|
|
424
|
+
* false positives on freshly cloned repos)
|
|
425
|
+
*
|
|
426
|
+
* Opt out via courseConfig.lint.narrationFreshness === false.
|
|
427
|
+
*
|
|
428
|
+
* @param {Array} slides - Flattened slide list.
|
|
429
|
+
* @param {string} coursePath - Path to the course directory (contains slides/).
|
|
430
|
+
* @param {string[]} warnings - Output array; warnings are pushed in place.
|
|
431
|
+
*/
|
|
432
|
+
export function checkNarrationFreshness(slides, coursePath, warnings) {
|
|
433
|
+
const slidesDir = path.join(coursePath, 'slides');
|
|
434
|
+
const audioDir = path.join(coursePath, 'assets', 'audio');
|
|
435
|
+
// Cache file lives at the project root, one level above coursePath.
|
|
436
|
+
const projectRoot = path.dirname(coursePath);
|
|
437
|
+
const cacheFile = path.join(projectRoot, '.narration-cache.json');
|
|
438
|
+
|
|
439
|
+
const cacheLoaded = fs.existsSync(cacheFile);
|
|
440
|
+
const cache = cacheLoaded ? loadNarrationCache(cacheFile) : {};
|
|
441
|
+
|
|
442
|
+
for (const slide of slides) {
|
|
443
|
+
if (!slide.component || !slide.component.startsWith('@slides/')) continue;
|
|
444
|
+
|
|
445
|
+
const slideFileName = slide.component.replace('@slides/', '');
|
|
446
|
+
const slideFilePath = path.join(slidesDir, slideFileName);
|
|
447
|
+
if (!fs.existsSync(slideFilePath)) continue;
|
|
448
|
+
|
|
449
|
+
const baseName = slideFileName.replace(/\.js$/, '');
|
|
450
|
+
|
|
451
|
+
let items;
|
|
452
|
+
try {
|
|
453
|
+
items = parseSlideNarration(slideFilePath, baseName, audioDir);
|
|
454
|
+
} catch {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (!items) continue;
|
|
458
|
+
|
|
459
|
+
const sourceSrc = `@slides/${slideFileName}`;
|
|
460
|
+
|
|
461
|
+
for (const item of items) {
|
|
462
|
+
const cacheKey = narrationCacheKey(sourceSrc, item.key);
|
|
463
|
+
const audioExists = fs.existsSync(item.outputPath);
|
|
464
|
+
|
|
465
|
+
// Per spec: if no audio file exists, do NOT warn — author may not have
|
|
466
|
+
// generated narration yet (TTS provider may not even be configured).
|
|
467
|
+
if (!audioExists) continue;
|
|
468
|
+
|
|
469
|
+
const status = classifyNarrationFreshness({
|
|
470
|
+
item,
|
|
471
|
+
cachedHash: cache[cacheKey],
|
|
472
|
+
audioExists,
|
|
473
|
+
cacheLoaded
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
if (status === 'stale') {
|
|
477
|
+
const keyLabel = item.key === 'slide' ? '' : ` (key: "${item.key}")`;
|
|
478
|
+
const relAudio = path.relative(projectRoot, item.outputPath).replace(/\\/g, '/');
|
|
479
|
+
warnings.push(
|
|
480
|
+
`Slide "${slide.id}": narration audio is stale${keyLabel} \u2014 text changed since ${relAudio} was generated. Run \`coursecode narration\` to regenerate.`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
// 'unknown' (cache file missing): silent — avoids noise on fresh clones.
|
|
484
|
+
// 'ok': silent.
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
401
489
|
/**
|
|
402
490
|
* Lint framework JS source files for banned logging/error patterns.
|
|
403
491
|
* Prevents regression to pre-unified-logger patterns.
|
package/lib/mcp-prompts.js
CHANGED
|
@@ -407,6 +407,57 @@ Use AFTER making changes to validate the course.`,
|
|
|
407
407
|
idempotentHint: true
|
|
408
408
|
}
|
|
409
409
|
},
|
|
410
|
+
{
|
|
411
|
+
name: 'coursecode_narration',
|
|
412
|
+
description: `Generate (or dry-run) audio narration for slides via the configured TTS provider.
|
|
413
|
+
|
|
414
|
+
Reads \`export const narration = ...\` from each slide file, hashes text+voice settings, and only regenerates audio whose hash changed since the last run (cache: .narration-cache.json). MP3 files are written to course/assets/audio/.
|
|
415
|
+
|
|
416
|
+
Provider selection follows the same rules as the CLI:
|
|
417
|
+
TTS_PROVIDER env var, or auto-detect from API keys, default deepgram.
|
|
418
|
+
The .env file at the project root supplies API keys.
|
|
419
|
+
|
|
420
|
+
USE WHEN:
|
|
421
|
+
- The lint reports stale narration warnings ("narration audio is stale...").
|
|
422
|
+
- You added or modified narration text in a slide.
|
|
423
|
+
- You want to confirm what would be generated (use dryRun: true — no API calls, no audio written).
|
|
424
|
+
|
|
425
|
+
APPROVAL:
|
|
426
|
+
- Real generation (dryRun omitted or false) calls a TTS provider and writes MP3 files, so it requires explicit user approval before running.
|
|
427
|
+
- Use dryRun: true first when you only need to inspect what would be regenerated.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
- success, summary (e.g. "3 generated, 5 unchanged")
|
|
431
|
+
- generated, skipped, noNarration counts
|
|
432
|
+
- errors[], warnings[]
|
|
433
|
+
- output: tail of the generator's console output for debugging
|
|
434
|
+
- dryRun: echoes the input flag
|
|
435
|
+
|
|
436
|
+
NOT idempotent: real runs call paid TTS APIs and write files. Prefer dryRun first.`,
|
|
437
|
+
inputSchema: {
|
|
438
|
+
type: 'object',
|
|
439
|
+
properties: {
|
|
440
|
+
dryRun: {
|
|
441
|
+
type: 'boolean',
|
|
442
|
+
description: 'Show what would be generated without calling TTS or writing files (default: false).'
|
|
443
|
+
},
|
|
444
|
+
force: {
|
|
445
|
+
type: 'boolean',
|
|
446
|
+
description: 'Regenerate every narration item, ignoring the cache (default: false). Use sparingly — costs API credits.'
|
|
447
|
+
},
|
|
448
|
+
slide: {
|
|
449
|
+
type: 'string',
|
|
450
|
+
description: 'Optional slide ID filter (e.g. "intro"). Only narration for this slide is processed.'
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
required: []
|
|
454
|
+
},
|
|
455
|
+
annotations: {
|
|
456
|
+
readOnlyHint: false,
|
|
457
|
+
idempotentHint: false,
|
|
458
|
+
destructiveHint: false
|
|
459
|
+
}
|
|
460
|
+
},
|
|
410
461
|
{
|
|
411
462
|
name: 'coursecode_icon_catalog',
|
|
412
463
|
description: `Get icon information.
|
package/lib/mcp-server.js
CHANGED
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
getInteractionCatalog,
|
|
29
29
|
getIconCatalog,
|
|
30
30
|
lintCourse,
|
|
31
|
-
buildCourse
|
|
31
|
+
buildCourse,
|
|
32
|
+
generateNarration
|
|
32
33
|
} from './authoring-api.js';
|
|
33
34
|
|
|
34
35
|
import headless from './headless-browser.js';
|
|
@@ -282,6 +283,14 @@ export async function startMcpServer(options = {}) {
|
|
|
282
283
|
result = await lintCourse();
|
|
283
284
|
break;
|
|
284
285
|
|
|
286
|
+
case 'coursecode_narration':
|
|
287
|
+
result = await generateNarration({
|
|
288
|
+
dryRun: args?.dryRun === true,
|
|
289
|
+
force: args?.force === true,
|
|
290
|
+
slide: args?.slide
|
|
291
|
+
});
|
|
292
|
+
break;
|
|
293
|
+
|
|
285
294
|
|
|
286
295
|
|
|
287
296
|
default:
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narration Parser - shared static-analysis utilities for narration sources.
|
|
3
|
+
*
|
|
4
|
+
* Used by both:
|
|
5
|
+
* - framework/scripts/generate-narration.js (audio generation + cache writes)
|
|
6
|
+
* - lib/build-linter.js (stale narration detection)
|
|
7
|
+
*
|
|
8
|
+
* Pure: no TTS calls, no audio I/O. Safe to import in any Node context.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import crypto from 'crypto';
|
|
14
|
+
|
|
15
|
+
// Reserved keys for voice settings (not narration content)
|
|
16
|
+
export const VOICE_SETTING_KEYS = [
|
|
17
|
+
'voice_id', 'model_id', 'stability', 'similarity_boost',
|
|
18
|
+
'voice', 'model', 'speed', 'rate', 'pitch', 'style'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* MD5 hash of arbitrary string content. Matches the generator's algorithm so
|
|
23
|
+
* the linter can recompute hashes and compare against `.narration-cache.json`.
|
|
24
|
+
*/
|
|
25
|
+
export function hashContent(content) {
|
|
26
|
+
return crypto.createHash('md5').update(content).digest('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Compute the cache key for a narration item, matching the generator's format.
|
|
31
|
+
* `slide` key uses the bare source path; other keys are suffixed with `#key`.
|
|
32
|
+
*/
|
|
33
|
+
export function narrationCacheKey(sourceSrc, itemKey) {
|
|
34
|
+
return itemKey === 'slide' ? sourceSrc : `${sourceSrc}#${itemKey}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compute the audio output path for a narration item.
|
|
39
|
+
*/
|
|
40
|
+
export function narrationAudioPath(audioDir, baseName, itemKey) {
|
|
41
|
+
return itemKey === 'slide'
|
|
42
|
+
? path.join(audioDir, `${baseName}.mp3`)
|
|
43
|
+
: path.join(audioDir, `${baseName}--${itemKey}.mp3`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Load the narration cache file. Returns {} if missing or unreadable.
|
|
48
|
+
*/
|
|
49
|
+
export function loadNarrationCache(cacheFile) {
|
|
50
|
+
if (!fs.existsSync(cacheFile)) return {};
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
|
|
53
|
+
} catch {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract narration export from a slide file using static analysis.
|
|
60
|
+
* This avoids importing the module (which would fail due to browser-only deps).
|
|
61
|
+
*
|
|
62
|
+
* Returns null if no narration export is present, otherwise an array of items:
|
|
63
|
+
* [{ key, text, settings, outputPath }]
|
|
64
|
+
*
|
|
65
|
+
* @param {string} filePath - Absolute path to slide JS file.
|
|
66
|
+
* @param {string} baseName - File stem used to derive output filenames.
|
|
67
|
+
* @param {string} audioDir - Directory where .mp3 files are written.
|
|
68
|
+
*/
|
|
69
|
+
export function parseSlideNarration(filePath, baseName, audioDir) {
|
|
70
|
+
let content = fs.readFileSync(filePath, 'utf-8');
|
|
71
|
+
|
|
72
|
+
// Remove block comments (/* ... */) to avoid matching JSDoc examples
|
|
73
|
+
content = content.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
74
|
+
// Remove single-line comments
|
|
75
|
+
content = content.replace(/\/\/.*$/gm, '');
|
|
76
|
+
|
|
77
|
+
// Match the full narration export up to the next top-level statement
|
|
78
|
+
const exportMatch = content.match(
|
|
79
|
+
/export\s+const\s+narration\s*=\s*([\s\S]*?);(?=\s*(?:export|async\s+function|function|const|let|var|class|\/\/|\/\*|$))/
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (!exportMatch) return null;
|
|
83
|
+
|
|
84
|
+
const exportValue = exportMatch[1].trim();
|
|
85
|
+
|
|
86
|
+
// Case 1: template literal — export const narration = `text`;
|
|
87
|
+
if (exportValue.startsWith('`') && exportValue.endsWith('`')) {
|
|
88
|
+
return [{
|
|
89
|
+
key: 'slide',
|
|
90
|
+
text: exportValue.slice(1, -1).trim(),
|
|
91
|
+
settings: {},
|
|
92
|
+
outputPath: narrationAudioPath(audioDir, baseName, 'slide')
|
|
93
|
+
}];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Case 2: quoted string
|
|
97
|
+
if ((exportValue.startsWith('"') && exportValue.endsWith('"')) ||
|
|
98
|
+
(exportValue.startsWith("'") && exportValue.endsWith("'"))) {
|
|
99
|
+
return [{
|
|
100
|
+
key: 'slide',
|
|
101
|
+
text: exportValue.slice(1, -1).trim(),
|
|
102
|
+
settings: {},
|
|
103
|
+
outputPath: narrationAudioPath(audioDir, baseName, 'slide')
|
|
104
|
+
}];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Case 3: object — multi-key or { text, ...settings }
|
|
108
|
+
if (exportValue.startsWith('{')) {
|
|
109
|
+
return parseNarrationObject(exportValue, baseName, audioDir);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse a narration object literal with multiple keys and/or voice settings.
|
|
117
|
+
* Handles: { slide: `...`, 'modal-id': `...`, voice_id: '...' }
|
|
118
|
+
* and { text: `...`, voice_id: '...' }
|
|
119
|
+
*/
|
|
120
|
+
export function parseNarrationObject(objectStr, baseName, audioDir) {
|
|
121
|
+
const results = [];
|
|
122
|
+
const globalSettings = {};
|
|
123
|
+
|
|
124
|
+
const settingPatterns = [
|
|
125
|
+
{ key: 'voice_id', regex: /voice_id\s*:\s*['"]([^'"]+)['"]/ },
|
|
126
|
+
{ key: 'voice', regex: /voice\s*:\s*['"]([^'"]+)['"]/ },
|
|
127
|
+
{ key: 'model_id', regex: /model_id\s*:\s*['"]([^'"]+)['"]/ },
|
|
128
|
+
{ key: 'model', regex: /model\s*:\s*['"]([^'"]+)['"]/ },
|
|
129
|
+
{ key: 'stability', regex: /stability\s*:\s*([\d.]+)/ },
|
|
130
|
+
{ key: 'similarity_boost', regex: /similarity_boost\s*:\s*([\d.]+)/ },
|
|
131
|
+
{ key: 'speed', regex: /speed\s*:\s*([\d.]+)/ },
|
|
132
|
+
{ key: 'rate', regex: /rate\s*:\s*['"]([^'"]+)['"]/ },
|
|
133
|
+
{ key: 'pitch', regex: /pitch\s*:\s*['"]([^'"]+)['"]/ },
|
|
134
|
+
{ key: 'style', regex: /style\s*:\s*['"]([^'"]+)['"]/ }
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
for (const { key, regex } of settingPatterns) {
|
|
138
|
+
const match = objectStr.match(regex);
|
|
139
|
+
if (match) globalSettings[key] = match[1];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Old format: { text: `...` } single narration with settings
|
|
143
|
+
const singleTextMatch = objectStr.match(/^\s*\{\s*text\s*:\s*`([\s\S]*?)`/);
|
|
144
|
+
if (singleTextMatch && !objectStr.match(/slide\s*:/)) {
|
|
145
|
+
return [{
|
|
146
|
+
key: 'slide',
|
|
147
|
+
text: singleTextMatch[1].trim(),
|
|
148
|
+
settings: globalSettings,
|
|
149
|
+
outputPath: narrationAudioPath(audioDir, baseName, 'slide')
|
|
150
|
+
}];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Multi-key with template literals
|
|
154
|
+
const keyValueRegex = /(?:(['"])([\w-]+)\1|(\w+))\s*:\s*`([\s\S]*?)`/g;
|
|
155
|
+
let match;
|
|
156
|
+
while ((match = keyValueRegex.exec(objectStr)) !== null) {
|
|
157
|
+
const key = match[2] || match[3];
|
|
158
|
+
const text = match[4].trim();
|
|
159
|
+
if (VOICE_SETTING_KEYS.includes(key) || key === 'text') continue;
|
|
160
|
+
results.push({
|
|
161
|
+
key,
|
|
162
|
+
text,
|
|
163
|
+
settings: { ...globalSettings },
|
|
164
|
+
outputPath: narrationAudioPath(audioDir, baseName, key)
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Multi-key with quoted string values
|
|
169
|
+
const quotedValueRegex = /(?:(['"])([\w-]+)\1|(\w+))\s*:\s*(['"])([\s\S]*?)\4/g;
|
|
170
|
+
while ((match = quotedValueRegex.exec(objectStr)) !== null) {
|
|
171
|
+
const key = match[2] || match[3];
|
|
172
|
+
const text = match[5].trim();
|
|
173
|
+
if (VOICE_SETTING_KEYS.includes(key) || key === 'text') continue;
|
|
174
|
+
if (results.some(r => r.key === key)) continue;
|
|
175
|
+
results.push({
|
|
176
|
+
key,
|
|
177
|
+
text,
|
|
178
|
+
settings: { ...globalSettings },
|
|
179
|
+
outputPath: narrationAudioPath(audioDir, baseName, key)
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return results.length > 0 ? results : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Classify the freshness of a single narration item. Pure function — caller
|
|
188
|
+
* supplies the parsed item, the cached hash, and the audio file existence flag.
|
|
189
|
+
*
|
|
190
|
+
* Returns one of:
|
|
191
|
+
* 'ok' - audio exists AND cached hash matches current text+settings
|
|
192
|
+
* 'stale' - audio exists AND cached hash differs (or no cache entry)
|
|
193
|
+
* 'missing' - text defined but audio file does not exist
|
|
194
|
+
* 'unknown' - audio exists but no cache file at all (can't determine; caller
|
|
195
|
+
* typically suppresses these to avoid noise on fresh clones)
|
|
196
|
+
*/
|
|
197
|
+
export function classifyNarrationFreshness({ item, cachedHash, audioExists, cacheLoaded }) {
|
|
198
|
+
const currentHash = hashContent(item.text + JSON.stringify(item.settings || {}));
|
|
199
|
+
|
|
200
|
+
if (!audioExists) return 'missing';
|
|
201
|
+
if (!cacheLoaded) return 'unknown';
|
|
202
|
+
if (cachedHash === currentHash) return 'ok';
|
|
203
|
+
return 'stale';
|
|
204
|
+
}
|