coursecode 0.1.37 → 0.1.39

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/bin/cli.js CHANGED
@@ -141,6 +141,7 @@ program
141
141
  .option('-f, --force', 'Regenerate all narration (ignore cache)')
142
142
  .option('-s, --slide <id>', 'Generate narration for a specific slide only')
143
143
  .option('--dry-run', 'Preview what would be generated')
144
+ .option('--rebuild-cache', 'Rebuild .narration-cache.json from existing audio (no TTS calls)')
144
145
  .action(async (options) => {
145
146
  const { narration } = await import('../lib/narration.js');
146
147
  await narration(options);
@@ -205,6 +205,23 @@ export async function lintCourse(courseConfig) {
205
205
  // Cleanup offscreen container
206
206
  cleanupOffscreenContainer();
207
207
 
208
+ // --- 4. Filesystem-backed checks via preview server ---
209
+ // The browser can't read .narration-cache.json or hash slide source files,
210
+ // so the preview server exposes /__lint/narration to do that work and
211
+ // return any stale-narration warnings. Silently ignored if the endpoint
212
+ // is unavailable (e.g. running outside the preview server).
213
+ try {
214
+ const resp = await fetch('/__lint/narration', { cache: 'no-store' });
215
+ if (resp.ok) {
216
+ const data = await resp.json();
217
+ if (Array.isArray(data.warnings)) {
218
+ warnings.push(...data.warnings);
219
+ }
220
+ }
221
+ } catch {
222
+ // Endpoint absent (e.g. SCORM package preview) — skip silently.
223
+ }
224
+
208
225
  // Display warnings individually so each appears as a separate entry in the debug panel
209
226
  if (warnings.length > 0) {
210
227
  for (const w of warnings) {
@@ -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 './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);
@@ -59,6 +65,10 @@ const VERBOSE = args.includes('--verbose') || args.includes('-v');
59
65
  const SLIDE_FILTER = args.includes('--slide') ? args[args.indexOf('--slide') + 1] : null;
60
66
  const SHOW_PROVIDERS = args.includes('--providers') || args.includes('--provider');
61
67
  const SHOW_HELP = args.includes('--help') || args.includes('-h');
68
+ // Rebuild .narration-cache.json from current slide text without calling any
69
+ // TTS provider. Useful when audio is committed but the cache is missing
70
+ // (e.g. after a fresh clone or migrating a project).
71
+ const REBUILD_CACHE = args.includes('--rebuild-cache');
62
72
 
63
73
  /**
64
74
  * Load environment variables from .env file
@@ -202,195 +212,23 @@ function categorizeAndFilterSources(sources) {
202
212
  return result;
203
213
  }
204
214
 
205
- /**
206
- * Load the narration cache
207
- */
215
+ /** Load the narration cache (delegates to shared module) */
208
216
  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 {};
217
+ return loadNarrationCache(CACHE_FILE);
217
218
  }
218
219
 
219
- /**
220
- * Save the narration cache
221
- */
220
+ /** Save the narration cache */
222
221
  function saveCache(cache) {
223
222
  fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
224
223
  }
225
224
 
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
- */
225
+ /** Wrapper that injects this script's AUDIO_DIR into the shared parser. */
246
226
  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;
227
+ return parseSlideNarrationShared(filePath, baseName, AUDIO_DIR);
294
228
  }
295
229
 
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
- }
230
+ // Removed: original inline parseSlideNarration / parseNarrationObject implementations
231
+ // now live in framework/scripts/narration-parser.js so the build linter can reuse them.
394
232
 
395
233
  /**
396
234
  * Main execution
@@ -410,11 +248,12 @@ async function main() {
410
248
 
411
249
  Usage:
412
250
  npm run narration Generate all changed narration
413
- npm run narration -- --force Regenerate all (ignore cache)
414
- npm run narration -- --dry-run Preview without generating
415
- npm run narration -- --slide <id> Generate specific slide only
416
- npm run narration -- --providers List available TTS providers
417
- npm run narration -- --verbose Show detailed output
251
+ npm run narration -- --force Regenerate all (ignore cache)
252
+ npm run narration -- --dry-run Preview without generating
253
+ npm run narration -- --slide <id> Generate specific slide only
254
+ npm run narration -- --rebuild-cache Rebuild cache from existing audio (no TTS calls)
255
+ npm run narration -- --providers List available TTS providers
256
+ npm run narration -- --verbose Show detailed output
418
257
 
419
258
  Provider Selection:
420
259
  Set TTS_PROVIDER env var to: elevenlabs, openai, or azure
@@ -432,17 +271,22 @@ Examples:
432
271
  // Load environment variables
433
272
  loadEnv();
434
273
 
435
- // Initialize TTS provider
274
+ // Initialize TTS provider — skipped in --rebuild-cache mode since we
275
+ // never call the provider when only refreshing the cache file.
436
276
  let provider;
437
- try {
438
- provider = getActiveProvider();
439
- provider.validateConfig();
440
- const defaultVoice = provider.getDefaultVoiceId();
441
- console.log(`🔊 Using TTS provider: ${provider.getName()} (voice: ${defaultVoice})\n`);
442
- } catch (error) {
443
- console.error(`❌ Provider error: ${error.message}\n`);
444
- printProviderHelp();
445
- process.exit(1);
277
+ if (REBUILD_CACHE) {
278
+ console.log('🔁 Rebuild-cache mode: hashing existing audio, no TTS calls.\n');
279
+ } else {
280
+ try {
281
+ provider = getActiveProvider();
282
+ provider.validateConfig();
283
+ const defaultVoice = provider.getDefaultVoiceId();
284
+ console.log(`🔊 Using TTS provider: ${provider.getName()} (voice: ${defaultVoice})\n`);
285
+ } catch (error) {
286
+ console.error(`❌ Provider error: ${error.message}\n`);
287
+ printProviderHelp();
288
+ process.exit(1);
289
+ }
446
290
  }
447
291
 
448
292
  // Load course config
@@ -541,7 +385,24 @@ Examples:
541
385
  const cacheKey = key === 'slide' ? source.src : `${source.src}#${key}`;
542
386
  const cachedHash = cache[cacheKey];
543
387
  const outputExists = fs.existsSync(outputPath);
544
-
388
+
389
+ // --rebuild-cache: hash text+settings for existing audio and move on.
390
+ // Never call TTS, never write or delete audio files.
391
+ if (REBUILD_CACHE) {
392
+ if (outputExists) {
393
+ newCache[cacheKey] = contentHash;
394
+ skipped++;
395
+ if (VERBOSE) {
396
+ const label = key === 'slide' ? source.slideId : `${source.slideId}#${key}`;
397
+ console.log(` ✅ ${label}: cached (${path.relative(ROOT_DIR, outputPath)})`);
398
+ }
399
+ } else if (VERBOSE) {
400
+ const label = key === 'slide' ? source.slideId : `${source.slideId}#${key}`;
401
+ console.log(` ⏭️ ${label}: no audio yet, skipping`);
402
+ }
403
+ continue;
404
+ }
405
+
545
406
  if (cachedHash === contentHash && outputExists && !FORCE_REGENERATE) {
546
407
  if (VERBOSE) {
547
408
  const label = key === 'slide' ? source.slideId : `${source.slideId}#${key}`;
@@ -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
+ }
@@ -1110,3 +1110,129 @@ 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
+ * @param {boolean} [options.rebuildCache=false] - Rebuild .narration-cache.json from existing audio without calling TTS
1124
+ * @returns {Promise<{success, dryRun, summary, generated, skipped, errors, warnings, output, duration}>}
1125
+ */
1126
+ export async function generateNarration(options = {}) {
1127
+ const { dryRun = false, force = false, slide, rebuildCache = false } = options;
1128
+ const startTime = Date.now();
1129
+
1130
+ let courseRoot;
1131
+ try {
1132
+ courseRoot = getCourseRoot();
1133
+ } catch (error) {
1134
+ return {
1135
+ success: false,
1136
+ error: error.message,
1137
+ errors: [error.message],
1138
+ warnings: [],
1139
+ output: '',
1140
+ duration: '0s'
1141
+ };
1142
+ }
1143
+
1144
+ const scriptPath = path.join(courseRoot, 'framework', 'scripts', 'generate-narration.js');
1145
+ if (!fs.existsSync(scriptPath)) {
1146
+ const msg = `Narration script not found at ${scriptPath}. Run \`coursecode upgrade\` to install framework scripts.`;
1147
+ return {
1148
+ success: false,
1149
+ error: msg,
1150
+ errors: [msg],
1151
+ warnings: [],
1152
+ output: '',
1153
+ duration: '0s'
1154
+ };
1155
+ }
1156
+
1157
+ const args = [scriptPath];
1158
+ if (force) args.push('--force');
1159
+ if (dryRun) args.push('--dry-run');
1160
+ if (slide) args.push('--slide', slide);
1161
+ if (rebuildCache) args.push('--rebuild-cache');
1162
+
1163
+ return new Promise((resolve) => {
1164
+ const child = spawn('node', args, {
1165
+ cwd: courseRoot,
1166
+ env: process.env,
1167
+ shell: false,
1168
+ stdio: ['ignore', 'pipe', 'pipe']
1169
+ });
1170
+
1171
+ let stdout = '';
1172
+ let stderr = '';
1173
+
1174
+ // Cap captured output to keep MCP responses manageable
1175
+ const MAX_OUTPUT = 64 * 1024;
1176
+ child.stdout.on('data', (d) => {
1177
+ if (stdout.length < MAX_OUTPUT) stdout += d.toString();
1178
+ });
1179
+ child.stderr.on('data', (d) => {
1180
+ if (stderr.length < MAX_OUTPUT) stderr += d.toString();
1181
+ });
1182
+
1183
+ child.on('close', (code) => {
1184
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
1185
+ const output = stdout + (stderr ? `\n${stderr}` : '');
1186
+
1187
+ // Parse summary line: "✨ Complete: 3 generated, 5 unchanged, 1 errors"
1188
+ const summaryMatch = output.match(/Complete:\s*(.+?)(?:\n|$)/);
1189
+ const summary = summaryMatch ? summaryMatch[1].trim() : null;
1190
+
1191
+ const numFromSummary = (label) => {
1192
+ if (!summary) return 0;
1193
+ const m = summary.match(new RegExp(`(\\d+)\\s+${label}`));
1194
+ return m ? parseInt(m[1], 10) : 0;
1195
+ };
1196
+
1197
+ const errors = [];
1198
+ const warnings = [];
1199
+ for (const line of output.split('\n')) {
1200
+ const trimmed = line.trim();
1201
+ if (!trimmed) continue;
1202
+ if (trimmed.startsWith('❌') || trimmed.startsWith('Error:')) errors.push(trimmed);
1203
+ else if (trimmed.startsWith('⚠️')) warnings.push(trimmed);
1204
+ }
1205
+
1206
+ resolve({
1207
+ success: code === 0,
1208
+ dryRun,
1209
+ summary,
1210
+ generated: numFromSummary('generated'),
1211
+ skipped: numFromSummary('unchanged'),
1212
+ noNarration: numFromSummary('no export'),
1213
+ errors,
1214
+ warnings,
1215
+ output: output.slice(-8000), // Tail for MCP response (full available in stdout)
1216
+ duration,
1217
+ message: code === 0
1218
+ ? `Narration ${dryRun ? '(dry-run) ' : ''}complete in ${duration}${summary ? `: ${summary}` : ''}`
1219
+ : `Narration failed (exit ${code}). ${errors.length} error(s).`
1220
+ });
1221
+ });
1222
+
1223
+ child.on('error', (error) => {
1224
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
1225
+ resolve({
1226
+ success: false,
1227
+ dryRun,
1228
+ error: error.message,
1229
+ errors: [error.message],
1230
+ warnings: [],
1231
+ output: '',
1232
+ duration,
1233
+ message: `Narration failed: ${error.message}`
1234
+ });
1235
+ });
1236
+ });
1237
+ }
1238
+
@@ -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 '../framework/scripts/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,61 @@ 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
+ rebuildCache: {
453
+ type: 'boolean',
454
+ description: 'Rebuild .narration-cache.json from existing audio files without calling TTS (default: false). Use after a fresh clone or when the cache is missing but audio is committed. Free; no API calls.'
455
+ }
456
+ },
457
+ required: []
458
+ },
459
+ annotations: {
460
+ readOnlyHint: false,
461
+ idempotentHint: false,
462
+ destructiveHint: false
463
+ }
464
+ },
410
465
  {
411
466
  name: 'coursecode_icon_catalog',
412
467
  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,15 @@ 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
+ rebuildCache: args?.rebuildCache === true
292
+ });
293
+ break;
294
+
285
295
 
286
296
 
287
297
  default:
package/lib/narration.js CHANGED
@@ -32,6 +32,7 @@ export async function narration(options = {}) {
32
32
  if (options.force) args.push('--force');
33
33
  if (options.slide) args.push('--slide', options.slide);
34
34
  if (options.dryRun) args.push('--dry-run');
35
+ if (options.rebuildCache) args.push('--rebuild-cache');
35
36
 
36
37
  return new Promise((resolve, reject) => {
37
38
  const child = spawn('node', args, {
@@ -67,6 +67,47 @@ export async function handleApiRoutes(ctx, req, res, url) {
67
67
  return true;
68
68
  }
69
69
 
70
+ // Narration freshness check — runtime linter fetches this since it can't
71
+ // read the filesystem directly. Returns warnings for stale .mp3 files
72
+ // (audio exists but cached hash differs from current text+settings).
73
+ // Silent when audio is absent or .narration-cache.json is missing.
74
+ if (url === '/__lint/narration') {
75
+ try {
76
+ const { checkNarrationFreshness } = await import('./build-linter.js');
77
+ const { flattenStructure } = await import('./validation-rules.js');
78
+ const configPath = path.join(paths.coursePath, 'course-config.js');
79
+ if (!fs.existsSync(configPath)) {
80
+ res.writeHead(200, { 'Content-Type': 'application/json' });
81
+ res.end(JSON.stringify({ warnings: [] }));
82
+ return true;
83
+ }
84
+ // Cache-bust import so edits to course-config.js are picked up
85
+ const configUrl = `${pathToFileURL(configPath).href}?t=${Date.now()}`;
86
+ const configModule = await import(configUrl);
87
+ const courseConfig = configModule.courseConfig || configModule.default;
88
+ if (!courseConfig?.structure) {
89
+ res.writeHead(200, { 'Content-Type': 'application/json' });
90
+ res.end(JSON.stringify({ warnings: [] }));
91
+ return true;
92
+ }
93
+ // Respect the same opt-out as the build linter
94
+ if (courseConfig.lint?.narrationFreshness === false) {
95
+ res.writeHead(200, { 'Content-Type': 'application/json' });
96
+ res.end(JSON.stringify({ warnings: [], disabled: true }));
97
+ return true;
98
+ }
99
+ const slides = flattenStructure(courseConfig.structure);
100
+ const warnings = [];
101
+ checkNarrationFreshness(slides, paths.coursePath, warnings);
102
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' });
103
+ res.end(JSON.stringify({ warnings }));
104
+ } catch (err) {
105
+ res.writeHead(500, { 'Content-Type': 'application/json' });
106
+ res.end(JSON.stringify({ warnings: [], error: err.message }));
107
+ }
108
+ return true;
109
+ }
110
+
70
111
  // Refresh course content HTML
71
112
  if (url === '/__content') {
72
113
  generateContentHtml({ coursePath: paths.coursePath, includeNarration: true })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
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": {