@hed-hog/lms 0.0.365 → 0.0.370

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 (243) hide show
  1. package/dist/certificate/certificate.controller.d.ts +1 -1
  2. package/dist/certificate/certificate.controller.d.ts.map +1 -1
  3. package/dist/certificate/certificate.controller.js +4 -2
  4. package/dist/certificate/certificate.controller.js.map +1 -1
  5. package/dist/certificate/certificate.service.d.ts +50 -0
  6. package/dist/certificate/certificate.service.d.ts.map +1 -1
  7. package/dist/certificate/certificate.service.js +73 -0
  8. package/dist/certificate/certificate.service.js.map +1 -1
  9. package/dist/class-group/class-group.controller.d.ts +1 -0
  10. package/dist/class-group/class-group.controller.d.ts.map +1 -1
  11. package/dist/class-group/class-group.service.d.ts +1 -0
  12. package/dist/class-group/class-group.service.d.ts.map +1 -1
  13. package/dist/course/course-ai-usage.service.d.ts +58 -0
  14. package/dist/course/course-ai-usage.service.d.ts.map +1 -0
  15. package/dist/course/course-ai-usage.service.js +176 -0
  16. package/dist/course/course-ai-usage.service.js.map +1 -0
  17. package/dist/course/course-audio-transcription.service.d.ts +65 -1
  18. package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
  19. package/dist/course/course-audio-transcription.service.js +381 -29
  20. package/dist/course/course-audio-transcription.service.js.map +1 -1
  21. package/dist/course/course-export-scorm12.service.d.ts +3 -0
  22. package/dist/course/course-export-scorm12.service.d.ts.map +1 -1
  23. package/dist/course/course-export-scorm12.service.js +141 -6
  24. package/dist/course/course-export-scorm12.service.js.map +1 -1
  25. package/dist/course/course-export.service.d.ts.map +1 -1
  26. package/dist/course/course-export.service.js +2 -1
  27. package/dist/course/course-export.service.js.map +1 -1
  28. package/dist/course/course-lesson.controller.d.ts +25 -3
  29. package/dist/course/course-lesson.controller.d.ts.map +1 -1
  30. package/dist/course/course-lesson.controller.js +71 -8
  31. package/dist/course/course-lesson.controller.js.map +1 -1
  32. package/dist/course/course-structure.controller.d.ts +30 -7
  33. package/dist/course/course-structure.controller.d.ts.map +1 -1
  34. package/dist/course/course-structure.controller.js +37 -4
  35. package/dist/course/course-structure.controller.js.map +1 -1
  36. package/dist/course/course-structure.service.d.ts +37 -5
  37. package/dist/course/course-structure.service.d.ts.map +1 -1
  38. package/dist/course/course-structure.service.js +165 -20
  39. package/dist/course/course-structure.service.js.map +1 -1
  40. package/dist/course/course-transcription-translation.service.d.ts +31 -0
  41. package/dist/course/course-transcription-translation.service.d.ts.map +1 -0
  42. package/dist/course/course-transcription-translation.service.js +227 -0
  43. package/dist/course/course-transcription-translation.service.js.map +1 -0
  44. package/dist/course/course-video-agent-pipeline.service.d.ts +70 -0
  45. package/dist/course/course-video-agent-pipeline.service.d.ts.map +1 -0
  46. package/dist/course/course-video-agent-pipeline.service.js +398 -0
  47. package/dist/course/course-video-agent-pipeline.service.js.map +1 -0
  48. package/dist/course/course-video-hls.service.d.ts +14 -0
  49. package/dist/course/course-video-hls.service.d.ts.map +1 -1
  50. package/dist/course/course-video-hls.service.js +25 -8
  51. package/dist/course/course-video-hls.service.js.map +1 -1
  52. package/dist/course/course.controller.d.ts +2 -0
  53. package/dist/course/course.controller.d.ts.map +1 -1
  54. package/dist/course/course.module.d.ts.map +1 -1
  55. package/dist/course/course.module.js +9 -0
  56. package/dist/course/course.module.js.map +1 -1
  57. package/dist/course/course.service.d.ts +2 -0
  58. package/dist/course/course.service.d.ts.map +1 -1
  59. package/dist/course/course.service.js +36 -2
  60. package/dist/course/course.service.js.map +1 -1
  61. package/dist/course/dto/create-course-bulk-job.dto.d.ts +2 -1
  62. package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -1
  63. package/dist/course/dto/create-course-bulk-job.dto.js +6 -1
  64. package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -1
  65. package/dist/course/dto/create-course-export.dto.d.ts +1 -0
  66. package/dist/course/dto/create-course-export.dto.d.ts.map +1 -1
  67. package/dist/course/dto/create-course-export.dto.js +6 -0
  68. package/dist/course/dto/create-course-export.dto.js.map +1 -1
  69. package/dist/course/ffmpeg.util.d.ts +10 -0
  70. package/dist/course/ffmpeg.util.d.ts.map +1 -0
  71. package/dist/course/ffmpeg.util.js +79 -0
  72. package/dist/course/ffmpeg.util.js.map +1 -0
  73. package/dist/course/lms-bulk-upload-automation.service.d.ts +3 -1
  74. package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
  75. package/dist/course/lms-bulk-upload-automation.service.js +33 -16
  76. package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
  77. package/dist/course/lms-bulk-upload.controller.d.ts +3 -0
  78. package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
  79. package/dist/course/lms-bulk-upload.service.d.ts +3 -0
  80. package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
  81. package/dist/course/lms-bulk-upload.service.js +48 -29
  82. package/dist/course/lms-bulk-upload.service.js.map +1 -1
  83. package/dist/course/subtitle.util.d.ts +46 -0
  84. package/dist/course/subtitle.util.d.ts.map +1 -0
  85. package/dist/course/subtitle.util.js +206 -0
  86. package/dist/course/subtitle.util.js.map +1 -0
  87. package/dist/enterprise/training/training-admin.controller.d.ts +2 -0
  88. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
  89. package/dist/enterprise/training/training-admin.service.d.ts +2 -0
  90. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  91. package/dist/enterprise/training/training-student.service.d.ts +27 -0
  92. package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
  93. package/dist/enterprise/training/training-student.service.js +197 -10
  94. package/dist/enterprise/training/training-student.service.js.map +1 -1
  95. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +3 -1
  96. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -1
  97. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +19 -5
  98. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -1
  99. package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -1
  100. package/dist/lesson-xp-map/lesson-xp-map.module.js +2 -1
  101. package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -1
  102. package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -1
  103. package/dist/lms.module.d.ts.map +1 -1
  104. package/dist/lms.module.js +14 -0
  105. package/dist/lms.module.js.map +1 -1
  106. package/dist/platforma/dto/heartbeat.dto.d.ts +9 -0
  107. package/dist/platforma/dto/heartbeat.dto.d.ts.map +1 -0
  108. package/dist/platforma/dto/heartbeat.dto.js +50 -0
  109. package/dist/platforma/dto/heartbeat.dto.js.map +1 -0
  110. package/dist/platforma/handlers/emit-certificate.handler.d.ts +27 -0
  111. package/dist/platforma/handlers/emit-certificate.handler.d.ts.map +1 -0
  112. package/dist/platforma/handlers/emit-certificate.handler.js +117 -0
  113. package/dist/platforma/handlers/emit-certificate.handler.js.map +1 -0
  114. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts +31 -0
  115. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts.map +1 -0
  116. package/dist/platforma/handlers/lesson-heartbeat.handler.js +281 -0
  117. package/dist/platforma/handlers/lesson-heartbeat.handler.js.map +1 -0
  118. package/dist/platforma/platforma-heartbeat.service.d.ts +10 -0
  119. package/dist/platforma/platforma-heartbeat.service.d.ts.map +1 -0
  120. package/dist/platforma/platforma-heartbeat.service.js +50 -0
  121. package/dist/platforma/platforma-heartbeat.service.js.map +1 -0
  122. package/dist/platforma/platforma-performance.service.d.ts +121 -0
  123. package/dist/platforma/platforma-performance.service.d.ts.map +1 -0
  124. package/dist/platforma/platforma-performance.service.js +500 -0
  125. package/dist/platforma/platforma-performance.service.js.map +1 -0
  126. package/dist/platforma/platforma-search.service.d.ts +21 -0
  127. package/dist/platforma/platforma-search.service.d.ts.map +1 -0
  128. package/dist/platforma/platforma-search.service.js +64 -0
  129. package/dist/platforma/platforma-search.service.js.map +1 -0
  130. package/dist/platforma/platforma-video.service.d.ts +8 -0
  131. package/dist/platforma/platforma-video.service.d.ts.map +1 -1
  132. package/dist/platforma/platforma-video.service.js +45 -2
  133. package/dist/platforma/platforma-video.service.js.map +1 -1
  134. package/dist/platforma/platforma.controller.d.ts +213 -1
  135. package/dist/platforma/platforma.controller.d.ts.map +1 -1
  136. package/dist/platforma/platforma.controller.js +159 -2
  137. package/dist/platforma/platforma.controller.js.map +1 -1
  138. package/dist/realtime/lms-realtime.controller.d.ts +2 -0
  139. package/dist/realtime/lms-realtime.controller.d.ts.map +1 -1
  140. package/dist/realtime/lms-realtime.controller.js +31 -0
  141. package/dist/realtime/lms-realtime.controller.js.map +1 -1
  142. package/dist/realtime/lms-realtime.service.d.ts +1 -1
  143. package/dist/realtime/lms-realtime.service.d.ts.map +1 -1
  144. package/dist/realtime/lms-realtime.service.js.map +1 -1
  145. package/dist/training/dto/create-training.dto.d.ts +9 -0
  146. package/dist/training/dto/create-training.dto.d.ts.map +1 -1
  147. package/dist/training/dto/create-training.dto.js +45 -1
  148. package/dist/training/dto/create-training.dto.js.map +1 -1
  149. package/dist/training/training.controller.d.ts +144 -0
  150. package/dist/training/training.controller.d.ts.map +1 -1
  151. package/dist/training/training.service.d.ts +149 -0
  152. package/dist/training/training.service.d.ts.map +1 -1
  153. package/dist/training/training.service.js +332 -167
  154. package/dist/training/training.service.js.map +1 -1
  155. package/hedhog/data/image_type.yaml +10 -0
  156. package/hedhog/data/route.yaml +251 -0
  157. package/hedhog/data/setting_group.yaml +97 -0
  158. package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +139 -27
  159. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +182 -29
  160. package/hedhog/frontend/app/classes/_components/classes-calendar-view.tsx.ejs +277 -0
  161. package/hedhog/frontend/app/classes/page.tsx.ejs +127 -20
  162. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +69 -57
  163. package/hedhog/frontend/app/courses/[id]/_components/CourseIssuedCertificatesCard.tsx.ejs +168 -0
  164. package/hedhog/frontend/app/courses/[id]/structure/_components/course-ai-costs-tab.tsx.ejs +191 -0
  165. package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +81 -1
  166. package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +12 -0
  167. package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +141 -30
  168. package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +13 -13
  169. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +69 -1
  170. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +11 -23
  171. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +267 -19
  172. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +114 -86
  173. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +239 -31
  174. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +344 -59
  175. package/hedhog/frontend/app/courses/[id]/structure/_components/lesson-video-preview.tsx.ejs +200 -0
  176. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +1 -0
  177. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +3 -0
  178. package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +1 -8
  179. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -7
  180. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -0
  181. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-ai-costs.ts.ejs +40 -0
  182. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +2 -0
  183. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +25 -0
  184. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +148 -0
  185. package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +157 -8
  186. package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +1 -22
  187. package/hedhog/frontend/app/courses/page.tsx.ejs +66 -13
  188. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +6 -0
  189. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-calendar-tab.tsx.ejs +264 -0
  190. package/hedhog/frontend/app/enterprise/page.tsx.ejs +104 -47
  191. package/hedhog/frontend/app/exams/page.tsx.ejs +38 -4
  192. package/hedhog/frontend/app/instructors/page.tsx.ejs +87 -46
  193. package/hedhog/frontend/app/paths/page.tsx.ejs +650 -168
  194. package/hedhog/frontend/app/training/page.tsx.ejs +38 -4
  195. package/hedhog/frontend/messages/en.json +41 -12
  196. package/hedhog/frontend/messages/pt.json +44 -13
  197. package/hedhog/query/triggers.sql +33 -0
  198. package/hedhog/table/course_ai_usage.yaml +46 -0
  199. package/hedhog/table/course_enrollment.yaml +3 -0
  200. package/hedhog/table/course_lesson.yaml +3 -0
  201. package/hedhog/table/course_lesson_answer.yaml +37 -0
  202. package/hedhog/table/course_lesson_transcription_segment.yaml +8 -0
  203. package/hedhog/table/learning_path.yaml +6 -0
  204. package/hedhog/table/learning_path_module.yaml +22 -0
  205. package/hedhog/table/learning_path_step.yaml +9 -6
  206. package/hedhog/table/lesson_view_event.yaml +66 -0
  207. package/package.json +8 -7
  208. package/src/certificate/certificate.controller.ts +2 -0
  209. package/src/certificate/certificate.service.ts +99 -0
  210. package/src/course/course-ai-usage.service.ts +221 -0
  211. package/src/course/course-audio-transcription.service.ts +471 -43
  212. package/src/course/course-export-scorm12.service.ts +149 -5
  213. package/src/course/course-export.service.ts +1 -0
  214. package/src/course/course-lesson.controller.ts +59 -6
  215. package/src/course/course-structure.controller.ts +19 -1
  216. package/src/course/course-structure.service.ts +184 -10
  217. package/src/course/course-transcription-translation.service.ts +293 -0
  218. package/src/course/course-video-agent-pipeline.service.ts +471 -0
  219. package/src/course/course-video-hls.service.ts +30 -10
  220. package/src/course/course.module.ts +9 -0
  221. package/src/course/course.service.ts +46 -1
  222. package/src/course/dto/create-course-bulk-job.dto.ts +7 -3
  223. package/src/course/dto/create-course-export.dto.ts +6 -0
  224. package/src/course/ffmpeg.util.ts +65 -0
  225. package/src/course/lms-bulk-upload-automation.service.ts +33 -8
  226. package/src/course/lms-bulk-upload.service.ts +20 -1
  227. package/src/course/subtitle.util.ts +220 -0
  228. package/src/enterprise/training/training-student.service.ts +224 -4
  229. package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +14 -0
  230. package/src/lesson-xp-map/lesson-xp-map.module.ts +2 -1
  231. package/src/lms.module.ts +14 -0
  232. package/src/platforma/dto/heartbeat.dto.ts +30 -0
  233. package/src/platforma/handlers/emit-certificate.handler.ts +117 -0
  234. package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -0
  235. package/src/platforma/platforma-heartbeat.service.ts +33 -0
  236. package/src/platforma/platforma-performance.service.ts +606 -0
  237. package/src/platforma/platforma-search.service.ts +48 -0
  238. package/src/platforma/platforma-video.service.ts +59 -3
  239. package/src/platforma/platforma.controller.ts +130 -0
  240. package/src/realtime/lms-realtime.controller.ts +27 -1
  241. package/src/realtime/lms-realtime.service.ts +2 -1
  242. package/src/training/dto/create-training.dto.ts +36 -0
  243. package/src/training/training.service.ts +360 -163
@@ -14,6 +14,8 @@ import { promises as fs } from 'fs';
14
14
  import { tmpdir } from 'os';
15
15
  import { join } from 'path';
16
16
  import { promisify } from 'util';
17
+ import { CourseAiUsageService } from './course-ai-usage.service';
18
+ import { alignTextToTimings, fitsInTwoLines, TimedWord } from './subtitle.util';
17
19
 
18
20
  const execFileAsync = promisify(execFile);
19
21
 
@@ -104,6 +106,8 @@ export class CourseAudioTranscriptionService implements OnModuleInit, IJobHandle
104
106
  private readonly registry: QueueHandlerRegistry,
105
107
  @Inject(forwardRef(() => DatabaseQueueProvider))
106
108
  private readonly dbQueue: DatabaseQueueProvider,
109
+ @Inject(forwardRef(() => CourseAiUsageService))
110
+ private readonly aiUsageService: CourseAiUsageService,
107
111
  ) {}
108
112
 
109
113
  onModuleInit() {
@@ -111,6 +115,401 @@ export class CourseAudioTranscriptionService implements OnModuleInit, IJobHandle
111
115
  this.logger.log('Registered handler for "lms.audio.transcribe"');
112
116
  }
113
117
 
118
+ // Subtitle rules — Netflix Timed Text Style Guide (pt-BR).
119
+ private static readonly SUB_MAX_CHARS_PER_LINE = 42;
120
+ private static readonly SUB_MAX_LINES = 2;
121
+ private static readonly SUB_MAX_CPS = 17; // reading speed (chars per second)
122
+ private static readonly SUB_MAX_DURATION = 7; // seconds
123
+ private static readonly SUB_MIN_DURATION = 5 / 6; // ~0.833s
124
+ private static readonly SUB_MIN_GAP = 0.084; // ~2 frames @ 24fps
125
+ private static readonly SUB_PAUSE_SPLIT = 0.7; // silence that forces a new cue
126
+ // Soft break at commas/semicolons/colons only once the cue already holds a
127
+ // readable chunk (~one line), so we break at natural points without
128
+ // producing tiny fragments.
129
+ private static readonly SUB_SOFT_BREAK_MIN_CHARS = 38;
130
+ // Silence detection used to snap subtitle starts to where speech actually
131
+ // begins (Whisper word timestamps drift over intros/music and long pauses).
132
+ private static readonly SUB_SILENCE_NOISE_DB = -30; // silencedetect threshold
133
+ private static readonly SUB_SILENCE_MIN_DURATION = 0.3; // min silence length (s)
134
+
135
+ /**
136
+ * Parses the configured reading speed (characters per second) from the
137
+ * `lms-subtitle-reading-speed` setting value, e.g. "...(15 cps)". Falls back
138
+ * to the Netflix adult default and clamps to a sane range.
139
+ */
140
+ private resolveReadingSpeedCps(rawValue: unknown): number {
141
+ const match = String(rawValue ?? '').match(/(\d+)\s*cps/i);
142
+ const cps = match ? Number(match[1]) : NaN;
143
+ if (!Number.isFinite(cps) || cps < 6 || cps > 25) {
144
+ return CourseAudioTranscriptionService.SUB_MAX_CPS;
145
+ }
146
+ return cps;
147
+ }
148
+
149
+ /**
150
+ * Resolves the silencedetect parameters from settings, falling back to the
151
+ * defaults and clamping to sane ranges (noise must be negative dB; duration a
152
+ * small positive number of seconds).
153
+ */
154
+ private resolveSilenceParams(
155
+ noiseRaw: unknown,
156
+ durationRaw: unknown,
157
+ ): { noiseDb: number; minDuration: number } {
158
+ const Svc = CourseAudioTranscriptionService;
159
+ let noiseDb = Number(noiseRaw);
160
+ // Noise must be a strictly negative dB value (0/empty/positive are invalid).
161
+ if (!noiseRaw || !Number.isFinite(noiseDb) || noiseDb >= 0 || noiseDb < -90) {
162
+ noiseDb = Svc.SUB_SILENCE_NOISE_DB;
163
+ }
164
+ let minDuration = Number(durationRaw);
165
+ if (!Number.isFinite(minDuration) || minDuration <= 0 || minDuration > 10) {
166
+ minDuration = Svc.SUB_SILENCE_MIN_DURATION;
167
+ }
168
+ return { noiseDb, minDuration };
169
+ }
170
+
171
+ /**
172
+ * Detects silence intervals in the audio using FFmpeg's silencedetect filter.
173
+ * Subtitle starts are later snapped to the end of any silence they fall into,
174
+ * so captions appear when speech begins (not during intros/music or pauses).
175
+ * Returns an empty list on failure — silence alignment is best-effort.
176
+ */
177
+ private async detectSilences(
178
+ audioPath: string,
179
+ noiseDb: number = CourseAudioTranscriptionService.SUB_SILENCE_NOISE_DB,
180
+ minDuration: number = CourseAudioTranscriptionService.SUB_SILENCE_MIN_DURATION,
181
+ ): Promise<Array<{ start: number; end: number }>> {
182
+ try {
183
+ const { stderr } = await execFileAsync(
184
+ 'ffmpeg',
185
+ [
186
+ '-i',
187
+ audioPath,
188
+ '-af',
189
+ `silencedetect=noise=${noiseDb}dB:d=${minDuration}`,
190
+ '-f',
191
+ 'null',
192
+ '-',
193
+ ],
194
+ { maxBuffer: 1024 * 1024 * 20, windowsHide: true },
195
+ );
196
+
197
+ const silences: Array<{ start: number; end: number }> = [];
198
+ let curStart: number | null = null;
199
+ for (const line of String(stderr ?? '').split('\n')) {
200
+ const startMatch = line.match(/silence_start:\s*(-?[0-9.]+)/);
201
+ if (startMatch) {
202
+ curStart = Math.max(0, parseFloat(startMatch[1]));
203
+ continue;
204
+ }
205
+ const endMatch = line.match(/silence_end:\s*(-?[0-9.]+)/);
206
+ if (endMatch && curStart !== null) {
207
+ const end = parseFloat(endMatch[1]);
208
+ if (Number.isFinite(end) && end > curStart) {
209
+ silences.push({ start: curStart, end });
210
+ }
211
+ curStart = null;
212
+ }
213
+ }
214
+ return silences;
215
+ } catch (error) {
216
+ this.logger.warn(
217
+ `silencedetect failed, skipping start alignment: ${error instanceof Error ? error.message : String(error)}`,
218
+ );
219
+ return [];
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Groups word-level timestamps into subtitle cues and adjusts their timing to
225
+ * the Netflix style guide. Text is stored on a single line; line breaking is a
226
+ * presentation concern handled at VTT generation (see subtitle.util.ts).
227
+ *
228
+ * `silences` (from detectSilences) is used to snap each cue's start to where
229
+ * speech actually begins, correcting Whisper's drift over intros/pauses.
230
+ */
231
+ private buildSubtitleSegments(
232
+ allWords: Array<{ word: string; start: number; end: number }>,
233
+ maxCps: number = CourseAudioTranscriptionService.SUB_MAX_CPS,
234
+ silences: Array<{ start: number; end: number }> = [],
235
+ ): Array<{ start_seconds: number; end_seconds: number; text: string }> {
236
+ const Svc = CourseAudioTranscriptionService;
237
+
238
+ // Phase 1 — group words into cues respecting char count, duration and pauses.
239
+ const cues: Array<Array<{ word: string; start: number; end: number }>> = [];
240
+ let buffer: Array<{ word: string; start: number; end: number }> = [];
241
+ let bufferText = '';
242
+
243
+ const flush = () => {
244
+ if (buffer.length === 0) return;
245
+ cues.push(buffer);
246
+ buffer = [];
247
+ bufferText = '';
248
+ };
249
+
250
+ for (const entry of allWords) {
251
+ const word = entry.word.trim();
252
+ if (!word) continue;
253
+
254
+ const prev = buffer[buffer.length - 1];
255
+ const gapFromPrev = prev ? entry.start - prev.end : 0;
256
+ const tentativeText = bufferText ? `${bufferText} ${word}` : word;
257
+ const tentativeDuration =
258
+ buffer.length > 0 ? entry.end - buffer[0].start : entry.end - entry.start;
259
+
260
+ if (
261
+ buffer.length > 0 &&
262
+ (!fitsInTwoLines(tentativeText, Svc.SUB_MAX_CHARS_PER_LINE) ||
263
+ tentativeDuration > Svc.SUB_MAX_DURATION ||
264
+ gapFromPrev >= Svc.SUB_PAUSE_SPLIT)
265
+ ) {
266
+ flush();
267
+ }
268
+
269
+ buffer.push(entry);
270
+ bufferText = bufferText ? `${bufferText} ${word}` : word;
271
+
272
+ // Break naturally at sentence-ending punctuation.
273
+ if (/[.!?…]$/.test(word)) {
274
+ flush();
275
+ continue;
276
+ }
277
+
278
+ // Soft break at clause punctuation once the cue is already substantial,
279
+ // so subtitles break at natural points (commas, pauses) instead of mid-clause.
280
+ if (/[,;:]$/.test(word) && bufferText.length >= Svc.SUB_SOFT_BREAK_MIN_CHARS) {
281
+ flush();
282
+ }
283
+ }
284
+ flush();
285
+
286
+ // Phase 1.5 — merge single-word cues that are not real pauses/emphasis.
287
+ const mergedCues = this.mergeLoneWordCues(cues);
288
+
289
+ // Phase 2 — build text and adjust display timing per cue.
290
+ const draft = mergedCues
291
+ .map((words) => ({
292
+ start: words[0].start,
293
+ endRaw: words[words.length - 1].end,
294
+ text: words.map((w) => w.word).join(' ').replace(/\s+/g, ' ').trim(),
295
+ }))
296
+ .filter((c) => c.text.length > 0);
297
+
298
+ const round = (n: number) => Math.max(0, Math.round(n * 1000) / 1000);
299
+
300
+ // Snap a start to the end of the silence it falls into (where speech begins).
301
+ // end_seconds is computed separately from `start`, so it will always be ≥ start.
302
+ const snapStart = (rawStart: number): number => {
303
+ for (const s of silences) {
304
+ if (rawStart >= s.start && rawStart < s.end) {
305
+ return s.end;
306
+ }
307
+ }
308
+ return rawStart;
309
+ };
310
+
311
+ const starts = draft.map((seg) => snapStart(seg.start));
312
+
313
+ return draft.map((seg, i) => {
314
+ const start = starts[i];
315
+
316
+ // Extend duration to satisfy minimum display time and reading speed (CPS),
317
+ // capped at the maximum duration.
318
+ let end = Math.max(
319
+ seg.endRaw,
320
+ start + Svc.SUB_MIN_DURATION,
321
+ start + seg.text.length / maxCps,
322
+ );
323
+ end = Math.min(end, start + Svc.SUB_MAX_DURATION);
324
+
325
+ // Never overlap the next cue (keep a minimum gap from its aligned start).
326
+ const nextStart = starts[i + 1];
327
+ if (nextStart !== undefined) {
328
+ const maxEnd = nextStart - Svc.SUB_MIN_GAP;
329
+ if (maxEnd > start && end > maxEnd) {
330
+ end = maxEnd;
331
+ }
332
+ }
333
+ if (end <= start) {
334
+ end = start + Svc.SUB_MIN_DURATION;
335
+ }
336
+
337
+ return {
338
+ start_seconds: round(start),
339
+ end_seconds: round(end),
340
+ text: seg.text,
341
+ };
342
+ });
343
+ }
344
+
345
+ /**
346
+ * Merges single-word cues into a neighbour when they are not separated by a
347
+ * real pause — avoiding isolated words on screen. A lone word kept apart by a
348
+ * pause (≥ SUB_PAUSE_SPLIT) is preserved, since it is a genuine pause/emphasis.
349
+ * Merges only happen when the combined cue still fits two lines and the max
350
+ * duration, so size/legibility rules are never violated.
351
+ */
352
+ private mergeLoneWordCues(
353
+ cues: Array<Array<{ word: string; start: number; end: number }>>,
354
+ ): Array<Array<{ word: string; start: number; end: number }>> {
355
+ const Svc = CourseAudioTranscriptionService;
356
+ const merged: Array<Array<{ word: string; start: number; end: number }>> = [];
357
+
358
+ for (const cue of cues) {
359
+ const prev = merged[merged.length - 1];
360
+ if (prev && (prev.length === 1 || cue.length === 1)) {
361
+ const gap = cue[0].start - prev[prev.length - 1].end;
362
+ const combined = [...prev, ...cue];
363
+ const combinedText = combined.map((w) => w.word).join(' ');
364
+ const combinedDuration = cue[cue.length - 1].end - prev[0].start;
365
+
366
+ if (
367
+ gap < Svc.SUB_PAUSE_SPLIT &&
368
+ combinedDuration <= Svc.SUB_MAX_DURATION &&
369
+ fitsInTwoLines(combinedText, Svc.SUB_MAX_CHARS_PER_LINE)
370
+ ) {
371
+ merged[merged.length - 1] = combined;
372
+ continue;
373
+ }
374
+ }
375
+ merged.push(cue);
376
+ }
377
+
378
+ return merged;
379
+ }
380
+
381
+ /**
382
+ * Transcribes a single audio chunk with the given model and returns the raw
383
+ * OpenAI response body. whisper-1 uses verbose_json with word/segment
384
+ * timestamps; the gpt-4o-transcribe family only supports json (text only).
385
+ */
386
+ private async transcribeChunk(
387
+ chunkBuffer: Buffer,
388
+ chunkName: string,
389
+ model: string,
390
+ apiKey: string,
391
+ language: string,
392
+ ): Promise<any> {
393
+ const formData = new FormData();
394
+ formData.append(
395
+ 'file',
396
+ new Blob([new Uint8Array(chunkBuffer)], { type: 'audio/mpeg' }),
397
+ chunkName,
398
+ );
399
+ const isWhisper = model === 'whisper-1';
400
+ formData.append('model', model);
401
+ // Only whisper-1 supports verbose_json + timestamp_granularities; the
402
+ // gpt-4o-transcribe family returns 400 for anything other than json/text.
403
+ formData.append('response_format', isWhisper ? 'verbose_json' : 'json');
404
+ formData.append('language', language);
405
+ if (isWhisper) {
406
+ formData.append('timestamp_granularities[]', 'word');
407
+ formData.append('timestamp_granularities[]', 'segment');
408
+ }
409
+
410
+ const headers: Record<string, string> = {
411
+ Authorization: `Bearer ${apiKey}`,
412
+ };
413
+ const maybeHeaders = (formData as any).getHeaders?.();
414
+ if (maybeHeaders && typeof maybeHeaders === 'object') {
415
+ Object.assign(headers, maybeHeaders);
416
+ }
417
+
418
+ try {
419
+ const response = await axios.post(
420
+ 'https://api.openai.com/v1/audio/transcriptions',
421
+ formData,
422
+ { headers },
423
+ );
424
+ return response.data;
425
+ } catch (axiosErr: any) {
426
+ const status = axiosErr?.response?.status;
427
+ const body = JSON.stringify(axiosErr?.response?.data ?? {});
428
+ this.logger.error(
429
+ `OpenAI transcription request failed — model=${model} language=${language} status=${status} body=${body}`,
430
+ );
431
+ throw axiosErr;
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Converts a raw chunk transcription response into absolute-timed words,
437
+ * applying `offsetSeconds` (the chunk's position in the full audio):
438
+ * - words[] → real per-word timestamps (whisper-1)
439
+ * - segments[] → synthesize even word timing within each segment (whisper-1
440
+ * verbose_json without word granularity)
441
+ * - text only → distribute words evenly across the chunk's speech window,
442
+ * skipping any leading silence detected by ffmpeg (gpt-4o)
443
+ */
444
+ private chunkResponseToWords(
445
+ data: any,
446
+ offsetSeconds: number,
447
+ silences: Array<{ start: number; end: number }>,
448
+ ): TimedWord[] {
449
+ const out: TimedWord[] = [];
450
+ const wordEntries: Array<{ word: string; start: number; end: number }> =
451
+ data?.words ?? [];
452
+
453
+ if (wordEntries.length > 0) {
454
+ for (const w of wordEntries) {
455
+ const wordText = String(w?.word ?? '').trim();
456
+ if (!wordText) continue;
457
+ out.push({
458
+ word: wordText,
459
+ start: offsetSeconds + Number(w?.start ?? 0),
460
+ end: offsetSeconds + Number(w?.end ?? 0),
461
+ });
462
+ }
463
+ return out;
464
+ }
465
+
466
+ const segments = data?.segments ?? [];
467
+ if (segments.length > 0) {
468
+ // whisper-1 verbose_json fallback: synthesize from segments
469
+ for (const segment of segments) {
470
+ const text = String(segment?.text ?? '').trim();
471
+ if (!text) continue;
472
+ const start = offsetSeconds + Number(segment?.start ?? 0);
473
+ const end = offsetSeconds + Number(segment?.end ?? 0);
474
+ const words = text.split(/\s+/).filter(Boolean);
475
+ const dur = Math.max(0, end - start);
476
+ const wordDur = words.length > 0 ? dur / words.length : 0;
477
+ for (let wi = 0; wi < words.length; wi++) {
478
+ out.push({
479
+ word: words[wi],
480
+ start: start + wi * wordDur,
481
+ end: start + (wi + 1) * wordDur,
482
+ });
483
+ }
484
+ }
485
+ return out;
486
+ }
487
+
488
+ // json-only models (gpt-4o-transcribe etc): distribute over the speech
489
+ // window of the chunk, skipping any leading silence detected by ffmpeg.
490
+ const fullText = String(data?.text ?? '').trim();
491
+ const words = fullText.split(/\s+/).filter(Boolean);
492
+ const chunkDur = 60;
493
+ const chunkSpeechOffset = silences
494
+ .filter(
495
+ (s) =>
496
+ s.start <= offsetSeconds + 1 &&
497
+ s.end > offsetSeconds &&
498
+ s.end <= offsetSeconds + chunkDur,
499
+ )
500
+ .reduce((max, s) => Math.max(max, s.end - offsetSeconds), 0);
501
+ const speechDur = Math.max(1, chunkDur - chunkSpeechOffset);
502
+ const wordDur = words.length > 0 ? speechDur / words.length : 0;
503
+ for (let wi = 0; wi < words.length; wi++) {
504
+ out.push({
505
+ word: words[wi],
506
+ start: offsetSeconds + chunkSpeechOffset + wi * wordDur,
507
+ end: offsetSeconds + chunkSpeechOffset + (wi + 1) * wordDur,
508
+ });
509
+ }
510
+ return out;
511
+ }
512
+
114
513
  async handle(job: {
115
514
  id: number;
116
515
  type: string;
@@ -157,8 +556,22 @@ export class CourseAudioTranscriptionService implements OnModuleInit, IJobHandle
157
556
  const settings = await this.settingService.getSettingValues([
158
557
  'ai-openai-profile-id',
159
558
  'lms-audio-transcription-enabled',
559
+ 'lms-subtitle-reading-speed',
560
+ 'lms-subtitle-silence-noise-db',
561
+ 'lms-subtitle-silence-min-duration',
562
+ 'lms-transcription-model',
160
563
  ]);
161
564
  const transcriptionEnabled = settings['lms-audio-transcription-enabled'] !== false;
565
+ const transcriptionModel = String(settings['lms-transcription-model'] ?? 'whisper-1');
566
+ // The model choice drives the mode: whisper-1 returns real timestamps, so it
567
+ // runs in a single pass. The gpt-4o models return no timestamps, so they run
568
+ // hybrid — whisper-1 supplies the timing and is merged onto the gpt-4o text.
569
+ const useHybrid = transcriptionModel !== 'whisper-1';
570
+ const readingSpeedCps = this.resolveReadingSpeedCps(settings['lms-subtitle-reading-speed']);
571
+ const silenceParams = this.resolveSilenceParams(
572
+ settings['lms-subtitle-silence-noise-db'],
573
+ settings['lms-subtitle-silence-min-duration'],
574
+ );
162
575
 
163
576
  if (!transcriptionEnabled) {
164
577
  const msg = `Transcrição desabilitada nas configurações do LMS (lms-audio-transcription-enabled=false).`;
@@ -199,7 +612,8 @@ export class CourseAudioTranscriptionService implements OnModuleInit, IJobHandle
199
612
  where: { id: courseId },
200
613
  include: { locale: true },
201
614
  });
202
- const language = course?.locale?.code ?? 'pt';
615
+ const language = (course?.locale?.code ?? 'pt').split('-')[0];
616
+ const localeId: number | null = course?.locale_id ?? null;
203
617
 
204
618
  const tempDir = join(tmpdir(), `lms-transcribe-${lessonId}-${Date.now()}`);
205
619
  await fs.mkdir(tempDir, { recursive: true });
@@ -281,12 +695,15 @@ export class CourseAudioTranscriptionService implements OnModuleInit, IJobHandle
281
695
  chunks: chunks.length,
282
696
  });
283
697
 
284
- const allSegments: Array<{
285
- course_lesson_id: number;
286
- start_seconds: number;
287
- end_seconds: number;
288
- text: string;
289
- }> = [];
698
+ // Detect silence in the full normalized audio so subtitle starts can be
699
+ // aligned to where speech actually begins (intros, music, long pauses).
700
+ const silences = await this.detectSilences(
701
+ normalizedAudioPath,
702
+ silenceParams.noiseDb,
703
+ silenceParams.minDuration,
704
+ );
705
+
706
+ const allWords: Array<{ word: string; start: number; end: number }> = [];
290
707
 
291
708
  for (let i = 0; i < chunks.length; i += 1) {
292
709
  const chunkName = chunks[i];
@@ -305,44 +722,36 @@ export class CourseAudioTranscriptionService implements OnModuleInit, IJobHandle
305
722
  const offsetSeconds = i * 60;
306
723
  const chunkBuffer = await fs.readFile(chunkPath);
307
724
 
308
- const formData = new FormData();
309
- formData.append(
310
- 'file',
311
- new Blob([chunkBuffer], { type: 'audio/mpeg' }),
312
- chunkName,
313
- );
314
- formData.append('model', 'whisper-1');
315
- formData.append('response_format', 'verbose_json');
316
- formData.append('language', language);
317
-
318
- const headers: Record<string, string> = {
319
- Authorization: `Bearer ${apiKey}`,
320
- };
321
- const maybeHeaders = (formData as any).getHeaders?.();
322
- if (maybeHeaders && typeof maybeHeaders === 'object') {
323
- Object.assign(headers, maybeHeaders);
324
- }
325
-
326
- const response = await axios.post(
327
- 'https://api.openai.com/v1/audio/transcriptions',
328
- formData,
329
- { headers },
330
- );
331
-
332
- const segments = response.data?.segments ?? [];
333
- for (const segment of segments) {
334
- const text = String(segment?.text ?? '').trim();
335
- if (!text) continue;
336
-
337
- allSegments.push({
338
- course_lesson_id: lessonId,
339
- start_seconds: offsetSeconds + Number(segment?.start ?? 0),
340
- end_seconds: offsetSeconds + Number(segment?.end ?? 0),
341
- text,
342
- });
725
+ if (useHybrid) {
726
+ // Hybrid: real timing from whisper-1 + premium text from the chosen
727
+ // model, merged by word alignment (see alignTextToTimings).
728
+ const [timingData, textData] = await Promise.all([
729
+ this.transcribeChunk(chunkBuffer, chunkName, 'whisper-1', apiKey, language),
730
+ this.transcribeChunk(chunkBuffer, chunkName, transcriptionModel, apiKey, language),
731
+ ]);
732
+ const timedWords = this.chunkResponseToWords(timingData, offsetSeconds, silences);
733
+ const cleanText = String(textData?.text ?? '').trim();
734
+ allWords.push(...alignTextToTimings(timedWords, cleanText));
735
+ } else {
736
+ const data = await this.transcribeChunk(
737
+ chunkBuffer,
738
+ chunkName,
739
+ transcriptionModel,
740
+ apiKey,
741
+ language,
742
+ );
743
+ allWords.push(...this.chunkResponseToWords(data, offsetSeconds, silences));
343
744
  }
344
745
  }
345
746
 
747
+ const allSegments = this.buildSubtitleSegments(allWords, readingSpeedCps, silences).map((seg) => ({
748
+ course_lesson_id: lessonId,
749
+ locale_id: localeId,
750
+ start_seconds: seg.start_seconds,
751
+ end_seconds: seg.end_seconds,
752
+ text: seg.text,
753
+ }));
754
+
346
755
  await emitProgress('Salvando transcrição gerada pela IA...', {
347
756
  phase: 'transcription_save',
348
757
  lessonId,
@@ -350,7 +759,7 @@ export class CourseAudioTranscriptionService implements OnModuleInit, IJobHandle
350
759
  });
351
760
  await this.prismaService.$transaction([
352
761
  (this.prismaService as any).course_lesson_transcription_segment.deleteMany({
353
- where: { course_lesson_id: lessonId },
762
+ where: { course_lesson_id: lessonId, locale_id: localeId },
354
763
  }),
355
764
  (this.prismaService as any).course_lesson_transcription_segment.createMany({
356
765
  data: allSegments,
@@ -361,6 +770,25 @@ export class CourseAudioTranscriptionService implements OnModuleInit, IJobHandle
361
770
  `Transcription saved for lesson ${lessonId}: ${allSegments.length} segments`,
362
771
  );
363
772
 
773
+ // Record AI cost (transcription is billed per second of audio). Hybrid
774
+ // mode runs two passes (whisper-1 for timing + the chosen model for text),
775
+ // so both are charged.
776
+ const audioSeconds = allWords.length > 0 ? allWords[allWords.length - 1].end : 0;
777
+ await this.aiUsageService.recordTranscriptionUsage({
778
+ courseId,
779
+ lessonId,
780
+ model: transcriptionModel,
781
+ audioSeconds,
782
+ });
783
+ if (useHybrid) {
784
+ await this.aiUsageService.recordTranscriptionUsage({
785
+ courseId,
786
+ lessonId,
787
+ model: 'whisper-1',
788
+ audioSeconds,
789
+ });
790
+ }
791
+
364
792
  // Automatically trigger XP calculation after transcription completes.
365
793
  try {
366
794
  const existingMap = await (this.prismaService as any).lesson_xp_map.findUnique({