@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
@@ -2,6 +2,7 @@ import { PrismaService } from '@hed-hog/api-prisma';
2
2
  import { FileService, NotificationService, SettingService } from '@hed-hog/core';
3
3
  import { DatabaseQueueProvider, IJobHandler, QueueHandlerRegistry } from '@hed-hog/queue';
4
4
  import { OnModuleInit } from '@nestjs/common';
5
+ import { CourseAiUsageService } from './course-ai-usage.service';
5
6
  export declare class CourseAudioTranscriptionService implements OnModuleInit, IJobHandler {
6
7
  private readonly prismaService;
7
8
  private readonly settingService;
@@ -9,12 +10,75 @@ export declare class CourseAudioTranscriptionService implements OnModuleInit, IJ
9
10
  private readonly notificationService;
10
11
  private readonly registry;
11
12
  private readonly dbQueue;
13
+ private readonly aiUsageService;
12
14
  private readonly logger;
13
15
  private createProgressEvent;
14
16
  private resolveNotificationProgress;
15
17
  private updateAsyncNotification;
16
- constructor(prismaService: PrismaService, settingService: SettingService, fileService: FileService, notificationService: NotificationService, registry: QueueHandlerRegistry, dbQueue: DatabaseQueueProvider);
18
+ constructor(prismaService: PrismaService, settingService: SettingService, fileService: FileService, notificationService: NotificationService, registry: QueueHandlerRegistry, dbQueue: DatabaseQueueProvider, aiUsageService: CourseAiUsageService);
17
19
  onModuleInit(): void;
20
+ private static readonly SUB_MAX_CHARS_PER_LINE;
21
+ private static readonly SUB_MAX_LINES;
22
+ private static readonly SUB_MAX_CPS;
23
+ private static readonly SUB_MAX_DURATION;
24
+ private static readonly SUB_MIN_DURATION;
25
+ private static readonly SUB_MIN_GAP;
26
+ private static readonly SUB_PAUSE_SPLIT;
27
+ private static readonly SUB_SOFT_BREAK_MIN_CHARS;
28
+ private static readonly SUB_SILENCE_NOISE_DB;
29
+ private static readonly SUB_SILENCE_MIN_DURATION;
30
+ /**
31
+ * Parses the configured reading speed (characters per second) from the
32
+ * `lms-subtitle-reading-speed` setting value, e.g. "...(15 cps)". Falls back
33
+ * to the Netflix adult default and clamps to a sane range.
34
+ */
35
+ private resolveReadingSpeedCps;
36
+ /**
37
+ * Resolves the silencedetect parameters from settings, falling back to the
38
+ * defaults and clamping to sane ranges (noise must be negative dB; duration a
39
+ * small positive number of seconds).
40
+ */
41
+ private resolveSilenceParams;
42
+ /**
43
+ * Detects silence intervals in the audio using FFmpeg's silencedetect filter.
44
+ * Subtitle starts are later snapped to the end of any silence they fall into,
45
+ * so captions appear when speech begins (not during intros/music or pauses).
46
+ * Returns an empty list on failure — silence alignment is best-effort.
47
+ */
48
+ private detectSilences;
49
+ /**
50
+ * Groups word-level timestamps into subtitle cues and adjusts their timing to
51
+ * the Netflix style guide. Text is stored on a single line; line breaking is a
52
+ * presentation concern handled at VTT generation (see subtitle.util.ts).
53
+ *
54
+ * `silences` (from detectSilences) is used to snap each cue's start to where
55
+ * speech actually begins, correcting Whisper's drift over intros/pauses.
56
+ */
57
+ private buildSubtitleSegments;
58
+ /**
59
+ * Merges single-word cues into a neighbour when they are not separated by a
60
+ * real pause — avoiding isolated words on screen. A lone word kept apart by a
61
+ * pause (≥ SUB_PAUSE_SPLIT) is preserved, since it is a genuine pause/emphasis.
62
+ * Merges only happen when the combined cue still fits two lines and the max
63
+ * duration, so size/legibility rules are never violated.
64
+ */
65
+ private mergeLoneWordCues;
66
+ /**
67
+ * Transcribes a single audio chunk with the given model and returns the raw
68
+ * OpenAI response body. whisper-1 uses verbose_json with word/segment
69
+ * timestamps; the gpt-4o-transcribe family only supports json (text only).
70
+ */
71
+ private transcribeChunk;
72
+ /**
73
+ * Converts a raw chunk transcription response into absolute-timed words,
74
+ * applying `offsetSeconds` (the chunk's position in the full audio):
75
+ * - words[] → real per-word timestamps (whisper-1)
76
+ * - segments[] → synthesize even word timing within each segment (whisper-1
77
+ * verbose_json without word granularity)
78
+ * - text only → distribute words evenly across the chunk's speech window,
79
+ * skipping any leading silence detected by ffmpeg (gpt-4o)
80
+ */
81
+ private chunkResponseToWords;
18
82
  handle(job: {
19
83
  id: number;
20
84
  type: string;
@@ -1 +1 @@
1
- {"version":3,"file":"course-audio-transcription.service.d.ts","sourceRoot":"","sources":["../../src/course/course-audio-transcription.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAgC,WAAW,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/G,OAAO,EAAE,qBAAqB,EAAE,WAAW,EAAqB,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAC7G,OAAO,EAIH,YAAY,EAEf,MAAM,gBAAgB,CAAC;AAexB,qBACa,+BAAgC,YAAW,YAAY,EAAE,WAAW;IAsE7E,OAAO,CAAC,QAAQ,CAAC,aAAa;IAE9B,OAAO,CAAC,QAAQ,CAAC,cAAc;IAE/B,OAAO,CAAC,QAAQ,CAAC,WAAW;IAE5B,OAAO,CAAC,QAAQ,CAAC,mBAAmB;IAEpC,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAEzB,OAAO,CAAC,QAAQ,CAAC,OAAO;IA/E1B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoD;YAE7D,mBAAmB;IAqBjC,OAAO,CAAC,2BAA2B;YAqBrB,uBAAuB;gBAyBlB,aAAa,EAAE,aAAa,EAE5B,cAAc,EAAE,cAAc,EAE9B,WAAW,EAAE,WAAW,EAExB,mBAAmB,EAAE,mBAAmB,EAExC,QAAQ,EAAE,oBAAoB,EAE9B,OAAO,EAAE,qBAAqB;IAGjD,YAAY;IAKN,MAAM,CAAC,GAAG,EAAE;QAChB,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAC7B,QAAQ,EAAE,MAAM,CAAC;QACjB,YAAY,EAAE,MAAM,CAAC;QACrB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC9B,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC9B,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KAClC,GAAG,OAAO,CAAC,IAAI,CAAC;CA+TlB"}
1
+ {"version":3,"file":"course-audio-transcription.service.d.ts","sourceRoot":"","sources":["../../src/course/course-audio-transcription.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAgC,WAAW,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/G,OAAO,EAAE,qBAAqB,EAAE,WAAW,EAAqB,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAC7G,OAAO,EAIH,YAAY,EAEf,MAAM,gBAAgB,CAAC;AAOxB,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AAUjE,qBACa,+BAAgC,YAAW,YAAY,EAAE,WAAW;IAsE7E,OAAO,CAAC,QAAQ,CAAC,aAAa;IAE9B,OAAO,CAAC,QAAQ,CAAC,cAAc;IAE/B,OAAO,CAAC,QAAQ,CAAC,WAAW;IAE5B,OAAO,CAAC,QAAQ,CAAC,mBAAmB;IAEpC,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAEzB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAExB,OAAO,CAAC,QAAQ,CAAC,cAAc;IAjFjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoD;YAE7D,mBAAmB;IAqBjC,OAAO,CAAC,2BAA2B;YAqBrB,uBAAuB;gBAyBlB,aAAa,EAAE,aAAa,EAE5B,cAAc,EAAE,cAAc,EAE9B,WAAW,EAAE,WAAW,EAExB,mBAAmB,EAAE,mBAAmB,EAExC,QAAQ,EAAE,oBAAoB,EAE9B,OAAO,EAAE,qBAAqB,EAE9B,cAAc,EAAE,oBAAoB;IAGvD,YAAY;IAMZ,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,sBAAsB,CAAM;IACpD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAK;IAC1C,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAM;IACzC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CAAK;IAC7C,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IACjD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAS;IAC5C,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAO;IAI9C,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,wBAAwB,CAAM;IAGtD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,oBAAoB,CAAO;IACnD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,wBAAwB,CAAO;IAEvD;;;;OAIG;IACH,OAAO,CAAC,sBAAsB;IAS9B;;;;OAIG;IACH,OAAO,CAAC,oBAAoB;IAiB5B;;;;;OAKG;YACW,cAAc;IA8C5B;;;;;;;OAOG;IACH,OAAO,CAAC,qBAAqB;IAkH7B;;;;;;OAMG;IACH,OAAO,CAAC,iBAAiB;IA6BzB;;;;OAIG;YACW,eAAe;IAiD7B;;;;;;;;OAQG;IACH,OAAO,CAAC,oBAAoB;IAqEtB,MAAM,CAAC,GAAG,EAAE;QAChB,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAC7B,QAAQ,EAAE,MAAM,CAAC;QACjB,YAAY,EAAE,MAAM,CAAC;QACrB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC9B,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC9B,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KAClC,GAAG,OAAO,CAAC,IAAI,CAAC;CA4VlB"}
@@ -27,6 +27,8 @@ const fs_1 = require("fs");
27
27
  const os_1 = require("os");
28
28
  const path_1 = require("path");
29
29
  const util_1 = require("util");
30
+ const course_ai_usage_service_1 = require("./course-ai-usage.service");
31
+ const subtitle_util_1 = require("./subtitle.util");
30
32
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
31
33
  let CourseAudioTranscriptionService = CourseAudioTranscriptionService_1 = class CourseAudioTranscriptionService {
32
34
  async createProgressEvent(queueJobId, message, metadata) {
@@ -72,21 +74,323 @@ let CourseAudioTranscriptionService = CourseAudioTranscriptionService_1 = class
72
74
  this.logger.warn(`Failed to update async notification ${context.notificationId} for user ${context.userId}: ${error instanceof Error ? error.message : 'unknown error'}`);
73
75
  }
74
76
  }
75
- constructor(prismaService, settingService, fileService, notificationService, registry, dbQueue) {
77
+ constructor(prismaService, settingService, fileService, notificationService, registry, dbQueue, aiUsageService) {
76
78
  this.prismaService = prismaService;
77
79
  this.settingService = settingService;
78
80
  this.fileService = fileService;
79
81
  this.notificationService = notificationService;
80
82
  this.registry = registry;
81
83
  this.dbQueue = dbQueue;
84
+ this.aiUsageService = aiUsageService;
82
85
  this.logger = new common_1.Logger(CourseAudioTranscriptionService_1.name);
83
86
  }
84
87
  onModuleInit() {
85
88
  this.registry.register('lms.audio.transcribe', this);
86
89
  this.logger.log('Registered handler for "lms.audio.transcribe"');
87
90
  }
88
- async handle(job) {
91
+ /**
92
+ * Parses the configured reading speed (characters per second) from the
93
+ * `lms-subtitle-reading-speed` setting value, e.g. "...(15 cps)". Falls back
94
+ * to the Netflix adult default and clamps to a sane range.
95
+ */
96
+ resolveReadingSpeedCps(rawValue) {
97
+ const match = String(rawValue !== null && rawValue !== void 0 ? rawValue : '').match(/(\d+)\s*cps/i);
98
+ const cps = match ? Number(match[1]) : NaN;
99
+ if (!Number.isFinite(cps) || cps < 6 || cps > 25) {
100
+ return CourseAudioTranscriptionService_1.SUB_MAX_CPS;
101
+ }
102
+ return cps;
103
+ }
104
+ /**
105
+ * Resolves the silencedetect parameters from settings, falling back to the
106
+ * defaults and clamping to sane ranges (noise must be negative dB; duration a
107
+ * small positive number of seconds).
108
+ */
109
+ resolveSilenceParams(noiseRaw, durationRaw) {
110
+ const Svc = CourseAudioTranscriptionService_1;
111
+ let noiseDb = Number(noiseRaw);
112
+ // Noise must be a strictly negative dB value (0/empty/positive are invalid).
113
+ if (!noiseRaw || !Number.isFinite(noiseDb) || noiseDb >= 0 || noiseDb < -90) {
114
+ noiseDb = Svc.SUB_SILENCE_NOISE_DB;
115
+ }
116
+ let minDuration = Number(durationRaw);
117
+ if (!Number.isFinite(minDuration) || minDuration <= 0 || minDuration > 10) {
118
+ minDuration = Svc.SUB_SILENCE_MIN_DURATION;
119
+ }
120
+ return { noiseDb, minDuration };
121
+ }
122
+ /**
123
+ * Detects silence intervals in the audio using FFmpeg's silencedetect filter.
124
+ * Subtitle starts are later snapped to the end of any silence they fall into,
125
+ * so captions appear when speech begins (not during intros/music or pauses).
126
+ * Returns an empty list on failure — silence alignment is best-effort.
127
+ */
128
+ async detectSilences(audioPath, noiseDb = CourseAudioTranscriptionService_1.SUB_SILENCE_NOISE_DB, minDuration = CourseAudioTranscriptionService_1.SUB_SILENCE_MIN_DURATION) {
129
+ try {
130
+ const { stderr } = await execFileAsync('ffmpeg', [
131
+ '-i',
132
+ audioPath,
133
+ '-af',
134
+ `silencedetect=noise=${noiseDb}dB:d=${minDuration}`,
135
+ '-f',
136
+ 'null',
137
+ '-',
138
+ ], { maxBuffer: 1024 * 1024 * 20, windowsHide: true });
139
+ const silences = [];
140
+ let curStart = null;
141
+ for (const line of String(stderr !== null && stderr !== void 0 ? stderr : '').split('\n')) {
142
+ const startMatch = line.match(/silence_start:\s*(-?[0-9.]+)/);
143
+ if (startMatch) {
144
+ curStart = Math.max(0, parseFloat(startMatch[1]));
145
+ continue;
146
+ }
147
+ const endMatch = line.match(/silence_end:\s*(-?[0-9.]+)/);
148
+ if (endMatch && curStart !== null) {
149
+ const end = parseFloat(endMatch[1]);
150
+ if (Number.isFinite(end) && end > curStart) {
151
+ silences.push({ start: curStart, end });
152
+ }
153
+ curStart = null;
154
+ }
155
+ }
156
+ return silences;
157
+ }
158
+ catch (error) {
159
+ this.logger.warn(`silencedetect failed, skipping start alignment: ${error instanceof Error ? error.message : String(error)}`);
160
+ return [];
161
+ }
162
+ }
163
+ /**
164
+ * Groups word-level timestamps into subtitle cues and adjusts their timing to
165
+ * the Netflix style guide. Text is stored on a single line; line breaking is a
166
+ * presentation concern handled at VTT generation (see subtitle.util.ts).
167
+ *
168
+ * `silences` (from detectSilences) is used to snap each cue's start to where
169
+ * speech actually begins, correcting Whisper's drift over intros/pauses.
170
+ */
171
+ buildSubtitleSegments(allWords, maxCps = CourseAudioTranscriptionService_1.SUB_MAX_CPS, silences = []) {
172
+ const Svc = CourseAudioTranscriptionService_1;
173
+ // Phase 1 — group words into cues respecting char count, duration and pauses.
174
+ const cues = [];
175
+ let buffer = [];
176
+ let bufferText = '';
177
+ const flush = () => {
178
+ if (buffer.length === 0)
179
+ return;
180
+ cues.push(buffer);
181
+ buffer = [];
182
+ bufferText = '';
183
+ };
184
+ for (const entry of allWords) {
185
+ const word = entry.word.trim();
186
+ if (!word)
187
+ continue;
188
+ const prev = buffer[buffer.length - 1];
189
+ const gapFromPrev = prev ? entry.start - prev.end : 0;
190
+ const tentativeText = bufferText ? `${bufferText} ${word}` : word;
191
+ const tentativeDuration = buffer.length > 0 ? entry.end - buffer[0].start : entry.end - entry.start;
192
+ if (buffer.length > 0 &&
193
+ (!(0, subtitle_util_1.fitsInTwoLines)(tentativeText, Svc.SUB_MAX_CHARS_PER_LINE) ||
194
+ tentativeDuration > Svc.SUB_MAX_DURATION ||
195
+ gapFromPrev >= Svc.SUB_PAUSE_SPLIT)) {
196
+ flush();
197
+ }
198
+ buffer.push(entry);
199
+ bufferText = bufferText ? `${bufferText} ${word}` : word;
200
+ // Break naturally at sentence-ending punctuation.
201
+ if (/[.!?…]$/.test(word)) {
202
+ flush();
203
+ continue;
204
+ }
205
+ // Soft break at clause punctuation once the cue is already substantial,
206
+ // so subtitles break at natural points (commas, pauses) instead of mid-clause.
207
+ if (/[,;:]$/.test(word) && bufferText.length >= Svc.SUB_SOFT_BREAK_MIN_CHARS) {
208
+ flush();
209
+ }
210
+ }
211
+ flush();
212
+ // Phase 1.5 — merge single-word cues that are not real pauses/emphasis.
213
+ const mergedCues = this.mergeLoneWordCues(cues);
214
+ // Phase 2 — build text and adjust display timing per cue.
215
+ const draft = mergedCues
216
+ .map((words) => ({
217
+ start: words[0].start,
218
+ endRaw: words[words.length - 1].end,
219
+ text: words.map((w) => w.word).join(' ').replace(/\s+/g, ' ').trim(),
220
+ }))
221
+ .filter((c) => c.text.length > 0);
222
+ const round = (n) => Math.max(0, Math.round(n * 1000) / 1000);
223
+ // Snap a start to the end of the silence it falls into (where speech begins).
224
+ // end_seconds is computed separately from `start`, so it will always be ≥ start.
225
+ const snapStart = (rawStart) => {
226
+ for (const s of silences) {
227
+ if (rawStart >= s.start && rawStart < s.end) {
228
+ return s.end;
229
+ }
230
+ }
231
+ return rawStart;
232
+ };
233
+ const starts = draft.map((seg) => snapStart(seg.start));
234
+ return draft.map((seg, i) => {
235
+ const start = starts[i];
236
+ // Extend duration to satisfy minimum display time and reading speed (CPS),
237
+ // capped at the maximum duration.
238
+ let end = Math.max(seg.endRaw, start + Svc.SUB_MIN_DURATION, start + seg.text.length / maxCps);
239
+ end = Math.min(end, start + Svc.SUB_MAX_DURATION);
240
+ // Never overlap the next cue (keep a minimum gap from its aligned start).
241
+ const nextStart = starts[i + 1];
242
+ if (nextStart !== undefined) {
243
+ const maxEnd = nextStart - Svc.SUB_MIN_GAP;
244
+ if (maxEnd > start && end > maxEnd) {
245
+ end = maxEnd;
246
+ }
247
+ }
248
+ if (end <= start) {
249
+ end = start + Svc.SUB_MIN_DURATION;
250
+ }
251
+ return {
252
+ start_seconds: round(start),
253
+ end_seconds: round(end),
254
+ text: seg.text,
255
+ };
256
+ });
257
+ }
258
+ /**
259
+ * Merges single-word cues into a neighbour when they are not separated by a
260
+ * real pause — avoiding isolated words on screen. A lone word kept apart by a
261
+ * pause (≥ SUB_PAUSE_SPLIT) is preserved, since it is a genuine pause/emphasis.
262
+ * Merges only happen when the combined cue still fits two lines and the max
263
+ * duration, so size/legibility rules are never violated.
264
+ */
265
+ mergeLoneWordCues(cues) {
266
+ const Svc = CourseAudioTranscriptionService_1;
267
+ const merged = [];
268
+ for (const cue of cues) {
269
+ const prev = merged[merged.length - 1];
270
+ if (prev && (prev.length === 1 || cue.length === 1)) {
271
+ const gap = cue[0].start - prev[prev.length - 1].end;
272
+ const combined = [...prev, ...cue];
273
+ const combinedText = combined.map((w) => w.word).join(' ');
274
+ const combinedDuration = cue[cue.length - 1].end - prev[0].start;
275
+ if (gap < Svc.SUB_PAUSE_SPLIT &&
276
+ combinedDuration <= Svc.SUB_MAX_DURATION &&
277
+ (0, subtitle_util_1.fitsInTwoLines)(combinedText, Svc.SUB_MAX_CHARS_PER_LINE)) {
278
+ merged[merged.length - 1] = combined;
279
+ continue;
280
+ }
281
+ }
282
+ merged.push(cue);
283
+ }
284
+ return merged;
285
+ }
286
+ /**
287
+ * Transcribes a single audio chunk with the given model and returns the raw
288
+ * OpenAI response body. whisper-1 uses verbose_json with word/segment
289
+ * timestamps; the gpt-4o-transcribe family only supports json (text only).
290
+ */
291
+ async transcribeChunk(chunkBuffer, chunkName, model, apiKey, language) {
292
+ var _a, _b, _c, _d, _e;
293
+ const formData = new FormData();
294
+ formData.append('file', new Blob([new Uint8Array(chunkBuffer)], { type: 'audio/mpeg' }), chunkName);
295
+ const isWhisper = model === 'whisper-1';
296
+ formData.append('model', model);
297
+ // Only whisper-1 supports verbose_json + timestamp_granularities; the
298
+ // gpt-4o-transcribe family returns 400 for anything other than json/text.
299
+ formData.append('response_format', isWhisper ? 'verbose_json' : 'json');
300
+ formData.append('language', language);
301
+ if (isWhisper) {
302
+ formData.append('timestamp_granularities[]', 'word');
303
+ formData.append('timestamp_granularities[]', 'segment');
304
+ }
305
+ const headers = {
306
+ Authorization: `Bearer ${apiKey}`,
307
+ };
308
+ const maybeHeaders = (_b = (_a = formData).getHeaders) === null || _b === void 0 ? void 0 : _b.call(_a);
309
+ if (maybeHeaders && typeof maybeHeaders === 'object') {
310
+ Object.assign(headers, maybeHeaders);
311
+ }
312
+ try {
313
+ const response = await axios_1.default.post('https://api.openai.com/v1/audio/transcriptions', formData, { headers });
314
+ return response.data;
315
+ }
316
+ catch (axiosErr) {
317
+ const status = (_c = axiosErr === null || axiosErr === void 0 ? void 0 : axiosErr.response) === null || _c === void 0 ? void 0 : _c.status;
318
+ const body = JSON.stringify((_e = (_d = axiosErr === null || axiosErr === void 0 ? void 0 : axiosErr.response) === null || _d === void 0 ? void 0 : _d.data) !== null && _e !== void 0 ? _e : {});
319
+ this.logger.error(`OpenAI transcription request failed — model=${model} language=${language} status=${status} body=${body}`);
320
+ throw axiosErr;
321
+ }
322
+ }
323
+ /**
324
+ * Converts a raw chunk transcription response into absolute-timed words,
325
+ * applying `offsetSeconds` (the chunk's position in the full audio):
326
+ * - words[] → real per-word timestamps (whisper-1)
327
+ * - segments[] → synthesize even word timing within each segment (whisper-1
328
+ * verbose_json without word granularity)
329
+ * - text only → distribute words evenly across the chunk's speech window,
330
+ * skipping any leading silence detected by ffmpeg (gpt-4o)
331
+ */
332
+ chunkResponseToWords(data, offsetSeconds, silences) {
89
333
  var _a, _b, _c, _d, _e, _f, _g, _h, _j;
334
+ const out = [];
335
+ const wordEntries = (_a = data === null || data === void 0 ? void 0 : data.words) !== null && _a !== void 0 ? _a : [];
336
+ if (wordEntries.length > 0) {
337
+ for (const w of wordEntries) {
338
+ const wordText = String((_b = w === null || w === void 0 ? void 0 : w.word) !== null && _b !== void 0 ? _b : '').trim();
339
+ if (!wordText)
340
+ continue;
341
+ out.push({
342
+ word: wordText,
343
+ start: offsetSeconds + Number((_c = w === null || w === void 0 ? void 0 : w.start) !== null && _c !== void 0 ? _c : 0),
344
+ end: offsetSeconds + Number((_d = w === null || w === void 0 ? void 0 : w.end) !== null && _d !== void 0 ? _d : 0),
345
+ });
346
+ }
347
+ return out;
348
+ }
349
+ const segments = (_e = data === null || data === void 0 ? void 0 : data.segments) !== null && _e !== void 0 ? _e : [];
350
+ if (segments.length > 0) {
351
+ // whisper-1 verbose_json fallback: synthesize from segments
352
+ for (const segment of segments) {
353
+ const text = String((_f = segment === null || segment === void 0 ? void 0 : segment.text) !== null && _f !== void 0 ? _f : '').trim();
354
+ if (!text)
355
+ continue;
356
+ const start = offsetSeconds + Number((_g = segment === null || segment === void 0 ? void 0 : segment.start) !== null && _g !== void 0 ? _g : 0);
357
+ const end = offsetSeconds + Number((_h = segment === null || segment === void 0 ? void 0 : segment.end) !== null && _h !== void 0 ? _h : 0);
358
+ const words = text.split(/\s+/).filter(Boolean);
359
+ const dur = Math.max(0, end - start);
360
+ const wordDur = words.length > 0 ? dur / words.length : 0;
361
+ for (let wi = 0; wi < words.length; wi++) {
362
+ out.push({
363
+ word: words[wi],
364
+ start: start + wi * wordDur,
365
+ end: start + (wi + 1) * wordDur,
366
+ });
367
+ }
368
+ }
369
+ return out;
370
+ }
371
+ // json-only models (gpt-4o-transcribe etc): distribute over the speech
372
+ // window of the chunk, skipping any leading silence detected by ffmpeg.
373
+ const fullText = String((_j = data === null || data === void 0 ? void 0 : data.text) !== null && _j !== void 0 ? _j : '').trim();
374
+ const words = fullText.split(/\s+/).filter(Boolean);
375
+ const chunkDur = 60;
376
+ const chunkSpeechOffset = silences
377
+ .filter((s) => s.start <= offsetSeconds + 1 &&
378
+ s.end > offsetSeconds &&
379
+ s.end <= offsetSeconds + chunkDur)
380
+ .reduce((max, s) => Math.max(max, s.end - offsetSeconds), 0);
381
+ const speechDur = Math.max(1, chunkDur - chunkSpeechOffset);
382
+ const wordDur = words.length > 0 ? speechDur / words.length : 0;
383
+ for (let wi = 0; wi < words.length; wi++) {
384
+ out.push({
385
+ word: words[wi],
386
+ start: offsetSeconds + chunkSpeechOffset + wi * wordDur,
387
+ end: offsetSeconds + chunkSpeechOffset + (wi + 1) * wordDur,
388
+ });
389
+ }
390
+ return out;
391
+ }
392
+ async handle(job) {
393
+ var _a, _b, _c, _d, _e;
90
394
  const { courseId, lessonId, audioFileId, parentJobId, notificationId, notificationUserId } = job.payload;
91
395
  const notificationContext = Number.isInteger(Number(notificationId)) &&
92
396
  Number.isInteger(Number(notificationUserId))
@@ -107,8 +411,19 @@ let CourseAudioTranscriptionService = CourseAudioTranscriptionService_1 = class
107
411
  const settings = await this.settingService.getSettingValues([
108
412
  'ai-openai-profile-id',
109
413
  'lms-audio-transcription-enabled',
414
+ 'lms-subtitle-reading-speed',
415
+ 'lms-subtitle-silence-noise-db',
416
+ 'lms-subtitle-silence-min-duration',
417
+ 'lms-transcription-model',
110
418
  ]);
111
419
  const transcriptionEnabled = settings['lms-audio-transcription-enabled'] !== false;
420
+ const transcriptionModel = String((_a = settings['lms-transcription-model']) !== null && _a !== void 0 ? _a : 'whisper-1');
421
+ // The model choice drives the mode: whisper-1 returns real timestamps, so it
422
+ // runs in a single pass. The gpt-4o models return no timestamps, so they run
423
+ // hybrid — whisper-1 supplies the timing and is merged onto the gpt-4o text.
424
+ const useHybrid = transcriptionModel !== 'whisper-1';
425
+ const readingSpeedCps = this.resolveReadingSpeedCps(settings['lms-subtitle-reading-speed']);
426
+ const silenceParams = this.resolveSilenceParams(settings['lms-subtitle-silence-noise-db'], settings['lms-subtitle-silence-min-duration']);
112
427
  if (!transcriptionEnabled) {
113
428
  const msg = `Transcrição desabilitada nas configurações do LMS (lms-audio-transcription-enabled=false).`;
114
429
  if (notificationContext) {
@@ -141,7 +456,8 @@ let CourseAudioTranscriptionService = CourseAudioTranscriptionService_1 = class
141
456
  where: { id: courseId },
142
457
  include: { locale: true },
143
458
  });
144
- const language = (_b = (_a = course === null || course === void 0 ? void 0 : course.locale) === null || _a === void 0 ? void 0 : _a.code) !== null && _b !== void 0 ? _b : 'pt';
459
+ const language = ((_c = (_b = course === null || course === void 0 ? void 0 : course.locale) === null || _b === void 0 ? void 0 : _b.code) !== null && _c !== void 0 ? _c : 'pt').split('-')[0];
460
+ const localeId = (_d = course === null || course === void 0 ? void 0 : course.locale_id) !== null && _d !== void 0 ? _d : null;
145
461
  const tempDir = (0, path_1.join)((0, os_1.tmpdir)(), `lms-transcribe-${lessonId}-${Date.now()}`);
146
462
  await fs_1.promises.mkdir(tempDir, { recursive: true });
147
463
  try {
@@ -209,7 +525,10 @@ let CourseAudioTranscriptionService = CourseAudioTranscriptionService_1 = class
209
525
  lessonId,
210
526
  chunks: chunks.length,
211
527
  });
212
- const allSegments = [];
528
+ // Detect silence in the full normalized audio so subtitle starts can be
529
+ // aligned to where speech actually begins (intros, music, long pauses).
530
+ const silences = await this.detectSilences(normalizedAudioPath, silenceParams.noiseDb, silenceParams.minDuration);
531
+ const allWords = [];
213
532
  for (let i = 0; i < chunks.length; i += 1) {
214
533
  const chunkName = chunks[i];
215
534
  if (!chunkName)
@@ -223,32 +542,29 @@ let CourseAudioTranscriptionService = CourseAudioTranscriptionService_1 = class
223
542
  const chunkPath = (0, path_1.join)(tempDir, chunkName);
224
543
  const offsetSeconds = i * 60;
225
544
  const chunkBuffer = await fs_1.promises.readFile(chunkPath);
226
- const formData = new FormData();
227
- formData.append('file', new Blob([chunkBuffer], { type: 'audio/mpeg' }), chunkName);
228
- formData.append('model', 'whisper-1');
229
- formData.append('response_format', 'verbose_json');
230
- formData.append('language', language);
231
- const headers = {
232
- Authorization: `Bearer ${apiKey}`,
233
- };
234
- const maybeHeaders = (_d = (_c = formData).getHeaders) === null || _d === void 0 ? void 0 : _d.call(_c);
235
- if (maybeHeaders && typeof maybeHeaders === 'object') {
236
- Object.assign(headers, maybeHeaders);
545
+ if (useHybrid) {
546
+ // Hybrid: real timing from whisper-1 + premium text from the chosen
547
+ // model, merged by word alignment (see alignTextToTimings).
548
+ const [timingData, textData] = await Promise.all([
549
+ this.transcribeChunk(chunkBuffer, chunkName, 'whisper-1', apiKey, language),
550
+ this.transcribeChunk(chunkBuffer, chunkName, transcriptionModel, apiKey, language),
551
+ ]);
552
+ const timedWords = this.chunkResponseToWords(timingData, offsetSeconds, silences);
553
+ const cleanText = String((_e = textData === null || textData === void 0 ? void 0 : textData.text) !== null && _e !== void 0 ? _e : '').trim();
554
+ allWords.push(...(0, subtitle_util_1.alignTextToTimings)(timedWords, cleanText));
237
555
  }
238
- const response = await axios_1.default.post('https://api.openai.com/v1/audio/transcriptions', formData, { headers });
239
- const segments = (_f = (_e = response.data) === null || _e === void 0 ? void 0 : _e.segments) !== null && _f !== void 0 ? _f : [];
240
- for (const segment of segments) {
241
- const text = String((_g = segment === null || segment === void 0 ? void 0 : segment.text) !== null && _g !== void 0 ? _g : '').trim();
242
- if (!text)
243
- continue;
244
- allSegments.push({
245
- course_lesson_id: lessonId,
246
- start_seconds: offsetSeconds + Number((_h = segment === null || segment === void 0 ? void 0 : segment.start) !== null && _h !== void 0 ? _h : 0),
247
- end_seconds: offsetSeconds + Number((_j = segment === null || segment === void 0 ? void 0 : segment.end) !== null && _j !== void 0 ? _j : 0),
248
- text,
249
- });
556
+ else {
557
+ const data = await this.transcribeChunk(chunkBuffer, chunkName, transcriptionModel, apiKey, language);
558
+ allWords.push(...this.chunkResponseToWords(data, offsetSeconds, silences));
250
559
  }
251
560
  }
561
+ const allSegments = this.buildSubtitleSegments(allWords, readingSpeedCps, silences).map((seg) => ({
562
+ course_lesson_id: lessonId,
563
+ locale_id: localeId,
564
+ start_seconds: seg.start_seconds,
565
+ end_seconds: seg.end_seconds,
566
+ text: seg.text,
567
+ }));
252
568
  await emitProgress('Salvando transcrição gerada pela IA...', {
253
569
  phase: 'transcription_save',
254
570
  lessonId,
@@ -256,13 +572,31 @@ let CourseAudioTranscriptionService = CourseAudioTranscriptionService_1 = class
256
572
  });
257
573
  await this.prismaService.$transaction([
258
574
  this.prismaService.course_lesson_transcription_segment.deleteMany({
259
- where: { course_lesson_id: lessonId },
575
+ where: { course_lesson_id: lessonId, locale_id: localeId },
260
576
  }),
261
577
  this.prismaService.course_lesson_transcription_segment.createMany({
262
578
  data: allSegments,
263
579
  }),
264
580
  ]);
265
581
  this.logger.log(`Transcription saved for lesson ${lessonId}: ${allSegments.length} segments`);
582
+ // Record AI cost (transcription is billed per second of audio). Hybrid
583
+ // mode runs two passes (whisper-1 for timing + the chosen model for text),
584
+ // so both are charged.
585
+ const audioSeconds = allWords.length > 0 ? allWords[allWords.length - 1].end : 0;
586
+ await this.aiUsageService.recordTranscriptionUsage({
587
+ courseId,
588
+ lessonId,
589
+ model: transcriptionModel,
590
+ audioSeconds,
591
+ });
592
+ if (useHybrid) {
593
+ await this.aiUsageService.recordTranscriptionUsage({
594
+ courseId,
595
+ lessonId,
596
+ model: 'whisper-1',
597
+ audioSeconds,
598
+ });
599
+ }
266
600
  // Automatically trigger XP calculation after transcription completes.
267
601
  try {
268
602
  const existingMap = await this.prismaService.lesson_xp_map.findUnique({
@@ -322,6 +656,22 @@ let CourseAudioTranscriptionService = CourseAudioTranscriptionService_1 = class
322
656
  }
323
657
  };
324
658
  exports.CourseAudioTranscriptionService = CourseAudioTranscriptionService;
659
+ // Subtitle rules — Netflix Timed Text Style Guide (pt-BR).
660
+ CourseAudioTranscriptionService.SUB_MAX_CHARS_PER_LINE = 42;
661
+ CourseAudioTranscriptionService.SUB_MAX_LINES = 2;
662
+ CourseAudioTranscriptionService.SUB_MAX_CPS = 17; // reading speed (chars per second)
663
+ CourseAudioTranscriptionService.SUB_MAX_DURATION = 7; // seconds
664
+ CourseAudioTranscriptionService.SUB_MIN_DURATION = 5 / 6; // ~0.833s
665
+ CourseAudioTranscriptionService.SUB_MIN_GAP = 0.084; // ~2 frames @ 24fps
666
+ CourseAudioTranscriptionService.SUB_PAUSE_SPLIT = 0.7; // silence that forces a new cue
667
+ // Soft break at commas/semicolons/colons only once the cue already holds a
668
+ // readable chunk (~one line), so we break at natural points without
669
+ // producing tiny fragments.
670
+ CourseAudioTranscriptionService.SUB_SOFT_BREAK_MIN_CHARS = 38;
671
+ // Silence detection used to snap subtitle starts to where speech actually
672
+ // begins (Whisper word timestamps drift over intros/music and long pauses).
673
+ CourseAudioTranscriptionService.SUB_SILENCE_NOISE_DB = -30; // silencedetect threshold
674
+ CourseAudioTranscriptionService.SUB_SILENCE_MIN_DURATION = 0.3; // min silence length (s)
325
675
  exports.CourseAudioTranscriptionService = CourseAudioTranscriptionService = CourseAudioTranscriptionService_1 = __decorate([
326
676
  (0, common_1.Injectable)(),
327
677
  __param(0, (0, common_1.Inject)((0, common_1.forwardRef)(() => api_prisma_1.PrismaService))),
@@ -330,11 +680,13 @@ exports.CourseAudioTranscriptionService = CourseAudioTranscriptionService = Cour
330
680
  __param(3, (0, common_1.Inject)((0, common_1.forwardRef)(() => core_1.NotificationService))),
331
681
  __param(4, (0, common_1.Inject)((0, common_1.forwardRef)(() => queue_1.QueueHandlerRegistry))),
332
682
  __param(5, (0, common_1.Inject)((0, common_1.forwardRef)(() => queue_1.DatabaseQueueProvider))),
683
+ __param(6, (0, common_1.Inject)((0, common_1.forwardRef)(() => course_ai_usage_service_1.CourseAiUsageService))),
333
684
  __metadata("design:paramtypes", [api_prisma_1.PrismaService,
334
685
  core_1.SettingService,
335
686
  core_1.FileService,
336
687
  core_1.NotificationService,
337
688
  queue_1.QueueHandlerRegistry,
338
- queue_1.DatabaseQueueProvider])
689
+ queue_1.DatabaseQueueProvider,
690
+ course_ai_usage_service_1.CourseAiUsageService])
339
691
  ], CourseAudioTranscriptionService);
340
692
  //# sourceMappingURL=course-audio-transcription.service.js.map