retiam 2.0.2 → 2.0.3

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 (35) hide show
  1. package/agent/AGENTS.md +4 -1
  2. package/agent/skill/SKILL.md +7 -4
  3. package/agent/skill/examples/NarratedMotion.ts +10 -2
  4. package/agent/skill/references/api.md +4 -2
  5. package/agent/skill/references/explainers.md +4 -2
  6. package/agent/skill/references/quality.md +3 -0
  7. package/agent/skill/references/tts.md +154 -60
  8. package/bin/lesson.js +39 -12
  9. package/dist/{UTIF-BrQ3Ng5l.min.js → UTIF-D6vx2UEX.min.js} +1 -1
  10. package/dist/{UTIF-CN0-pEnd.js → UTIF-mkffT70R.js} +1 -1
  11. package/dist/{index-yQIdejOj.js → index-CdBPrYbr.js} +3818 -3785
  12. package/dist/{index-3sAseSJQ.min.js → index-jgFyrc2D.min.js} +175 -175
  13. package/dist/libs/explainer/index.js +74 -73
  14. package/dist/libs/explainer/plan.js +162 -140
  15. package/dist/libs/explainer/types.d.ts +5 -0
  16. package/dist/libs/tts/animations/Speak.d.ts +13 -7
  17. package/dist/libs/tts/components/TTSNarrationComponent.d.ts +9 -7
  18. package/dist/libs/tts/core/TTSCache.d.ts +3 -3
  19. package/dist/libs/tts/core/TTSClient.d.ts +3 -3
  20. package/dist/libs/tts/core/types.d.ts +48 -6
  21. package/dist/libs/tts/index.d.ts +2 -1
  22. package/dist/libs/tts/index.js +165 -672
  23. package/dist/libs/tts/node/index.d.ts +10 -0
  24. package/dist/libs/tts/node/index.js +791 -0
  25. package/dist/libs/tts/providers/ElevenLabsTTSProvider.d.ts +30 -0
  26. package/dist/libs/tts/providers/ITTSProvider.d.ts +2 -2
  27. package/dist/libs/vite/index.js +1 -1
  28. package/dist/{main-lQwYAed8.min.js → main-Cc4QQbSK.min.js} +1 -1
  29. package/dist/{main-BTk_9jtl.js → main-DMu_5Qy7.js} +1 -1
  30. package/dist/{msEdgeV2-BLEnvPJa.js → msEdgeV2-D3w4Xsn-.js} +30 -29
  31. package/dist/retiam.es.js +1 -1
  32. package/dist/retiam.es.min.js +1 -1
  33. package/dist/src/engine/entities/composites/text/mathjaxInterop.d.ts +6 -0
  34. package/package.json +4 -2
  35. package/readme.md +20 -1
package/agent/AGENTS.md CHANGED
@@ -32,7 +32,10 @@ and WebGPU APIs.
32
32
  - Use Retiam's built-in libraries for their supported features. For generated
33
33
  narration, use `retiam/tts`; read the skill's `references/tts.md` and
34
34
  `examples/NarratedMotion.ts`. Use another speech generator only when the user
35
- requests it. Diagnose native TTS failures instead of silently installing Python
35
+ requests it. ElevenLabs is a native provider: pass its key to provider.init(),
36
+ direct expression with model-compatible controls, and keep captions clean.
37
+ Follow the TTS reference; no ElevenLabs Vite setup is needed. Diagnose failures
38
+ instead of silently installing Python
36
39
  TTS tools or replacing the narration pipeline.
37
40
  - Use `retiam/explainer` for prepared speech, named cues, explicit silent pauses
38
41
  and synchronized beats. `npm run lesson:check` validates the contract;
@@ -57,10 +57,13 @@ and `retiam/rough` for sketch styling.
57
57
  For voiceover, speech, or narration, read [references/tts.md](references/tts.md)
58
58
  and [examples/NarratedMotion.ts](examples/NarratedMotion.ts) before authoring.
59
59
  That example isolates speech integration; it is not a finished video's visual standard.
60
- Use `retiam/tts` for synthesis, timing, subtitles, and the scene audio bus. Use a
61
- different speech generator only when the user requests it. If Retiam TTS fails,
62
- check its Vite middleware, endpoint, network access, and audio unlock; report the
63
- actual blocker instead of silently installing Python TTS tools or switching providers.
60
+ Use `retiam/tts` for synthesis, timing, subtitles, and the scene audio bus. Edge
61
+ is the default; when the user chooses ElevenLabs, initialize its native provider
62
+ with the caller's key directly, without Vite configuration. Direct the chosen
63
+ voice for natural questions, discoveries and resolution; keep delivery markup
64
+ out of captions and prepare audio before locking cues. The TTS reference covers
65
+ model-specific expression and prepared assets. Diagnose the selected provider's
66
+ actual failure instead of silently installing Python tools or switching providers.
64
67
 
65
68
  ## Author for the requested result
66
69
 
@@ -1,7 +1,15 @@
1
1
  import {
2
2
  CENTER, Circle, Concurrent, Create, CustomAnimation, Line, Scene, Text, Write, hex,
3
3
  } from 'retiam';
4
- import { Speak, Subtitles } from 'retiam/tts';
4
+ import { ElevenLabsTTSProvider, Speak, Subtitles, TTS } from 'retiam/tts';
5
+
6
+ // Optional application setup before starting the scene. Credentials come from
7
+ // the caller; browser-supplied keys are visible in that browser. No Vite changes.
8
+ export async function initializeElevenLabs(apiKey: string, voiceId: string) {
9
+ const provider = new ElevenLabsTTSProvider();
10
+ await provider.init({ apiKey, voiceId, modelId: 'eleven_v3', voiceSettings: { stability: 0.5 } });
11
+ TTS.setProvider(provider);
12
+ }
5
13
 
6
14
  export default class NarratedMotion extends Scene {
7
15
  async run() {
@@ -19,7 +27,7 @@ export default class NarratedMotion extends Scene {
19
27
  this.camera.frame([[-4.5, -2.5, 0], [4.5, 3, 0]], { padding: 0.25 });
20
28
 
21
29
  const narration = new Speak('Watch the marker move from left to right.', {
22
- voice: 'en-US-JennyNeural', speed: 1, subtitles, entity: marker,
30
+ subtitles, entity: marker,
23
31
  pulseAmplitude: 0, volume: 1,
24
32
  });
25
33
  // Headless tools must wait for synthesis, not a promise requiring timeline steps.
@@ -72,6 +72,7 @@ Prefer absolute values derived from progress over accumulating per-frame offsets
72
72
  | `retiam/graphs` | Graph and automata entities |
73
73
  | `retiam/physics` | Physics worlds, bodies, joints, backend adapters |
74
74
  | `retiam/tts` | Speech, narration, and subtitles |
75
+ | `retiam/tts/node` | Renderer-free speech synthesis for Node preparation scripts |
75
76
  | `retiam/rough` | Rough styling utilities |
76
77
  | `retiam/vite` | Node-side Vite middleware for Retiam TTS; import in Vite config |
77
78
 
@@ -79,8 +80,9 @@ Follow the installed sublibrary's index declarations to its options and examples
79
80
  before using it. Optional features can have their own asset or environment needs.
80
81
 
81
82
  For narration, follow [tts.md](tts.md) and the checked
82
- [NarratedMotion.ts](../examples/NarratedMotion.ts) example. The native TTS route is
83
- already enabled by new project Vite configurations. Existing projects can add
83
+ [NarratedMotion.ts](../examples/NarratedMotion.ts) example. ElevenLabs initializes
84
+ directly with a key on `ElevenLabsTTSProvider`, without Vite changes. The Edge TTS
85
+ route is already enabled by new project Vite configurations. Existing projects can add
84
86
  `msEdgeTTSPlugin` from `retiam/vite`; see the TTS guide for the configuration and
85
87
  failure checks. Never import the Vite plugin in browser scene code.
86
88
 
@@ -57,8 +57,10 @@ Do not expose the candidate or subtitle answer during that prediction pause.
57
57
  Keep performance notes out of spoken text. A narration segment has an `id`,
58
58
  `text`, and optional `pauseAfterMs`. Segments need not correspond one-to-one to
59
59
  visual actions. Split at purposeful thinking pauses, retaining coherent speech
60
- between them. The built-in voice remains the default. Change a provider only when
61
- requested; additional providers may normalize recordings into `PreparedSpeech`.
60
+ between them. Edge remains the default. When ElevenLabs is selected, use its
61
+ native provider and the initialization/performance guidance in [tts.md](tts.md).
62
+ Keep caption/cue `text` clean; put delivery markup in a segment's `speechText`.
63
+ Other generators may normalize recordings into `PreparedSpeech`.
62
64
  Include narration in an explainer unless the user requests a silent treatment.
63
65
  The sample's disabled narration is an offline-preview convenience, not a reason
64
66
  to omit the requested video's spoken explanation.
@@ -28,6 +28,9 @@ style or palette overrides this default.
28
28
  `retiam/tts`. Use conversational questions and precise language, synchronize
29
29
  speech with visible events, and allow meaningful silent pauses. Keep requested
30
30
  subtitles unobtrusive so they leave room for the mathematical construction.
31
+ Match vocal delivery to the beat's question, discovery, or resolution. For
32
+ expressive ElevenLabs delivery follow [tts.md](tts.md): choose model-compatible
33
+ cues, listen to a representative sample, and preserve clean caption text.
31
34
 
32
35
  The goal is a clear, satisfying visual argument. Choose 2D or 3D according to
33
36
  the concept. Use a slide, dashboard, or another aesthetic when requested.
@@ -1,72 +1,166 @@
1
- # Native Retiam narration
1
+ # Retiam narration: synthesis and performance
2
2
 
3
- Generate speech for Retiam scenes through `retiam/tts`. It supplies synthesis,
4
- timing, subtitle synchronization, and playback through the scene's voiceover bus.
5
- The built-in provider uses Microsoft Edge neural speech over the network. It
6
- does not require installing Python TTS packages.
3
+ Use `retiam/tts` for speech, timing, subtitles, and the scene voiceover bus.
4
+ Microsoft Edge is the default. Use the native `ElevenLabsTTSProvider` when the
5
+ user selects ElevenLabs and supplies credentials. Preserve the chosen provider;
6
+ diagnose failures rather than silently switching generators or installing Python
7
+ TTS tools. Read the installed declarations before using newer options.
7
8
 
8
- Use [NarratedMotion.ts](../examples/NarratedMotion.ts) as the executable recipe.
9
- Copy it into `src/scenes/` to preview or render it. Adapt the text, voice, and
10
- motion to the brief. Use a different speech generator only if the user requests
11
- one; diagnose failures in this path instead of silently replacing it.
9
+ ## ElevenLabs: initialize the provider directly
12
10
 
13
- ## Browser and renderer setup
11
+ No ElevenLabs Vite plugin or Vite configuration is needed. For a self-contained
12
+ scene example, put `new ElevenLabsTTSProvider()`, `await provider.init({ apiKey,
13
+ voiceId, ... })`, and `await provider.synthesize(...)` inside `Scene.run()`.
14
+ Pass the key directly to initialization. A `.env`, `process.env`, a preparation
15
+ script, and pre-generated audio are optional workflows, not setup requirements;
16
+ do not introduce them when the user requests a simple in-scene example.
17
+ Register synthesis in `composer.pendingTasks` before awaiting it for headless
18
+ rendering. A provider instance can be used directly; selecting it globally is
19
+ only needed when using the `TTS`, `Speak`, or `speak()` facades.
14
20
 
15
- New projects enable the native proxy in their Vite configuration. For an existing
16
- project using an engine release that provides `retiam/vite`, preserve its other
17
- plugins and add:
21
+ To select an initialized provider globally:
18
22
 
19
23
  ```ts
20
- import { defineConfig } from 'vite';
21
- import { msEdgeTTSPlugin } from 'retiam/vite';
24
+ import { ElevenLabsTTSProvider, TTS } from 'retiam/tts';
22
25
 
23
- export default defineConfig({ plugins: [msEdgeTTSPlugin()] });
26
+ export async function useExpressiveVoice(apiKey: string, voiceId: string) {
27
+ const provider = new ElevenLabsTTSProvider();
28
+ await provider.init({
29
+ apiKey, voiceId,
30
+ modelId: 'eleven_v3',
31
+ voiceSettings: { stability: 0.5 },
32
+ });
33
+ TTS.setProvider(provider);
34
+ return provider;
35
+ }
24
36
  ```
25
37
 
26
- This is a **server-side** import. Scene code imports speech APIs from `retiam/tts`.
27
- The middleware serves `/api/tts/msedge/synthesize` in Vite development and preview
28
- servers, including the Vite server started by Retiam's render/snapshot commands.
29
- A static production host needs an equivalent server endpoint or pre-generated
30
- audio; bundling the page alone does not host speech synthesis.
38
+ `apiKey` and `voiceId` come from the caller's setup, not literal credentials
39
+ committed to a scene. Browser initialization uses the caller's key directly and
40
+ makes it accessible to that browser. For private preparation use Node's
41
+ `retiam/tts/node` entry point, with the same provider API, and ship the generated
42
+ audio. Do not add ElevenLabs credentials or middleware to `vite.config.ts`.
43
+ Initialization validates local settings without generating audio or fetching
44
+ voices. Use `await provider.fetchVoices()` when choosing a voice;
45
+ `provider.getVoices()` and `TTS.findVoice()` then use that fetched catalog.
46
+ Require a real ElevenLabs voice ID, not an Edge ID or an invented display name.
47
+
48
+ The default model is `eleven_multilingual_v2`. Select an available expressive
49
+ model such as `eleven_v3` or `eleven_v4` deliberately when directing with audio
50
+ tags. Keep a compatible model/voice pairing throughout a speaker's performance.
51
+ The provider accepts open model IDs, MP3 output formats, model-specific voice
52
+ settings, language, seed, pronunciation dictionaries, and neighboring-text context.
53
+ Per-line overrides go in `elevenlabs`; `speed` can also be supplied at the top
54
+ level. Omitted voice/rate settings inherit provider defaults through `Speak`,
55
+ `speak()` and `TTSNarrationComponent`. `TTS.init()` preserves the selected provider;
56
+ `TTS.configure()` explicitly restores the default Edge client.
57
+
58
+ ## Direct a natural explanation
59
+
60
+ Choose the emotional action of a beat: inviting a prediction, weighing a
61
+ possibility, making a discovery, or calmly resolving the explanation. Let the
62
+ voice express that action. A constant promotional excitement or a cue on every
63
+ sentence makes an explanation harder to follow. Keep the speaker recognizable
64
+ across beats, with a voice suited to the requested language and audience.
65
+
66
+ Write speech for listening: concrete questions, contractions, varied sentence
67
+ lengths, and emphasis on the relationship changing on screen. Read technical
68
+ symbols as intended speech; keep formulas and concise notation in the visuals.
69
+ Prefer phrase or paragraph performances to synthesizing isolated words, so the
70
+ model can carry an idea through to its conclusion. Split at genuine explanatory
71
+ boundaries, preserving neighboring context when needed.
72
+
73
+ Model-specific controls:
74
+
75
+ - On v3/v4, use occasional delivery tags such as `[curious]` or `[excited]` for
76
+ a meaningful change in delivery. These models do not support SSML break tags;
77
+ use punctuation and phrasing for vocal pauses. Test the chosen voice's delivery.
78
+ - On v2 models, use natural punctuation and supported SSML breaks sparingly.
79
+ Do not assume v3 audio tags work on v2 or Edge. Edge supports its own `pitch`
80
+ prosody; ElevenLabs rejects nonzero Edge pitch settings.
81
+ - Lower stability can increase variation; higher stability can reduce it.
82
+ Treat settings as voice/model-dependent. Adjust a short representative sample
83
+ after listening, rather than maximizing style or copying one preset everywhere.
84
+ - Supported speed is 0.7–1.2. Start close to the voice's natural pace and use
85
+ explicit scene holds for prediction time. A seed is best-effort, not guaranteed
86
+ repeatability; approved local audio provides repeatable rendering.
87
+
88
+ Keep performance directions out of captions. Supply clean `displayText` when
89
+ synthesis text contains delivery or pronunciation markup:
90
+
91
+ ```ts
92
+ import { Speak } from 'retiam/tts';
93
+
94
+ export function directedLine() {
95
+ return new Speak('[curious] What changes if we move this point?', {
96
+ displayText: 'What changes if we move this point?',
97
+ elevenlabs: { modelId: 'eleven_v3', voiceSettings: { stability: 0.5 } },
98
+ });
99
+ }
100
+ ```
101
+
102
+ Listen for pronunciation, expression, unwanted vocalizations, rushed endings,
103
+ and pauses before approving a voice. Regenerate only the lines that need it.
104
+ Keep approved audio across visual revisions; settings alone cannot lock a
105
+ performance. Do not spend credits merely to test whether initialization works.
106
+ See the model-specific [ElevenLabs delivery guide](https://elevenlabs.io/docs/overview/capabilities/text-to-speech/best-practices)
107
+ when using an unfamiliar model or control.
108
+
109
+ ## Lessons and prepared speech
110
+
111
+ For ElevenLabs lessons set `narration.provider` to `elevenlabs`, set `voice` to
112
+ the chosen voice ID, and put model/delivery settings under `narration.elevenlabs`.
113
+ Keep each segment's `text` clean for captions and cue phrases. Optional
114
+ `speechText` contains the exact synthesis script, including delivery tags.
115
+ Keep actual prediction/reveal gaps in `pauseAfterMs`, separate from vocal rhythm.
116
+
117
+ `retiam lesson prepare` supports both native providers. For ElevenLabs the CLI
118
+ reads `ELEVENLABS_API_KEY` from its environment and passes it to provider.init().
119
+ The key does not go in the lesson JSON. It caches audio and word timings and
120
+ writes `lesson/prepared.json` plus `public/narration/*.mp3`. Reuse those assets
121
+ for subsequent preview and render. Changing delivery text or voice settings
122
+ invalidates the corresponding prepared speech. The runtime can also synthesize
123
+ using an initialized matching global provider or an explicit synthesis adapter.
124
+
125
+ ## Edge browser and renderer setup
126
+
127
+ Only Edge uses the existing Vite proxy. New projects already enable it. For an
128
+ older project, preserve other plugins and add `msEdgeTTSPlugin` from
129
+ `retiam/vite` on the server side. Its route is `/api/tts/msedge/synthesize` in
130
+ Vite development/preview and Retiam's renderer. Static hosts need that server
131
+ route or prepared audio. Never import the Vite plugin into scene code.
31
132
 
32
133
  ## Timeline recipe
33
134
 
34
- - Use `Speak` as an animation for simultaneous narration and motion. Set `entity`
35
- to an entity already added to the scene so speech uses that scene's audio bus.
36
- Call `prepareAudio()` first; its result sets the animation's exact duration.
37
- - Register the **synthesis promise** in `this.composer.pendingTasks` before
38
- awaiting it. Headless tools can then wait for network speech generation without
39
- advancing the scene past the narration. Do not register `this.play(...)` or
40
- `speak(...)` promises there: they need timeline updates and would deadlock.
41
- - Set accompanying motion duration from the prepared `Speak.duration`. Avoid
42
- guessing speech duration from word counts. Also pass that duration explicitly
43
- to `Concurrent` so the group's defaults do not rescale the prepared narration.
44
- - For sequential narration, `await speak(this, text, options)` synthesizes,
45
- plays, synchronizes optional subtitles, and waits for the utterance duration.
46
- Pre-synthesize that text with `TTS.synthesize(text, options)` and register only
47
- its synthesis promise before headless rendering; the default provider caches
48
- the result in memory.
49
- - `TTS.speak(...)` is a different, lower-level client facade. Its playback result
50
- does not itself provide the scene's `wait(...)` pacing.
51
- - Add `new Subtitles({ fontSize, bottomOffset, maxWidth, ... })` to the scene, and
52
- pass it as `subtitles`. Keep the caption area within the shot. Caption timing
53
- uses the generated audio metadata.
54
- - `voice` is a voice ID such as `en-US-JennyNeural`; `speed` is a multiplier
55
- such as `1.0`, `pitch` accepts values such as `'+0Hz'`, and playback `volume`
56
- is 0–1. Follow installed types for more options.
57
-
58
- ## Diagnose native TTS failures
59
-
60
- 1. A missing `retiam/vite` import means the installed engine predates this setup
61
- or its build is incomplete. Check `retiam context --json` and the installed
62
- package version before changing providers.
63
- 2. HTTP 404 at `/api/tts/msedge/synthesize` means the middleware is absent or the
64
- configured endpoint differs. Fix the Vite configuration and restart the server.
65
- 3. HTTP 502 means the proxy ran but synthesis failed. Read the server error;
66
- check network access and the requested voice. Do not present silent audio as
67
- a successful narration render.
68
- 4. Browser autoplay may require a click or keypress to unlock audio in Studio.
69
- The headless CLI supplies its autoplay flag.
70
- 5. For exported video, verify an audio stream exists and listen to the result.
71
- Snapshots verify visuals only. Speech generation requires network access on a
72
- cache miss; the middleware caches audio in `node_modules/.cache/msedge-tts`.
135
+ Use [NarratedMotion.ts](../examples/NarratedMotion.ts) as the executable
136
+ integration recipe, adapting its speech and visual action to the brief.
137
+
138
+ - Use `Speak` for simultaneous narration and motion. Set `entity` to an entity
139
+ already added to the scene, so playback uses that scene's audio bus.
140
+ - Call `prepareAudio()` before locking timings. Register the synthesis promise
141
+ in `this.composer.pendingTasks` before awaiting it. Do not register playback
142
+ promises or `this.play(...)`: those require timeline steps and would deadlock.
143
+ - Derive motion duration from prepared `Speak.duration`. Pass that duration to
144
+ `Concurrent` too, so its defaults do not rescale the narration.
145
+ - `await speak(this, text, options)` handles playback, subtitles, ducking and
146
+ scene pacing. For headless use, pre-synthesize the same text/options and
147
+ register only synthesis as pending work. The provider caches that result.
148
+ - `TTS.speak(...)` is a lower-level client facade; it does not pace the scene.
149
+ - Add `Subtitles` and pass it to the narration. Caption text uses the generated
150
+ audio's `text`; ElevenLabs character alignment is converted to word timings
151
+ for lesson cues. Missing timing metadata is not a license to guess cue times.
152
+ - `stream()` yields sentence-sized complete audio results, not transport byte
153
+ chunks. A directed line with `displayText` stays one performance.
154
+
155
+ ## Diagnose and verify
156
+
157
+ For ElevenLabs, check initialization, voice/model compatibility, API permissions,
158
+ quota/rate-limit errors, and network access. Errors surface without automatic
159
+ paid retries. After disposal the provider requires initialization again. For
160
+ Edge, a 404 means missing middleware/route; a 502 means upstream synthesis
161
+ failed. Inspect the actual error before changing providers.
162
+
163
+ Browser autoplay may require a user gesture; headless tools supply their own
164
+ autoplay flag. Verify that exported video contains non-silent audio, listen to
165
+ the performance, and check synchronization. A type check or visual snapshot
166
+ does not establish speech quality.
package/bin/lesson.js CHANGED
@@ -15,6 +15,10 @@ const help = `Usage: retiam lesson <check|prepare|timeline|captions|styles|style
15
15
  --out PATH Override the prepared JSON or SRT destination.
16
16
  --json Emit one machine-readable result; diagnostics go to stderr.
17
17
 
18
+ ElevenLabs: set narration.provider to "elevenlabs", choose narration.voice,
19
+ and supply ELEVENLABS_API_KEY in the command environment. The CLI passes it
20
+ directly to provider.init(); no Vite configuration is required.
21
+
18
22
  Examples:
19
23
  retiam lesson style excalidraw
20
24
  retiam lesson check --json
@@ -79,22 +83,45 @@ export async function runLessonCLI(args) {
79
83
  if (command === 'prepare') {
80
84
  if (!plan.narration.enabled)
81
85
  throw Error('Narration is disabled. Set narration.enabled to true before preparing speech.');
82
- if ((plan.narration.provider ?? 'msedge') !== 'msedge')
83
- throw Error('This CLI prepares the built-in msedge voice. Import a PreparedSpeech manifest for another provider, or use prepareLesson with a synthesize adapter.');
84
- const { createServer } = await import('vite'), { msEdgeTTSPlugin } = await import('../dist/libs/vite/index.js');
85
- const cache = path.resolve('node_modules/.cache/msedge-tts'), assets = path.resolve('public/narration');
86
+ const providerName = plan.narration.provider ?? 'msedge';
87
+ if (!['msedge', 'elevenlabs'].includes(providerName))
88
+ throw Error('Import a PreparedSpeech manifest for this provider, or use prepareLesson with a synthesize adapter.');
89
+ const cache = path.resolve(`node_modules/.cache/${providerName}-tts`), assets = path.resolve('public/narration');
86
90
  fs.mkdirSync(assets, { recursive: true });
87
- const server = await createServer({ configFile: false, logLevel: 'error', plugins: [msEdgeTTSPlugin({ cacheDir: cache, verbose: false })], server: { host: '127.0.0.1', port: 0, open: false, hmr: false, watch: null }, optimizeDeps: { noDiscovery: true } });
91
+ let server, speechProvider, port;
92
+ if (providerName === 'elevenlabs') {
93
+ const { ElevenLabsTTSProvider } = await import('../dist/libs/tts/node/index.js');
94
+ speechProvider = new ElevenLabsTTSProvider();
95
+ await speechProvider.init({ ...plan.narration.elevenlabs,
96
+ apiKey: process.env.ELEVENLABS_API_KEY, voiceId: plan.narration.voice });
97
+ fs.mkdirSync(cache, { recursive: true });
98
+ } else {
99
+ const { createServer } = await import('vite'), { msEdgeTTSPlugin } = await import('../dist/libs/vite/index.js');
100
+ server = await createServer({ configFile: false, logLevel: 'error', plugins: [msEdgeTTSPlugin({ cacheDir: cache, verbose: false })], server: { host: '127.0.0.1', port: 0, open: false, hmr: false, watch: null }, optimizeDeps: { noDiscovery: true } });
101
+ }
88
102
  prepared = { version: 1, voiceKey: voiceKey(plan), assets: {} };
89
103
  try {
90
- await server.listen();
91
- const port = server.httpServer.address().port;
104
+ if (server) { await server.listen(); port = server.httpServer.address().port; }
92
105
  // Sequential generation avoids provider throttling; cached requests return immediately.
93
106
  for (const beat of plan.beats)
94
107
  for (const segment of beat.narration) {
95
- const request = { text: segment.text, voice: plan.narration.voice ?? 'en-US-AndrewNeural', speed: plan.narration.speed ?? 1, pitch: plan.narration.pitch ?? '+0Hz', volume: 1 };
108
+ const request = providerName === 'msedge'
109
+ ? { text: segment.speechText ?? segment.text, voice: plan.narration.voice ?? 'en-US-AndrewNeural', speed: plan.narration.speed ?? 1, pitch: plan.narration.pitch ?? '+0Hz', volume: 1 }
110
+ : { text: segment.speechText ?? segment.text, displayText: segment.text, voice: plan.narration.voice, speed: plan.narration.speed, pitch: plan.narration.pitch, elevenlabs: plan.narration.elevenlabs };
111
+ const hash = crypto.createHash('sha256').update(JSON.stringify({ provider: providerName, request })).digest('hex');
96
112
  let result;
97
- for (let attempt = 0; attempt < 3; attempt++) {
113
+ if (speechProvider) {
114
+ const audioFile = path.join(cache, hash + '.mp3'), metadataFile = path.join(cache, hash + '.json');
115
+ if (fs.existsSync(audioFile) && fs.existsSync(metadataFile)) {
116
+ result = { ...JSON.parse(fs.readFileSync(metadataFile, 'utf8')), bytes: fs.readFileSync(audioFile) };
117
+ } else {
118
+ const audio = await speechProvider.synthesize(request.text, request);
119
+ result = { bytes: Buffer.from(audio.rawBuffer), durationMs: audio.durationMs, words: audio.wordBoundaries ?? [] };
120
+ if (!result.words.length) throw Error('ElevenLabs returned no word timings; narration was not prepared.');
121
+ fs.writeFileSync(audioFile, result.bytes);
122
+ fs.writeFileSync(metadataFile, JSON.stringify({ durationMs: result.durationMs, words: result.words }));
123
+ }
124
+ } else for (let attempt = 0; attempt < 3; attempt++) {
98
125
  try {
99
126
  const res = await fetch(`http://127.0.0.1:${port}/api/tts/msedge/synthesize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), signal: AbortSignal.timeout(90000) });
100
127
  if (!res.ok)
@@ -111,13 +138,13 @@ export async function runLessonCLI(args) {
111
138
  console.error(`Retrying ${beat.id}/${segment.id}: ${error.message}`);
112
139
  }
113
140
  }
114
- const hash = crypto.createHash('sha256').update(JSON.stringify(request)).digest('hex');
115
141
  fs.writeFileSync(path.join(assets, hash + '.mp3'), result.bytes);
116
- prepared.assets[speechKey(beat.id, segment.id)] = { text: segment.text, durationMs: result.durationMs, words: result.words, url: `/narration/${hash}.mp3` };
142
+ prepared.assets[speechKey(beat.id, segment.id)] = { text: segment.text, speechText: segment.speechText, durationMs: result.durationMs, words: result.words, url: `/narration/${hash}.mp3` };
117
143
  }
118
144
  }
119
145
  finally {
120
- await server.close();
146
+ await server?.close();
147
+ await speechProvider?.dispose();
121
148
  }
122
149
  compileLesson(plan, prepared);
123
150
  const dest = path.resolve(out ?? preparedFile);