refacil-sdd-ai 5.2.2 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/NOTICE.md +46 -0
  2. package/README.md +209 -42
  3. package/agents/auditor.md +46 -0
  4. package/agents/debugger.md +41 -1
  5. package/agents/implementer.md +76 -10
  6. package/agents/investigator.md +36 -0
  7. package/agents/proposer.md +46 -2
  8. package/agents/tester.md +45 -8
  9. package/agents/validator.md +67 -13
  10. package/bin/cli.js +428 -83
  11. package/bin/postinstall.js +20 -0
  12. package/lib/bus/broker.js +121 -3
  13. package/lib/bus/spawn.js +189 -121
  14. package/lib/check-review.js +102 -0
  15. package/lib/codegraph-telemetry.js +135 -0
  16. package/lib/codegraph.js +273 -0
  17. package/lib/commands/autopilot.js +120 -0
  18. package/lib/commands/bus.js +29 -36
  19. package/lib/commands/compact.js +185 -46
  20. package/lib/commands/read-spec.js +352 -0
  21. package/lib/commands/sdd.js +429 -44
  22. package/lib/compact-guidance.js +122 -77
  23. package/lib/config.js +136 -0
  24. package/lib/global-paths.js +56 -20
  25. package/lib/hooks.js +32 -4
  26. package/lib/ide-detection.js +1 -1
  27. package/lib/ignore-files.js +5 -1
  28. package/lib/installer.js +202 -19
  29. package/lib/kapso.js +241 -0
  30. package/lib/methodology-migration-pending.js +13 -0
  31. package/lib/open-browser.js +32 -0
  32. package/lib/opencode-migrate.js +148 -0
  33. package/lib/opencode-plugin/index.js +84 -104
  34. package/lib/opencode-plugin/rules.js +236 -0
  35. package/lib/project-root.js +154 -0
  36. package/lib/repo-ide-sync.js +5 -0
  37. package/lib/spec-reader/lang.js +72 -0
  38. package/lib/spec-reader/md-parser.js +299 -0
  39. package/lib/spec-reader/session.js +139 -0
  40. package/lib/spec-reader/ui/app.js +685 -0
  41. package/lib/spec-reader/ui/index.html +59 -0
  42. package/lib/spec-reader/ui/mixed-lang.js +200 -0
  43. package/lib/spec-reader/ui/model-cache.js +117 -0
  44. package/lib/spec-reader/ui/style.css +294 -0
  45. package/lib/spec-reader/ui/supertonic-helper.js +565 -0
  46. package/lib/spec-sync.js +258 -0
  47. package/lib/test-scope.js +713 -0
  48. package/lib/testing-policy-sync.js +14 -2
  49. package/package.json +6 -3
  50. package/skills/apply/SKILL.md +39 -64
  51. package/skills/archive/SKILL.md +74 -48
  52. package/skills/ask/SKILL.md +43 -8
  53. package/skills/autopilot/SKILL.md +476 -0
  54. package/skills/bug/SKILL.md +52 -53
  55. package/skills/explore/SKILL.md +48 -1
  56. package/skills/guide/SKILL.md +31 -13
  57. package/skills/inbox/SKILL.md +9 -0
  58. package/skills/join/SKILL.md +1 -1
  59. package/skills/prereqs/BUS-CROSS-REPO.md +33 -16
  60. package/skills/prereqs/METHODOLOGY-CONTRACT.md +96 -17
  61. package/skills/prereqs/SKILL.md +1 -1
  62. package/skills/propose/SKILL.md +74 -19
  63. package/skills/read-spec/SKILL.md +76 -0
  64. package/skills/reply/SKILL.md +42 -9
  65. package/skills/review/SKILL.md +63 -25
  66. package/skills/review/checklist.md +2 -2
  67. package/skills/say/SKILL.md +40 -4
  68. package/skills/setup/SKILL.md +59 -5
  69. package/skills/setup/troubleshooting.md +11 -3
  70. package/skills/stats/SKILL.md +157 -0
  71. package/skills/test/SKILL.md +35 -10
  72. package/skills/up-code/SKILL.md +20 -13
  73. package/skills/update/SKILL.md +32 -1
  74. package/skills/verify/SKILL.md +78 -41
  75. package/templates/compact-guidance.md +10 -0
  76. package/templates/methodology-guide.md +5 -0
@@ -0,0 +1,685 @@
1
+ import { installCachedFetch, hasAnyCachedModel } from './model-cache.js';
2
+ import {
3
+ loadTextToSpeech,
4
+ loadVoiceStyle,
5
+ writeWavFile,
6
+ AVAILABLE_LANGS,
7
+ } from './supertonic-helper.js';
8
+ import {
9
+ splitBilingualText,
10
+ MIXED_LANG_GAP_SEC,
11
+ trimTrailingSilence,
12
+ trimLeadingSilence,
13
+ concatWavParts,
14
+ } from './mixed-lang.js';
15
+
16
+ const HF_BASE = 'https://huggingface.co/Supertone/supertonic-3/resolve/main';
17
+ const ONNX_DIR = `${HF_BASE}/onnx`;
18
+ const TOTAL_STEP = 5;
19
+
20
+ const statusBar = document.getElementById('status-bar');
21
+ const sourceMeta = document.getElementById('source-meta');
22
+ const sectionsEl = document.getElementById('sections');
23
+ const playBtn = document.getElementById('play-btn');
24
+ const prevBtn = document.getElementById('prev-btn');
25
+ const nextBtn = document.getElementById('next-btn');
26
+ const langSelect = document.getElementById('lang-select');
27
+ const voiceSelect = document.getElementById('voice-select');
28
+ const speedRange = document.getElementById('speed-range');
29
+ const speedVal = document.getElementById('speed-val');
30
+ const fileSidebar = document.getElementById('file-sidebar');
31
+
32
+ let sessionData = null;
33
+ let textToSpeech = null;
34
+ let currentStyle = null;
35
+ let currentIndex = 0;
36
+ let playing = false;
37
+ let paused = false;
38
+ let audioCtx = null;
39
+ let activeSource = null;
40
+ let backendLabel = 'WASM';
41
+ let playGeneration = 0;
42
+ let pendingPlayIndex = null;
43
+ let synthInProgress = false;
44
+ /** @type {{ index: number, buffer: AudioBuffer } | null} */
45
+ let preparedAudio = null;
46
+ let prepareGeneration = 0;
47
+ /** Bloquea clics mientras corre prepare + intento de autoplay inicial. */
48
+ let startupBusy = false;
49
+
50
+ // Computed once at module load; CDN script is render-blocking so marked is already available.
51
+ const markedAvailable = typeof window !== 'undefined' && typeof window.marked !== 'undefined';
52
+ if (markedAvailable) {
53
+ window.marked.use({ gfm: true });
54
+ }
55
+
56
+ function setStatus(msg, isError = false) {
57
+ statusBar.textContent = msg;
58
+ statusBar.className = isError ? 'error' : '';
59
+ }
60
+
61
+ function getSessionId() {
62
+ const params = new URLSearchParams(window.location.search);
63
+ return params.get('id') || params.get('session');
64
+ }
65
+
66
+ /** SDD section titles that sound clearer when spoken with context (Spanish). */
67
+ const SPANISH_TITLE_SPOKEN = {
68
+ Alcance: 'El alcance del cambio',
69
+ 'Archivos fuera de alcance (doNotTouch)': 'Archivos fuera de alcance',
70
+ Requisitos: 'Requisitos del cambio',
71
+ };
72
+
73
+ function speakableTitle(title) {
74
+ const t = (title || '').trim();
75
+ if (!t) return t;
76
+ const meta = sessionData?.meta || {};
77
+ const isSpanish = meta.lang === 'es' || meta.artifactLanguage === 'spanish';
78
+ if (isSpanish && SPANISH_TITLE_SPOKEN[t]) return SPANISH_TITLE_SPOKEN[t];
79
+ return t;
80
+ }
81
+
82
+ /** Title + body for TTS (title gives context; body lines avoid duplicate #### headings). */
83
+ function sectionSpeakText(section) {
84
+ const title = speakableTitle(section.title);
85
+ const body = (section.text || '').trim();
86
+ if (title && body) return `${title}. ${body}`;
87
+ return title || body;
88
+ }
89
+
90
+ async function ensureAudioContext() {
91
+ if (!audioCtx) audioCtx = new AudioContext();
92
+ if (audioCtx.state === 'suspended') {
93
+ await audioCtx.resume();
94
+ }
95
+ return audioCtx.state === 'running';
96
+ }
97
+
98
+ function renderSections(sections) {
99
+ sectionsEl.innerHTML = '';
100
+
101
+ // T-03: offline fallback if marked.js was not loaded
102
+ if (!markedAvailable) {
103
+ setStatus('marked.js no disponible — mostrando texto plano');
104
+ }
105
+
106
+ sections.forEach((sec, i) => {
107
+ const el = document.createElement('article');
108
+ el.className = 'section';
109
+ el.dataset.index = String(i);
110
+ if (sec.title) {
111
+ const h = document.createElement('h2');
112
+ h.textContent = sec.title;
113
+ el.appendChild(h);
114
+ }
115
+ const contentEl = document.createElement('div');
116
+ contentEl.className = 'section-body';
117
+ if (markedAvailable && sec.rawMarkdown) {
118
+ // T-02: render markdown as HTML via marked.parse
119
+ contentEl.innerHTML = window.marked.parse(sec.rawMarkdown);
120
+ } else {
121
+ // CA-06: backward compat fallback to plain text
122
+ contentEl.textContent = sec.text || '';
123
+ }
124
+ el.appendChild(contentEl);
125
+ el.addEventListener('click', () => {
126
+ goToSection(i, true);
127
+ });
128
+ sectionsEl.appendChild(el);
129
+ });
130
+ highlightSection(0);
131
+ }
132
+
133
+ function highlightSection(index) {
134
+ currentIndex = index;
135
+ document.querySelectorAll('.section').forEach((el) => {
136
+ el.classList.toggle('active', Number(el.dataset.index) === index);
137
+ });
138
+ const active = document.querySelector(`.section[data-index="${index}"]`);
139
+ if (active) active.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
140
+ prevBtn.disabled = index <= 0;
141
+ nextBtn.disabled = !sessionData || index >= sessionData.sections.length - 1;
142
+ }
143
+
144
+ /**
145
+ * Render the file sidebar for folder-mode sessions.
146
+ * @param {Array<{ name: string, relPath: string, sections: object[] }>} files
147
+ * @param {string} activeFileName
148
+ */
149
+ function renderFileSidebar(files, activeFileName) {
150
+ fileSidebar.innerHTML = '';
151
+ fileSidebar.classList.remove('hidden');
152
+ files.forEach((file) => {
153
+ const btn = document.createElement('button');
154
+ btn.type = 'button';
155
+ btn.className = 'sidebar-file-btn';
156
+ btn.textContent = file.name;
157
+ btn.dataset.filename = file.name;
158
+ if (file.name === activeFileName) {
159
+ btn.classList.add('active');
160
+ }
161
+ btn.addEventListener('click', () => {
162
+ if (btn.classList.contains('active')) return;
163
+ switchToFile(file.name);
164
+ });
165
+ fileSidebar.appendChild(btn);
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Highlight the active file button in the sidebar.
171
+ * @param {string} fileName
172
+ */
173
+ function highlightSidebarFile(fileName) {
174
+ fileSidebar.querySelectorAll('.sidebar-file-btn').forEach((btn) => {
175
+ btn.classList.toggle('active', btn.dataset.filename === fileName);
176
+ });
177
+ }
178
+
179
+ /**
180
+ * Switch the main panel to display sections for the given file name.
181
+ * Mirrors the startup flow: prepare section 0 (with progress) then play from buffer.
182
+ * @param {string} fileName
183
+ */
184
+ async function switchToFile(fileName) {
185
+ if (!sessionData || sessionData.mode !== 'folder') return;
186
+ const fileEntry = (sessionData.files || []).find((f) => f.name === fileName);
187
+ if (!fileEntry) return;
188
+
189
+ // Stop any in-flight synthesis and playback
190
+ playGeneration += 1;
191
+ invalidatePreparedAudio();
192
+ stopAudio();
193
+ const switchGen = playGeneration;
194
+
195
+ // Update session data and UI immediately (sync, no blocking)
196
+ sessionData.sections = fileEntry.sections;
197
+ sessionData.selectedFile = fileName;
198
+ renderSections(fileEntry.sections);
199
+ highlightSidebarFile(fileName);
200
+ sourceMeta.textContent = fileEntry.relPath || fileName;
201
+ nextBtn.disabled = fileEntry.sections.length <= 1;
202
+
203
+ if (!textToSpeech || !currentStyle) return;
204
+
205
+ try {
206
+ // Prepare section 0 (shows "Preparando…" progress, synthesis runs once)
207
+ const prepared = await prepareSection(0);
208
+ if (playGeneration !== switchGen) return; // user switched to another file while preparing
209
+
210
+ if (!prepared) {
211
+ setStatus('Listo. Pulsa Play para iniciar.');
212
+ return;
213
+ }
214
+
215
+ // Play from the already-prepared buffer (fast, no re-synthesis)
216
+ await playSection(0);
217
+ } finally {
218
+ // Ensure the Play button is always re-enabled after a file switch
219
+ playBtn.disabled = false;
220
+ }
221
+ }
222
+
223
+ function fillLangSelect(selected) {
224
+ const LABEL = { es: 'Español', en: 'English' };
225
+ langSelect.innerHTML = '';
226
+ for (const code of ['es', 'en']) {
227
+ const opt = document.createElement('option');
228
+ opt.value = code;
229
+ opt.textContent = LABEL[code];
230
+ if (code === selected) opt.selected = true;
231
+ langSelect.appendChild(opt);
232
+ }
233
+ langSelect.disabled = true;
234
+ }
235
+
236
+ async function loadVoice(voiceId) {
237
+ const url = `${HF_BASE}/voice_styles/${voiceId}.json`;
238
+ currentStyle = await loadVoiceStyle([url], false);
239
+ }
240
+
241
+ async function initTts(onProgress) {
242
+ installCachedFetch((cur, total, name) => {
243
+ if (onProgress) onProgress(`Downloading model: ${name} (${cur}/${total})`);
244
+ });
245
+
246
+ if (!navigator.onLine && !(await hasAnyCachedModel())) {
247
+ throw new Error(
248
+ 'Internet connection required for the first Supertonic model download. Connect and reload.',
249
+ );
250
+ }
251
+
252
+ if (typeof WebAssembly === 'undefined') {
253
+ throw new Error('This browser does not support WebAssembly required for on-device TTS.');
254
+ }
255
+
256
+ let executionProvider = 'wasm';
257
+ try {
258
+ const result = await loadTextToSpeech(
259
+ ONNX_DIR,
260
+ { executionProviders: ['webgpu'], graphOptimizationLevel: 'all' },
261
+ (name, cur, total) => onProgress(`Loading ONNX (${cur}/${total}): ${name}`),
262
+ );
263
+ textToSpeech = result.textToSpeech;
264
+ executionProvider = 'webgpu';
265
+ backendLabel = 'WebGPU';
266
+ } catch (_) {
267
+ const result = await loadTextToSpeech(
268
+ ONNX_DIR,
269
+ { executionProviders: ['wasm'], graphOptimizationLevel: 'all' },
270
+ (name, cur, total) => onProgress(`Loading ONNX (${cur}/${total}): ${name}`),
271
+ );
272
+ textToSpeech = result.textToSpeech;
273
+ backendLabel = 'WASM';
274
+ }
275
+
276
+ const badge = document.createElement('span');
277
+ badge.id = 'backend-badge';
278
+ badge.textContent = backendLabel;
279
+ statusBar.appendChild(badge);
280
+ }
281
+
282
+ function stopAudio({ keepPaused = false } = {}) {
283
+ if (activeSource) {
284
+ try {
285
+ activeSource.stop();
286
+ } catch (_) {
287
+ // already stopped
288
+ }
289
+ activeSource = null;
290
+ }
291
+ playing = false;
292
+ if (!keepPaused) {
293
+ paused = false;
294
+ playBtn.textContent = 'Play';
295
+ }
296
+ }
297
+
298
+ async function synthesizeTextToBuffer(text, onProgress) {
299
+ const primaryLang = langSelect.value;
300
+ const speed = parseFloat(speedRange.value);
301
+ const segments = splitBilingualText(text, primaryLang).filter((s) => s.text.trim());
302
+ const sampleRate = textToSpeech.sampleRate;
303
+ const multiLang = segments.length > 1;
304
+
305
+ /** @type {Float32Array[]} */
306
+ const wavParts = [];
307
+ const gapLen = Math.floor(MIXED_LANG_GAP_SEC * sampleRate);
308
+ const gap = gapLen > 0 ? new Float32Array(gapLen) : null;
309
+ let segNum = 0;
310
+
311
+ for (let i = 0; i < segments.length; i++) {
312
+ const { text: segText, lang } = segments[i];
313
+ segNum += 1;
314
+ const isLast = i === segments.length - 1;
315
+ const silenceAfter = multiLang ? 0 : 0.3;
316
+ const { wav, duration } = await textToSpeech.call(
317
+ segText,
318
+ lang,
319
+ currentStyle,
320
+ TOTAL_STEP,
321
+ speed,
322
+ silenceAfter,
323
+ multiLang
324
+ ? (step, total) => {
325
+ if (typeof onProgress === 'function') {
326
+ onProgress(step, total, { index: segNum, total: segments.length });
327
+ }
328
+ }
329
+ : onProgress,
330
+ );
331
+ const rawLen = Math.floor(sampleRate * duration[0]);
332
+ let chunk = trimTrailingSilence(wav.slice(0, rawLen), sampleRate);
333
+ chunk = trimLeadingSilence(chunk); // threshold uses default 0.006 — never pass sampleRate here
334
+ if (chunk.length === 0) continue;
335
+ if (wavParts.length > 0 && gap) {
336
+ wavParts.push(gap);
337
+ }
338
+ wavParts.push(Float32Array.from(chunk));
339
+ if (!multiLang && isLast && silenceAfter > 0) {
340
+ const tailPad = Math.floor(silenceAfter * sampleRate);
341
+ wavParts.push(new Float32Array(tailPad));
342
+ }
343
+ }
344
+
345
+ if (wavParts.length === 0) return null;
346
+
347
+ const wavOut = concatWavParts(wavParts);
348
+ const wavBuffer = writeWavFile(wavOut, textToSpeech.sampleRate);
349
+ if (!audioCtx) audioCtx = new AudioContext();
350
+ const blob = new Blob([wavBuffer], { type: 'audio/wav' });
351
+ const arrayBuf = await blob.arrayBuffer();
352
+ return audioCtx.decodeAudioData(arrayBuf);
353
+ }
354
+
355
+ async function synthesizeSection(index, onProgress) {
356
+ const section = sessionData.sections[index];
357
+ const text = sectionSpeakText(section);
358
+ if (!text) return null;
359
+ return synthesizeTextToBuffer(text, onProgress);
360
+ }
361
+
362
+ async function prepareSection(index, { updateStatus = true } = {}) {
363
+ if (!textToSpeech || !currentStyle || !sessionData) return false;
364
+ const gen = ++prepareGeneration;
365
+ synthInProgress = true;
366
+ if (updateStatus) {
367
+ setStatus(`Preparando sección ${index + 1} de ${sessionData.sections.length}…`);
368
+ }
369
+ try {
370
+ const buffer = await synthesizeSection(index, (step, total, segInfo) => {
371
+ if (!updateStatus) return;
372
+ if (segInfo) {
373
+ setStatus(
374
+ `Preparando audio (${segInfo.index}/${segInfo.total} trozos, paso ${step}/${total})…`,
375
+ );
376
+ } else {
377
+ setStatus(`Preparando audio (${step}/${total})…`);
378
+ }
379
+ });
380
+ if (gen !== prepareGeneration) return false;
381
+ if (!buffer) {
382
+ preparedAudio = null;
383
+ if (index + 1 < sessionData.sections.length) {
384
+ return prepareSection(index + 1, { updateStatus });
385
+ }
386
+ return false;
387
+ }
388
+ preparedAudio = { index, buffer };
389
+ return true;
390
+ } catch (err) {
391
+ if (gen === prepareGeneration) {
392
+ preparedAudio = null;
393
+ if (updateStatus) setStatus(`Error TTS: ${err.message}`, true);
394
+ }
395
+ return false;
396
+ } finally {
397
+ if (gen === prepareGeneration) synthInProgress = false;
398
+ }
399
+ }
400
+
401
+ function invalidatePreparedAudio() {
402
+ prepareGeneration += 1;
403
+ preparedAudio = null;
404
+ }
405
+
406
+ function startBufferPlayback(index, audioBuffer, generation) {
407
+ if (generation !== playGeneration) return false;
408
+ if (activeSource) {
409
+ try {
410
+ activeSource.stop();
411
+ } catch (_) {
412
+ // already stopped
413
+ }
414
+ activeSource = null;
415
+ }
416
+ playing = true;
417
+ paused = false;
418
+ activeSource = audioCtx.createBufferSource();
419
+ activeSource.buffer = audioBuffer;
420
+ activeSource.connect(audioCtx.destination);
421
+ activeSource.onended = () => {
422
+ activeSource = null;
423
+ if (!playing || generation !== playGeneration) return;
424
+ if (index < sessionData.sections.length - 1) {
425
+ playSection(index + 1);
426
+ } else if (sessionData.mode === 'folder') {
427
+ // Auto-advance to the next file in the sidebar
428
+ const files = sessionData.files || [];
429
+ const curFileIdx = files.findIndex((f) => f.name === sessionData.selectedFile);
430
+ const nextFile = files[curFileIdx + 1];
431
+ if (nextFile) {
432
+ switchToFile(nextFile.name);
433
+ } else {
434
+ playing = false;
435
+ playBtn.textContent = 'Play';
436
+ setStatus('Lectura terminada.');
437
+ }
438
+ } else {
439
+ playing = false;
440
+ playBtn.textContent = 'Play';
441
+ setStatus('Lectura terminada.');
442
+ }
443
+ };
444
+ playBtn.textContent = 'Pause';
445
+ setStatus(`Reproduciendo sección ${index + 1} de ${sessionData.sections.length}…`);
446
+ activeSource.start();
447
+ return true;
448
+ }
449
+
450
+ /** Reproduce el buffer ya preparado tras un clic (evita re-sintetizar y solapes). */
451
+ async function playPreparedBuffer(index) {
452
+ if (!preparedAudio || preparedAudio.index !== index) {
453
+ return playSection(index);
454
+ }
455
+ const { buffer } = preparedAudio;
456
+ preparedAudio = null;
457
+ pendingPlayIndex = null;
458
+ const generation = ++playGeneration;
459
+ highlightSection(index);
460
+ stopAudio();
461
+ playing = true;
462
+ paused = false;
463
+ const canPlay = await ensureAudioContext();
464
+ if (!canPlay || generation !== playGeneration) {
465
+ preparedAudio = { index, buffer };
466
+ pendingPlayIndex = index;
467
+ playing = false;
468
+ playBtn.textContent = 'Play';
469
+ setStatus('Pulsa Play para iniciar (el navegador requiere un clic).');
470
+ return false;
471
+ }
472
+ return startBufferPlayback(index, buffer, generation);
473
+ }
474
+
475
+ async function playSection(index) {
476
+ if (!textToSpeech || !currentStyle || !sessionData) return false;
477
+ const section = sessionData.sections[index];
478
+ const text = sectionSpeakText(section);
479
+ if (!text) {
480
+ if (index < sessionData.sections.length - 1) {
481
+ return playSection(index + 1);
482
+ }
483
+ return false;
484
+ }
485
+
486
+ const generation = ++playGeneration;
487
+ pendingPlayIndex = null;
488
+ highlightSection(index);
489
+ stopAudio();
490
+ playing = true;
491
+ paused = false;
492
+
493
+ let audioBuffer = null;
494
+ if (preparedAudio && preparedAudio.index === index) {
495
+ audioBuffer = preparedAudio.buffer;
496
+ preparedAudio = null;
497
+ } else {
498
+ synthInProgress = true;
499
+ setStatus(`Preparando sección ${index + 1} de ${sessionData.sections.length}…`);
500
+ try {
501
+ audioBuffer = await synthesizeSection(index, (step, total) => {
502
+ setStatus(`Preparando audio (${step}/${total})…`);
503
+ });
504
+ } catch (err) {
505
+ synthInProgress = false;
506
+ playing = false;
507
+ playBtn.textContent = 'Play';
508
+ setStatus(`Error TTS: ${err.message}`, true);
509
+ return false;
510
+ }
511
+ synthInProgress = false;
512
+ }
513
+
514
+ if (!playing || generation !== playGeneration) return false;
515
+ if (!audioBuffer) {
516
+ if (index < sessionData.sections.length - 1) {
517
+ return playSection(index + 1);
518
+ }
519
+ return false;
520
+ }
521
+
522
+ if (!audioCtx) audioCtx = new AudioContext();
523
+ if (audioCtx.state !== 'running') {
524
+ audioCtx.resume().catch(() => {});
525
+ // Give the browser one tick to process the resume before checking state.
526
+ // If Chrome allows autoplay it transitions synchronously; if blocked it stays suspended.
527
+ await new Promise((r) => setTimeout(r, 50));
528
+ }
529
+
530
+ if (audioCtx.state !== 'running') {
531
+ preparedAudio = { index, buffer: audioBuffer };
532
+ pendingPlayIndex = index;
533
+ playing = false;
534
+ playBtn.textContent = 'Play';
535
+ setStatus('Listo. Pulsa Play para iniciar.');
536
+ return false;
537
+ }
538
+
539
+ return startBufferPlayback(index, audioBuffer, generation);
540
+ }
541
+
542
+ function goToSection(index, startPlay) {
543
+ if (!sessionData) return;
544
+ const i = Math.max(0, Math.min(index, sessionData.sections.length - 1));
545
+ playGeneration += 1;
546
+ invalidatePreparedAudio();
547
+ stopAudio();
548
+ highlightSection(i);
549
+ if (startPlay) {
550
+ playing = true;
551
+ playSection(i);
552
+ }
553
+ }
554
+
555
+ playBtn.addEventListener('click', async () => {
556
+ if (!textToSpeech || startupBusy) return;
557
+ if (synthInProgress && !preparedAudio) {
558
+ setStatus('Preparando audio… un momento y vuelve a pulsar Play.');
559
+ return;
560
+ }
561
+ if (playing && activeSource) {
562
+ paused = true;
563
+ stopAudio({ keepPaused: true });
564
+ playBtn.textContent = 'Resume';
565
+ return;
566
+ }
567
+ // Cancela autoplay u otro playSection en vuelo.
568
+ playGeneration += 1;
569
+ stopAudio();
570
+
571
+ const idx = pendingPlayIndex !== null ? pendingPlayIndex : currentIndex;
572
+ paused = false;
573
+ if (preparedAudio?.index === idx) {
574
+ await playPreparedBuffer(idx);
575
+ return;
576
+ }
577
+ await playSection(idx);
578
+ });
579
+
580
+ prevBtn.addEventListener('click', () => goToSection(currentIndex - 1, playing));
581
+ nextBtn.addEventListener('click', () => goToSection(currentIndex + 1, playing));
582
+
583
+ speedRange.addEventListener('input', () => {
584
+ speedVal.textContent = speedRange.value;
585
+ });
586
+
587
+ voiceSelect.addEventListener('change', async () => {
588
+ invalidatePreparedAudio();
589
+ try {
590
+ setStatus('Loading voice style…');
591
+ await loadVoice(voiceSelect.value);
592
+ setStatus('Voz actualizada. Preparando sección…');
593
+ await prepareSection(currentIndex);
594
+ setStatus('Listo. Pulsa Play.');
595
+ } catch (err) {
596
+ setStatus(`Voice load failed: ${err.message}`, true);
597
+ }
598
+ });
599
+
600
+ langSelect.addEventListener('change', () => {
601
+ invalidatePreparedAudio();
602
+ });
603
+
604
+ speedRange.addEventListener('change', () => {
605
+ speedVal.textContent = speedRange.value;
606
+ invalidatePreparedAudio();
607
+ });
608
+
609
+ async function main() {
610
+ const sessionId = getSessionId();
611
+ if (!sessionId) {
612
+ setStatus('Missing session id in URL (?id=…)', true);
613
+ return;
614
+ }
615
+
616
+ try {
617
+ const res = await fetch(`/api/read-spec/${encodeURIComponent(sessionId)}`);
618
+ if (!res.ok) {
619
+ const err = await res.json().catch(() => ({}));
620
+ setStatus(err.error || `Session not found (${res.status})`, true);
621
+ return;
622
+ }
623
+ sessionData = await res.json();
624
+
625
+ // Normalize mode: sessions without 'mode' field are treated as "file" (backward compat)
626
+ if (!sessionData.mode) sessionData.mode = 'file';
627
+
628
+ if (sessionData.mode === 'folder') {
629
+ // Folder mode: show sidebar and load sections for selected file
630
+ const files = sessionData.files || [];
631
+ const selectedFileName = sessionData.selectedFile || (files[0] && files[0].name) || '';
632
+ const selectedFile = files.find((f) => f.name === selectedFileName) || files[0];
633
+ if (selectedFile) {
634
+ sessionData.sections = selectedFile.sections;
635
+ sourceMeta.textContent = selectedFile.relPath || selectedFileName;
636
+ } else {
637
+ sourceMeta.textContent = sessionData.sourcePath || 'spec';
638
+ }
639
+ renderSections(sessionData.sections || []);
640
+ renderFileSidebar(files, selectedFileName);
641
+ } else {
642
+ // File mode: no sidebar
643
+ sourceMeta.textContent = sessionData.sourcePath || 'spec';
644
+ renderSections(sessionData.sections || []);
645
+ }
646
+
647
+ const meta = sessionData.meta || {};
648
+ fillLangSelect(meta.lang || 'es');
649
+ voiceSelect.value = meta.voice || 'M3';
650
+ speedRange.value = String(meta.speed ?? 1);
651
+ speedVal.textContent = speedRange.value;
652
+
653
+ setStatus('Loading TTS model (first visit may take several minutes)…');
654
+ await initTts((msg) => setStatus(msg));
655
+ await loadVoice(voiceSelect.value);
656
+
657
+ playBtn.textContent = 'Play';
658
+ nextBtn.disabled = sessionData.sections.length <= 1;
659
+
660
+ startupBusy = true;
661
+ try {
662
+ const prepared = await prepareSection(0);
663
+ if (!prepared) {
664
+ setStatus('Listo. Pulsa Play para iniciar la lectura.');
665
+ return;
666
+ }
667
+
668
+ setStatus('Iniciando lectura automática…');
669
+ const started = await playSection(0);
670
+ if (!started) {
671
+ setStatus('Listo. Pulsa Play (el audio ya está preparado; el navegador exige un clic).');
672
+ }
673
+ } finally {
674
+ startupBusy = false;
675
+ playBtn.disabled = false;
676
+ }
677
+ } catch (err) {
678
+ console.error(err);
679
+ setStatus(err.message || String(err), true);
680
+ startupBusy = false;
681
+ playBtn.disabled = false;
682
+ }
683
+ }
684
+
685
+ main();