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.
@@ -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 = ['voice_id', 'model_id', 'stability', 'similarity_boost', 'voice', 'model', 'speed', 'rate', 'pitch', 'style'];
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
- if (fs.existsSync(CACHE_FILE)) {
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
- let content = fs.readFileSync(filePath, 'utf-8');
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
- * Parse a narration object with multiple keys
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
@@ -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
+
@@ -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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "description": "Multi-format course authoring framework with CLI tools (SCORM 2004, SCORM 1.2, cmi5, LTI 1.3)",
5
5
  "type": "module",
6
6
  "bin": {