learnx-cli 0.3.0__py3-none-any.whl

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 (131) hide show
  1. learnx_cli-0.3.0.dist-info/METADATA +240 -0
  2. learnx_cli-0.3.0.dist-info/RECORD +131 -0
  3. learnx_cli-0.3.0.dist-info/WHEEL +4 -0
  4. learnx_cli-0.3.0.dist-info/entry_points.txt +2 -0
  5. tutor/.env copy.example +4 -0
  6. tutor/__init__.py +0 -0
  7. tutor/__main__.py +4 -0
  8. tutor/assets/__init__.py +5 -0
  9. tutor/assets/html/fonts/Inter-Bold.woff2 +0 -0
  10. tutor/assets/html/fonts/Inter-Regular.woff2 +0 -0
  11. tutor/assets/html/fonts/Inter-SemiBold.woff2 +0 -0
  12. tutor/assets/html/fonts/JetBrainsMono-Regular.woff2 +0 -0
  13. tutor/assets/html/highlight-java.min.js +2 -0
  14. tutor/assets/html/highlight-javascript.min.js +2 -0
  15. tutor/assets/html/highlight-python.min.js +2 -0
  16. tutor/assets/html/highlight.min.js +17 -0
  17. tutor/assets/html/mermaid.min.js +31 -0
  18. tutor/assets/html/slide_base.css +464 -0
  19. tutor/assets/html/theme-learnx-dark.css +12 -0
  20. tutor/audio/__init__.py +0 -0
  21. tutor/audio/audio_builder.py +143 -0
  22. tutor/audio/sanitizer.py +9 -0
  23. tutor/audio/tts_renderer.py +54 -0
  24. tutor/cli/__init__.py +0 -0
  25. tutor/cli/commands.py +391 -0
  26. tutor/cli/logo.py +21 -0
  27. tutor/cli/playback_commands.py +239 -0
  28. tutor/cli/shell.py +91 -0
  29. tutor/cli/shell_context.py +18 -0
  30. tutor/cli/theme.py +39 -0
  31. tutor/cli/video_commands.py +123 -0
  32. tutor/config.py +122 -0
  33. tutor/conftest.py +5 -0
  34. tutor/constants.py +82 -0
  35. tutor/exceptions.py +26 -0
  36. tutor/generation/__init__.py +0 -0
  37. tutor/generation/assembler.py +81 -0
  38. tutor/generation/curriculum.py +97 -0
  39. tutor/generation/dialogue.py +172 -0
  40. tutor/generation/narrator.py +122 -0
  41. tutor/generation/segment_parser.py +223 -0
  42. tutor/generation/segment_planner.py +200 -0
  43. tutor/generation/visual_planner.py +205 -0
  44. tutor/infra/__init__.py +0 -0
  45. tutor/infra/llm.py +152 -0
  46. tutor/ingestion/__init__.py +0 -0
  47. tutor/ingestion/chunker.py +171 -0
  48. tutor/ingestion/doc_analyzer.py +41 -0
  49. tutor/ingestion/parse_content.py +19 -0
  50. tutor/ingestion/summarizer.py +51 -0
  51. tutor/inspector.py +117 -0
  52. tutor/llm_config.toml +58 -0
  53. tutor/models.py +147 -0
  54. tutor/player/__init__.py +0 -0
  55. tutor/player/input_handler.py +45 -0
  56. tutor/player/player.py +308 -0
  57. tutor/player/player_display.py +117 -0
  58. tutor/prompts/curriculum.txt +67 -0
  59. tutor/prompts/dialogue.txt +62 -0
  60. tutor/prompts/narrate.txt +34 -0
  61. tutor/prompts/qa.txt +17 -0
  62. tutor/prompts/summarize.txt +9 -0
  63. tutor/prompts/visual.txt +60 -0
  64. tutor/prompts/visual_v3.txt +91 -0
  65. tutor/qa/__init__.py +0 -0
  66. tutor/qa/qa.py +105 -0
  67. tutor/requirements-dev.txt +2 -0
  68. tutor/requirements.txt +12 -0
  69. tutor/sample_docs/headingless_large.md +1 -0
  70. tutor/sample_docs/headingless_test.md +1 -0
  71. tutor/sample_docs/java-basics.md +78 -0
  72. tutor/tests/__init__.py +0 -0
  73. tutor/tests/audio/__init__.py +0 -0
  74. tutor/tests/audio/test_audio_builder.py +106 -0
  75. tutor/tests/audio/test_sanitizer.py +41 -0
  76. tutor/tests/cli/__init__.py +0 -0
  77. tutor/tests/cli/test_commands.py +67 -0
  78. tutor/tests/cli/test_video_commands.py +190 -0
  79. tutor/tests/e2e/README.md +61 -0
  80. tutor/tests/e2e/__init__.py +0 -0
  81. tutor/tests/e2e/conftest.py +117 -0
  82. tutor/tests/e2e/fixtures/README.md +17 -0
  83. tutor/tests/e2e/fixtures/sample.md +13 -0
  84. tutor/tests/e2e/test_audio_quality.py +40 -0
  85. tutor/tests/e2e/test_av_sync.py +56 -0
  86. tutor/tests/e2e/test_pipeline_smoke.py +37 -0
  87. tutor/tests/e2e/test_slide_render.py +72 -0
  88. tutor/tests/e2e/test_video_streams.py +104 -0
  89. tutor/tests/generation/__init__.py +0 -0
  90. tutor/tests/generation/conftest.py +134 -0
  91. tutor/tests/generation/test_assembler.py +64 -0
  92. tutor/tests/generation/test_curriculum.py +107 -0
  93. tutor/tests/generation/test_narrator.py +165 -0
  94. tutor/tests/generation/test_segment_edge_cases.py +280 -0
  95. tutor/tests/generation/test_segment_planner.py +324 -0
  96. tutor/tests/generation/test_visual_planner.py +319 -0
  97. tutor/tests/ingestion/__init__.py +0 -0
  98. tutor/tests/ingestion/test_chunker.py +94 -0
  99. tutor/tests/ingestion/test_doc_analyzer.py +51 -0
  100. tutor/tests/player/__init__.py +0 -0
  101. tutor/tests/player/test_player_states.py +88 -0
  102. tutor/tests/test_assets.py +39 -0
  103. tutor/tests/test_models_visual.py +180 -0
  104. tutor/tests/visual/__init__.py +0 -0
  105. tutor/tests/visual/test_beat_timer.py +321 -0
  106. tutor/tests/visual/test_pipeline_integration.py +178 -0
  107. tutor/tests/visual/test_slide_renderer.py +298 -0
  108. tutor/tests/visual/test_subtitle_writer.py +165 -0
  109. tutor/tests/visual/test_video_assembler.py +108 -0
  110. tutor/tests/visual/test_visual_pipeline.py +270 -0
  111. tutor/tutor.py +365 -0
  112. tutor/visual/__init__.py +213 -0
  113. tutor/visual/beat_timer.py +222 -0
  114. tutor/visual/slide_renderer.py +236 -0
  115. tutor/visual/subtitle_writer.py +187 -0
  116. tutor/visual/templates/_base.html.j2 +40 -0
  117. tutor/visual/templates/analogy.html.j2 +21 -0
  118. tutor/visual/templates/callout.html.j2 +10 -0
  119. tutor/visual/templates/code_example.html.j2 +12 -0
  120. tutor/visual/templates/comparison.html.j2 +28 -0
  121. tutor/visual/templates/decision_guide.html.j2 +37 -0
  122. tutor/visual/templates/definition.html.j2 +13 -0
  123. tutor/visual/templates/diagram.html.j2 +11 -0
  124. tutor/visual/templates/hook_question.html.j2 +17 -0
  125. tutor/visual/templates/key_insight.html.j2 +9 -0
  126. tutor/visual/templates/memory_hook.html.j2 +7 -0
  127. tutor/visual/templates/outro.html.j2 +16 -0
  128. tutor/visual/templates/question_prompt.html.j2 +13 -0
  129. tutor/visual/templates/step_sequence.html.j2 +14 -0
  130. tutor/visual/templates/title_card.html.j2 +12 -0
  131. tutor/visual/video_assembler.py +299 -0
@@ -0,0 +1,464 @@
1
+ /* ============================================================
2
+ LearnX Slide Base — v12
3
+ Typography: Inter (UI) + JetBrains Mono (code)
4
+ Design: per-type accent system adapted from presentation-ai
5
+ ============================================================ */
6
+
7
+ /* ── Web Fonts ──────────────────────────────────────────── */
8
+
9
+ @font-face {
10
+ font-family: "Inter";
11
+ src: url("fonts/Inter-Regular.woff2") format("woff2");
12
+ font-weight: 400;
13
+ font-style: normal;
14
+ font-display: block;
15
+ }
16
+ @font-face {
17
+ font-family: "Inter";
18
+ src: url("fonts/Inter-SemiBold.woff2") format("woff2");
19
+ font-weight: 600;
20
+ font-style: normal;
21
+ font-display: block;
22
+ }
23
+ @font-face {
24
+ font-family: "Inter";
25
+ src: url("fonts/Inter-Bold.woff2") format("woff2");
26
+ font-weight: 700;
27
+ font-style: normal;
28
+ font-display: block;
29
+ }
30
+ @font-face {
31
+ font-family: "JetBrains Mono";
32
+ src: url("fonts/JetBrainsMono-Regular.woff2") format("woff2");
33
+ font-weight: 400;
34
+ font-style: normal;
35
+ font-display: block;
36
+ }
37
+
38
+ /* ── Design Tokens ──────────────────────────────────────── */
39
+
40
+ :root {
41
+ /* Surfaces */
42
+ --bg-deep: #0d1117;
43
+ --bg-card: #161b22;
44
+ --bg-elevated: #1c2128;
45
+ --bg-overlay: rgba(255,255,255,.04);
46
+
47
+ /* Borders */
48
+ --border-soft: #21262d;
49
+ --border-mid: #30363d;
50
+ --border-strong: #484f58;
51
+
52
+ /* Text */
53
+ --text-pri: #e6edf3;
54
+ --text-sec: #8b949e;
55
+ --text-dim: #656d76;
56
+
57
+ /* Full accent palette */
58
+ --cyan: #22d3ee;
59
+ --blue: #60a5fa;
60
+ --purple: #c084fc;
61
+ --teal: #2dd4bf;
62
+ --green: #4ade80;
63
+ --amber: #fbbf24;
64
+ --orange: #fb923c;
65
+ --pink: #f472b6;
66
+ --rose: #fb7185;
67
+ --sky: #38bdf8;
68
+ --indigo: #818cf8;
69
+
70
+ /*
71
+ * Per-slide-type accent — templates override this via:
72
+ * {% block extra_style %}:root { --type-accent: var(--blue); }{% endblock %}
73
+ * Default (cyan) applies when no template overrides it.
74
+ */
75
+ --type-accent: var(--cyan);
76
+
77
+ /* Typography */
78
+ --font-ui: "Inter", system-ui, "Helvetica Neue", Arial, sans-serif;
79
+ --font-mono: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New", monospace;
80
+
81
+ /* Legacy aliases — keep existing templates working until Day 2 */
82
+ --accent-cyn: var(--cyan);
83
+ --accent-amb: var(--amber);
84
+ --accent-grn: var(--green);
85
+ --divider: var(--border-mid);
86
+ }
87
+
88
+ /* ── Reset + Base ───────────────────────────────────────── */
89
+
90
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
91
+
92
+ html, body {
93
+ width: 1920px;
94
+ height: 1080px;
95
+ overflow: hidden;
96
+ background: var(--bg-deep);
97
+ color: var(--text-pri);
98
+ font-family: var(--font-ui);
99
+ /*
100
+ * Subtle per-type accent glow — top-right corner.
101
+ * Inspired by presentation-ai professional themes.
102
+ * color-mix() requires Chromium 111+ (compatible with current Playwright).
103
+ */
104
+ background-image: radial-gradient(
105
+ ellipse 900px 600px at 96% 4%,
106
+ color-mix(in srgb, var(--type-accent) 7%, transparent) 0%,
107
+ transparent 70%
108
+ );
109
+ }
110
+
111
+ /* ── Chrome Layout ──────────────────────────────────────── */
112
+
113
+ .top-bar {
114
+ height: 60px;
115
+ background: var(--bg-card);
116
+ border-bottom: 1px solid var(--border-soft);
117
+ display: flex;
118
+ align-items: center;
119
+ padding: 0 48px;
120
+ font-size: 20px;
121
+ font-weight: 600;
122
+ color: var(--text-sec);
123
+ gap: 16px;
124
+ }
125
+
126
+ .footer-bar {
127
+ position: absolute;
128
+ bottom: 0;
129
+ height: 56px;
130
+ width: 100%;
131
+ background: var(--bg-card);
132
+ border-top: 1px solid var(--border-soft);
133
+ display: flex;
134
+ align-items: center;
135
+ justify-content: center;
136
+ gap: 8px;
137
+ }
138
+
139
+ .content {
140
+ position: absolute;
141
+ top: 60px;
142
+ bottom: 56px;
143
+ left: 80px;
144
+ right: 80px;
145
+ display: flex;
146
+ flex-direction: column;
147
+ justify-content: center;
148
+ }
149
+
150
+ /* ── Progress Dots ──────────────────────────────────────── */
151
+
152
+ .dot {
153
+ width: 10px;
154
+ height: 10px;
155
+ border-radius: 50%;
156
+ display: inline-block;
157
+ margin: 0 4px;
158
+ }
159
+ .dot--filled { background: var(--type-accent); }
160
+ .dot--hollow { background: transparent; border: 1.5px solid var(--border-strong); }
161
+
162
+ /* ── Shared Helpers ─────────────────────────────────────── */
163
+
164
+ /* Card with left accent border — primary elevated content container */
165
+ .card {
166
+ background: var(--bg-card);
167
+ border: 1px solid var(--border-soft);
168
+ border-left: 4px solid var(--type-accent);
169
+ border-radius: 0 12px 12px 0;
170
+ padding: 40px 48px;
171
+ box-shadow: 0 4px 24px rgba(0,0,0,.4);
172
+ }
173
+
174
+ /* Small coloured pill — slide type label */
175
+ .type-badge {
176
+ display: inline-flex;
177
+ align-items: center;
178
+ padding: 6px 20px;
179
+ border-radius: 24px;
180
+ font-size: 16px;
181
+ font-weight: 700;
182
+ letter-spacing: .06em;
183
+ text-transform: uppercase;
184
+ background: var(--type-accent);
185
+ color: #0d1117;
186
+ }
187
+
188
+ /* ── hook_question ──────────────────────────────────────── */
189
+
190
+ .hook-slide { display: flex; flex-direction: column; gap: 40px; }
191
+ .hook-question {
192
+ font-size: 68px;
193
+ font-weight: 700;
194
+ color: var(--text-pri);
195
+ line-height: 1.2;
196
+ max-width: 1520px;
197
+ }
198
+ .learn-label {
199
+ font-size: 18px;
200
+ font-weight: 700;
201
+ letter-spacing: .10em;
202
+ text-transform: uppercase;
203
+ color: var(--type-accent);
204
+ margin-bottom: 16px;
205
+ }
206
+ .learn-list {
207
+ font-size: 34px;
208
+ color: var(--text-sec);
209
+ list-style: none;
210
+ padding: 0;
211
+ display: flex;
212
+ flex-direction: column;
213
+ gap: 12px;
214
+ }
215
+ .learn-list li::before { content: "\2192\00a0\00a0"; color: var(--type-accent); font-weight: 700; }
216
+
217
+ /* ── definition ─────────────────────────────────────────── */
218
+
219
+ .definition-slide { display: flex; flex-direction: column; gap: 32px; }
220
+ .definition-term {
221
+ font-size: 60px;
222
+ font-weight: 700;
223
+ color: var(--type-accent);
224
+ }
225
+ .definition-text {
226
+ font-size: 36px;
227
+ color: var(--text-pri);
228
+ line-height: 1.55;
229
+ max-width: 1440px;
230
+ }
231
+
232
+ /* ── analogy ────────────────────────────────────────────── */
233
+
234
+ .analogy-slide {
235
+ display: grid;
236
+ grid-template-columns: 1fr 72px 1fr;
237
+ align-items: stretch;
238
+ gap: 32px;
239
+ height: 100%;
240
+ }
241
+ .analogy-panel {
242
+ background: var(--bg-card);
243
+ border: 1px solid var(--border-soft);
244
+ border-top: 3px solid var(--type-accent);
245
+ border-radius: 12px;
246
+ padding: 40px 48px;
247
+ display: flex;
248
+ flex-direction: column;
249
+ gap: 20px;
250
+ box-shadow: 0 4px 20px rgba(0,0,0,.35);
251
+ }
252
+ .analogy-label {
253
+ font-size: 22px;
254
+ font-weight: 700;
255
+ color: var(--type-accent);
256
+ text-transform: uppercase;
257
+ letter-spacing: .06em;
258
+ }
259
+ .analogy-body { font-size: 34px; color: var(--text-pri); line-height: 1.5; }
260
+ .analogy-sep { font-size: 80px; color: var(--type-accent); text-align: center; align-self: center; opacity: .8; }
261
+
262
+ /* ── comparison / decision_guide ────────────────────────── */
263
+
264
+ .comparison-slide { width: 100%; }
265
+ .comparison-table { width: 100%; border-collapse: collapse; font-size: 32px; }
266
+ .comparison-table th {
267
+ padding: 20px 36px;
268
+ font-weight: 700;
269
+ font-size: 36px;
270
+ border-bottom: 2px solid var(--border-mid);
271
+ }
272
+ .comparison-table td { padding: 16px 36px; border-top: 1px solid var(--border-soft); }
273
+ .comparison-table tr:nth-child(even) td { background: var(--bg-card); }
274
+ .comparison-table tr:nth-child(odd) td { background: var(--bg-deep); }
275
+ .th-left { color: var(--type-accent); text-align: left; }
276
+ .th-right { color: var(--orange); text-align: left; }
277
+ .td-ellipsis { text-align: center; color: var(--text-sec); font-size: 28px; }
278
+
279
+ /* ── code_example ───────────────────────────────────────── */
280
+
281
+ .code-slide { display: flex; flex-direction: column; gap: 24px; }
282
+ .code-desc { font-size: 32px; color: var(--text-sec); }
283
+ .code-slide pre {
284
+ font-family: var(--font-mono);
285
+ font-size: 26px;
286
+ line-height: 1.65;
287
+ border-radius: 10px;
288
+ overflow: hidden;
289
+ border: 1px solid var(--border-soft);
290
+ box-shadow: 0 2px 16px rgba(0,0,0,.5);
291
+ }
292
+
293
+ /* ── diagram ────────────────────────────────────────────── */
294
+
295
+ .diagram-slide {
296
+ display: flex;
297
+ align-items: center;
298
+ justify-content: center;
299
+ height: 100%;
300
+ }
301
+ .diagram-slide .mermaid { font-size: 26px; }
302
+
303
+ /* ── question_prompt ────────────────────────────────────── */
304
+
305
+ .question-slide {
306
+ position: absolute;
307
+ inset: 0;
308
+ background: var(--bg-card);
309
+ display: flex;
310
+ align-items: center;
311
+ justify-content: center;
312
+ background-image: radial-gradient(
313
+ ellipse 800px 500px at 50% 50%,
314
+ color-mix(in srgb, var(--type-accent) 5%, transparent) 0%,
315
+ transparent 70%
316
+ );
317
+ }
318
+ .question-text {
319
+ font-size: 56px;
320
+ font-weight: 600;
321
+ color: var(--text-pri);
322
+ text-align: center;
323
+ max-width: 1400px;
324
+ line-height: 1.4;
325
+ }
326
+ .speaker-badge {
327
+ position: absolute;
328
+ top: 80px;
329
+ right: 80px;
330
+ padding: 10px 32px;
331
+ border-radius: 32px;
332
+ font-size: 26px;
333
+ font-weight: 700;
334
+ color: #0d1117;
335
+ }
336
+ .badge-maya { background: var(--green); }
337
+ .badge-sam { background: var(--amber); }
338
+
339
+ /* ── key_insight ────────────────────────────────────────── */
340
+
341
+ .key-insight-slide {
342
+ display: flex;
343
+ flex-direction: column;
344
+ align-items: center;
345
+ justify-content: center;
346
+ height: 100%;
347
+ gap: 32px;
348
+ }
349
+ .key-insight-text {
350
+ font-size: 64px;
351
+ font-weight: 700;
352
+ color: var(--text-pri);
353
+ text-align: center;
354
+ max-width: 1400px;
355
+ line-height: 1.35;
356
+ }
357
+ .key-insight-rule { width: 500px; height: 3px; background: var(--type-accent); }
358
+
359
+ /* ── memory_hook ────────────────────────────────────────── */
360
+
361
+ .memory-hook-slide {
362
+ display: flex;
363
+ align-items: center;
364
+ justify-content: center;
365
+ height: 100%;
366
+ }
367
+ .memory-hook-text {
368
+ font-size: 56px;
369
+ font-weight: 500;
370
+ color: var(--text-pri);
371
+ text-align: center;
372
+ max-width: 1400px;
373
+ line-height: 1.45;
374
+ }
375
+
376
+ /* ── title_card ─────────────────────────────────────────── */
377
+
378
+ .title-card-slide {
379
+ display: flex;
380
+ flex-direction: column;
381
+ align-items: center;
382
+ justify-content: center;
383
+ height: 100%;
384
+ gap: 32px;
385
+ }
386
+ .title-card-title {
387
+ font-size: 88px;
388
+ font-weight: 700;
389
+ color: var(--text-pri);
390
+ text-align: center;
391
+ line-height: 1.15;
392
+ }
393
+ .title-card-sub { font-size: 40px; color: var(--text-sec); text-align: center; }
394
+ .title-card-accent {
395
+ width: 200px;
396
+ height: 4px;
397
+ background: linear-gradient(90deg, var(--cyan), var(--blue));
398
+ border-radius: 2px;
399
+ }
400
+
401
+ /* ── outro ──────────────────────────────────────────────── */
402
+
403
+ .outro-slide {
404
+ display: flex;
405
+ flex-direction: column;
406
+ align-items: center;
407
+ justify-content: center;
408
+ height: 100%;
409
+ gap: 32px;
410
+ }
411
+ .outro-text { font-size: 68px; font-weight: 700; color: var(--text-pri); text-align: center; }
412
+ .outro-sub { font-size: 36px; color: var(--text-sec); text-align: center; }
413
+
414
+ /* ── step_sequence (new — v12) ──────────────────────────── */
415
+
416
+ .step-slide { display: flex; flex-direction: column; gap: 28px; }
417
+ .step-item { display: flex; align-items: flex-start; gap: 32px; }
418
+ .step-num {
419
+ flex-shrink: 0;
420
+ width: 64px;
421
+ height: 64px;
422
+ border-radius: 50%;
423
+ background: var(--type-accent);
424
+ color: #0d1117;
425
+ font-size: 30px;
426
+ font-weight: 700;
427
+ display: flex;
428
+ align-items: center;
429
+ justify-content: center;
430
+ }
431
+ .step-text { font-size: 34px; color: var(--text-pri); line-height: 1.5; padding-top: 10px; }
432
+
433
+ /* ── callout (new — v12) ────────────────────────────────── */
434
+
435
+ .callout-slide {
436
+ display: flex;
437
+ align-items: center;
438
+ justify-content: center;
439
+ height: 100%;
440
+ }
441
+ .callout-box {
442
+ background: color-mix(in srgb, var(--type-accent) 10%, var(--bg-card));
443
+ border: 2px solid var(--type-accent);
444
+ border-radius: 16px;
445
+ padding: 56px 72px;
446
+ max-width: 1440px;
447
+ display: flex;
448
+ flex-direction: column;
449
+ gap: 28px;
450
+ box-shadow: 0 0 60px color-mix(in srgb, var(--type-accent) 15%, transparent);
451
+ }
452
+ .callout-label {
453
+ font-size: 18px;
454
+ font-weight: 700;
455
+ letter-spacing: .10em;
456
+ text-transform: uppercase;
457
+ color: var(--type-accent);
458
+ }
459
+ .callout-text {
460
+ font-size: 48px;
461
+ font-weight: 600;
462
+ color: var(--text-pri);
463
+ line-height: 1.4;
464
+ }
@@ -0,0 +1,12 @@
1
+ /* highlight.js dark theme matching LearnX palette */
2
+ .hljs { background: #161b22; color: #e6edf3; }
3
+ .hljs-comment, .hljs-quote { color: #8b949e; font-style: italic; }
4
+ .hljs-keyword, .hljs-selector-tag, .hljs-built_in { color: #00b4d8; font-weight: 600; }
5
+ .hljs-string, .hljs-attr { color: #3fb950; }
6
+ .hljs-number, .hljs-literal { color: #e3a21a; }
7
+ .hljs-variable, .hljs-template-variable { color: #e6edf3; }
8
+ .hljs-type, .hljs-class .hljs-title { color: #00b4d8; font-weight: 700; }
9
+ .hljs-function .hljs-title, .hljs-title.function_ { color: #e6edf3; }
10
+ .hljs-meta { color: #8b949e; }
11
+ .hljs-emphasis { font-style: italic; }
12
+ .hljs-strong { font-weight: bold; }
File without changes
@@ -0,0 +1,143 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import shutil
5
+ from dataclasses import asdict
6
+ from pathlib import Path
7
+
8
+ from pydub import AudioSegment
9
+ from tqdm import tqdm
10
+
11
+ from tutor.audio.tts_renderer import render_segment
12
+ from tutor.constants import (
13
+ SILENCE_BREATH_MS,
14
+ SILENCE_TURN_MS,
15
+ SILENCE_UNIT_MS,
16
+ TTS_SEMAPHORE_LIMIT,
17
+ )
18
+ from tutor.models import DialogueLine, RenderedSegment, TimingEntry
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+
23
+ async def build(lines: list[DialogueLine], out_path: str, units_dir: str) -> None:
24
+ """
25
+ Entry point from tutor.py via asyncio.run(audio_builder.build(...)).
26
+ This is the single sync→async crossing point for the entire pipeline.
27
+ """
28
+ tmp_dir = str(Path(out_path).parent / ".tts_tmp")
29
+ Path(tmp_dir).mkdir(parents=True, exist_ok=True)
30
+ Path(units_dir).mkdir(parents=True, exist_ok=True)
31
+
32
+ segments = await _render_all(lines, tmp_dir)
33
+ _assemble(segments, out_path, units_dir)
34
+ _cleanup_tmp(tmp_dir)
35
+ log.info("Audio saved: %s", out_path)
36
+
37
+
38
+ async def _render_all(lines: list[DialogueLine], tmp_dir: str) -> list[RenderedSegment]:
39
+ semaphore = asyncio.Semaphore(TTS_SEMAPHORE_LIMIT)
40
+ results: list[RenderedSegment | None] = [None] * len(lines)
41
+
42
+ with tqdm(total=len(lines), desc="Generating audio", unit="seg") as pbar:
43
+
44
+ async def render_one(idx: int, line: DialogueLine) -> None:
45
+ async with semaphore:
46
+ results[idx] = await render_segment(line, tmp_dir, idx)
47
+ pbar.update(1)
48
+
49
+ await asyncio.gather(*[render_one(i, line) for i, line in enumerate(lines)])
50
+
51
+ return [r for r in results if r is not None]
52
+
53
+
54
+ def _assemble(segments: list[RenderedSegment], out_path: str, units_dir: str) -> None:
55
+ unit_groups: dict[int, list[RenderedSegment]] = {}
56
+ for seg in segments:
57
+ unit_num = seg.line.unit_number
58
+ unit_groups.setdefault(unit_num, []).append(seg)
59
+
60
+ # Sort: intro (0) first, then units (1..N), outro (-1) last
61
+ sorted_keys = sorted(unit_groups.keys(), key=lambda x: 999 if x == -1 else x)
62
+
63
+ unit_timing: dict[str, list] = {}
64
+ unit_audio: list[AudioSegment] = []
65
+
66
+ for unit_num in sorted_keys:
67
+ group = unit_groups[unit_num]
68
+ is_teaching_unit = unit_num >= 1
69
+ combined, entries = _concat_with_silence(group, capture_timing=is_teaching_unit)
70
+
71
+ if is_teaching_unit and entries:
72
+ unit_timing[str(unit_num)] = [asdict(e) for e in entries]
73
+
74
+ if unit_num == 0:
75
+ unit_label = "unit_00_intro"
76
+ elif unit_num == -1:
77
+ unit_label = "unit_99_outro"
78
+ else:
79
+ unit_label = f"unit_{unit_num:02d}"
80
+
81
+ unit_path = str(Path(units_dir) / f"{unit_label}.mp3")
82
+ combined.export(unit_path, format="mp3")
83
+ log.info("Saved unit: %s", unit_path)
84
+
85
+ unit_audio.append(combined)
86
+ if unit_num != -1:
87
+ unit_audio.append(AudioSegment.silent(duration=SILENCE_UNIT_MS))
88
+
89
+ timing_path = Path(out_path).parent / "tutorial.timing.json"
90
+ timing_path.write_text(
91
+ json.dumps({"version": 1, "units": unit_timing}, ensure_ascii=False, indent=2),
92
+ encoding="utf-8",
93
+ )
94
+ log.info("Timing file written: %s (%d units)", timing_path, len(unit_timing))
95
+
96
+ full_audio = sum(unit_audio, AudioSegment.empty())
97
+ full_audio.export(out_path, format="mp3")
98
+ log.info("Saved full audio: %s (%d segments)", out_path, len(segments))
99
+
100
+
101
+ def _concat_with_silence(
102
+ segments: list[RenderedSegment],
103
+ capture_timing: bool = False,
104
+ ) -> tuple[AudioSegment, list[TimingEntry]]:
105
+ result = AudioSegment.empty()
106
+ entries: list[TimingEntry] = []
107
+ cursor_ms = 0
108
+ prev_speaker: str | None = None
109
+
110
+ for idx, seg in enumerate(segments):
111
+ audio = AudioSegment.from_mp3(seg.audio_path)
112
+
113
+ if prev_speaker is None:
114
+ gap = 0
115
+ elif prev_speaker == seg.line.speaker:
116
+ gap = SILENCE_BREATH_MS
117
+ else:
118
+ gap = SILENCE_TURN_MS
119
+
120
+ if gap:
121
+ result += AudioSegment.silent(duration=gap)
122
+ cursor_ms += gap
123
+
124
+ if capture_timing:
125
+ entries.append(
126
+ TimingEntry(
127
+ line_index=idx,
128
+ speaker=seg.line.speaker,
129
+ text=seg.line.text,
130
+ start_ms=cursor_ms,
131
+ end_ms=cursor_ms + len(audio),
132
+ )
133
+ )
134
+
135
+ result += audio
136
+ cursor_ms += len(audio)
137
+ prev_speaker = seg.line.speaker
138
+
139
+ return result, entries
140
+
141
+
142
+ def _cleanup_tmp(tmp_dir: str) -> None:
143
+ shutil.rmtree(tmp_dir, ignore_errors=True)
@@ -0,0 +1,9 @@
1
+ import re
2
+
3
+ from tutor.constants import CODE_SUBSTITUTIONS
4
+
5
+
6
+ def apply(text: str) -> str:
7
+ for pattern, replacement in CODE_SUBSTITUTIONS:
8
+ text = re.sub(pattern, replacement, text)
9
+ return text
@@ -0,0 +1,54 @@
1
+ import logging
2
+ import os
3
+ from pathlib import Path
4
+
5
+ import edge_tts
6
+ from pydub import AudioSegment
7
+
8
+ from tutor.constants import (
9
+ RATE_COTUTOR,
10
+ RATE_STUDENT,
11
+ RATE_TUTOR,
12
+ VOICE_COTUTOR,
13
+ VOICE_STUDENT,
14
+ VOICE_TUTOR,
15
+ )
16
+ from tutor.exceptions import TTSError
17
+ from tutor.models import DialogueLine, RenderedSegment
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ VOICE_MAP: dict[str, str] = {
22
+ "ALEX": VOICE_TUTOR,
23
+ "MAYA": VOICE_STUDENT,
24
+ "SAM": VOICE_COTUTOR,
25
+ }
26
+
27
+ RATE_MAP: dict[str, str] = {
28
+ "ALEX": RATE_TUTOR,
29
+ "MAYA": RATE_STUDENT,
30
+ "SAM": RATE_COTUTOR,
31
+ }
32
+
33
+
34
+ async def render_segment(line: DialogueLine, out_dir: str, idx: int) -> RenderedSegment:
35
+ voice = VOICE_MAP.get(line.speaker, VOICE_TUTOR)
36
+ rate = RATE_MAP.get(line.speaker, RATE_TUTOR)
37
+ out_path = str(Path(out_dir) / f"seg_{line.unit_number:03d}_{idx:04d}.mp3")
38
+
39
+ try:
40
+ communicate = edge_tts.Communicate(line.text, voice, rate=rate)
41
+ await communicate.save(out_path)
42
+ except Exception as e:
43
+ raise TTSError(f"TTS failed for line {idx}: {e}") from e
44
+
45
+ if os.path.getsize(out_path) == 0:
46
+ raise TTSError(f"TTS returned empty file for line {idx}: {line.text[:60]}")
47
+
48
+ try:
49
+ audio = AudioSegment.from_mp3(out_path)
50
+ duration_ms = len(audio)
51
+ except Exception as e:
52
+ raise TTSError(f"Could not read rendered segment {out_path}: {e}") from e
53
+
54
+ return RenderedSegment(line=line, audio_path=out_path, duration_ms=duration_ms)
tutor/cli/__init__.py ADDED
File without changes