@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.
- package/dist/certificate/certificate.controller.d.ts +1 -1
- package/dist/certificate/certificate.controller.d.ts.map +1 -1
- package/dist/certificate/certificate.controller.js +4 -2
- package/dist/certificate/certificate.controller.js.map +1 -1
- package/dist/certificate/certificate.service.d.ts +50 -0
- package/dist/certificate/certificate.service.d.ts.map +1 -1
- package/dist/certificate/certificate.service.js +73 -0
- package/dist/certificate/certificate.service.js.map +1 -1
- package/dist/class-group/class-group.controller.d.ts +1 -0
- package/dist/class-group/class-group.controller.d.ts.map +1 -1
- package/dist/class-group/class-group.service.d.ts +1 -0
- package/dist/class-group/class-group.service.d.ts.map +1 -1
- package/dist/course/course-ai-usage.service.d.ts +58 -0
- package/dist/course/course-ai-usage.service.d.ts.map +1 -0
- package/dist/course/course-ai-usage.service.js +176 -0
- package/dist/course/course-ai-usage.service.js.map +1 -0
- package/dist/course/course-audio-transcription.service.d.ts +65 -1
- package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
- package/dist/course/course-audio-transcription.service.js +381 -29
- package/dist/course/course-audio-transcription.service.js.map +1 -1
- package/dist/course/course-export-scorm12.service.d.ts +3 -0
- package/dist/course/course-export-scorm12.service.d.ts.map +1 -1
- package/dist/course/course-export-scorm12.service.js +141 -6
- package/dist/course/course-export-scorm12.service.js.map +1 -1
- package/dist/course/course-export.service.d.ts.map +1 -1
- package/dist/course/course-export.service.js +2 -1
- package/dist/course/course-export.service.js.map +1 -1
- package/dist/course/course-lesson.controller.d.ts +25 -3
- package/dist/course/course-lesson.controller.d.ts.map +1 -1
- package/dist/course/course-lesson.controller.js +71 -8
- package/dist/course/course-lesson.controller.js.map +1 -1
- package/dist/course/course-structure.controller.d.ts +30 -7
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +37 -4
- package/dist/course/course-structure.controller.js.map +1 -1
- package/dist/course/course-structure.service.d.ts +37 -5
- package/dist/course/course-structure.service.d.ts.map +1 -1
- package/dist/course/course-structure.service.js +165 -20
- package/dist/course/course-structure.service.js.map +1 -1
- package/dist/course/course-transcription-translation.service.d.ts +31 -0
- package/dist/course/course-transcription-translation.service.d.ts.map +1 -0
- package/dist/course/course-transcription-translation.service.js +227 -0
- package/dist/course/course-transcription-translation.service.js.map +1 -0
- package/dist/course/course-video-agent-pipeline.service.d.ts +70 -0
- package/dist/course/course-video-agent-pipeline.service.d.ts.map +1 -0
- package/dist/course/course-video-agent-pipeline.service.js +398 -0
- package/dist/course/course-video-agent-pipeline.service.js.map +1 -0
- package/dist/course/course-video-hls.service.d.ts +14 -0
- package/dist/course/course-video-hls.service.d.ts.map +1 -1
- package/dist/course/course-video-hls.service.js +25 -8
- package/dist/course/course-video-hls.service.js.map +1 -1
- package/dist/course/course.controller.d.ts +2 -0
- package/dist/course/course.controller.d.ts.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +9 -0
- package/dist/course/course.module.js.map +1 -1
- package/dist/course/course.service.d.ts +2 -0
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +36 -2
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/dto/create-course-bulk-job.dto.d.ts +2 -1
- package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-bulk-job.dto.js +6 -1
- package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -1
- package/dist/course/dto/create-course-export.dto.d.ts +1 -0
- package/dist/course/dto/create-course-export.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-export.dto.js +6 -0
- package/dist/course/dto/create-course-export.dto.js.map +1 -1
- package/dist/course/ffmpeg.util.d.ts +10 -0
- package/dist/course/ffmpeg.util.d.ts.map +1 -0
- package/dist/course/ffmpeg.util.js +79 -0
- package/dist/course/ffmpeg.util.js.map +1 -0
- package/dist/course/lms-bulk-upload-automation.service.d.ts +3 -1
- package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload-automation.service.js +33 -16
- package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
- package/dist/course/lms-bulk-upload.controller.d.ts +3 -0
- package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload.service.d.ts +3 -0
- package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload.service.js +48 -29
- package/dist/course/lms-bulk-upload.service.js.map +1 -1
- package/dist/course/subtitle.util.d.ts +46 -0
- package/dist/course/subtitle.util.d.ts.map +1 -0
- package/dist/course/subtitle.util.js +206 -0
- package/dist/course/subtitle.util.js.map +1 -0
- package/dist/enterprise/training/training-admin.controller.d.ts +2 -0
- package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
- package/dist/enterprise/training/training-admin.service.d.ts +2 -0
- package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
- package/dist/enterprise/training/training-student.service.d.ts +27 -0
- package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
- package/dist/enterprise/training/training-student.service.js +197 -10
- package/dist/enterprise/training/training-student.service.js.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +3 -1
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +19 -5
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-map.module.js +2 -1
- package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -1
- package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +14 -0
- package/dist/lms.module.js.map +1 -1
- package/dist/platforma/dto/heartbeat.dto.d.ts +9 -0
- package/dist/platforma/dto/heartbeat.dto.d.ts.map +1 -0
- package/dist/platforma/dto/heartbeat.dto.js +50 -0
- package/dist/platforma/dto/heartbeat.dto.js.map +1 -0
- package/dist/platforma/handlers/emit-certificate.handler.d.ts +27 -0
- package/dist/platforma/handlers/emit-certificate.handler.d.ts.map +1 -0
- package/dist/platforma/handlers/emit-certificate.handler.js +117 -0
- package/dist/platforma/handlers/emit-certificate.handler.js.map +1 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts +31 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts.map +1 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.js +281 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.js.map +1 -0
- package/dist/platforma/platforma-heartbeat.service.d.ts +10 -0
- package/dist/platforma/platforma-heartbeat.service.d.ts.map +1 -0
- package/dist/platforma/platforma-heartbeat.service.js +50 -0
- package/dist/platforma/platforma-heartbeat.service.js.map +1 -0
- package/dist/platforma/platforma-performance.service.d.ts +121 -0
- package/dist/platforma/platforma-performance.service.d.ts.map +1 -0
- package/dist/platforma/platforma-performance.service.js +500 -0
- package/dist/platforma/platforma-performance.service.js.map +1 -0
- package/dist/platforma/platforma-search.service.d.ts +21 -0
- package/dist/platforma/platforma-search.service.d.ts.map +1 -0
- package/dist/platforma/platforma-search.service.js +64 -0
- package/dist/platforma/platforma-search.service.js.map +1 -0
- package/dist/platforma/platforma-video.service.d.ts +8 -0
- package/dist/platforma/platforma-video.service.d.ts.map +1 -1
- package/dist/platforma/platforma-video.service.js +45 -2
- package/dist/platforma/platforma-video.service.js.map +1 -1
- package/dist/platforma/platforma.controller.d.ts +213 -1
- package/dist/platforma/platforma.controller.d.ts.map +1 -1
- package/dist/platforma/platforma.controller.js +159 -2
- package/dist/platforma/platforma.controller.js.map +1 -1
- package/dist/realtime/lms-realtime.controller.d.ts +2 -0
- package/dist/realtime/lms-realtime.controller.d.ts.map +1 -1
- package/dist/realtime/lms-realtime.controller.js +31 -0
- package/dist/realtime/lms-realtime.controller.js.map +1 -1
- package/dist/realtime/lms-realtime.service.d.ts +1 -1
- package/dist/realtime/lms-realtime.service.d.ts.map +1 -1
- package/dist/realtime/lms-realtime.service.js.map +1 -1
- package/dist/training/dto/create-training.dto.d.ts +9 -0
- package/dist/training/dto/create-training.dto.d.ts.map +1 -1
- package/dist/training/dto/create-training.dto.js +45 -1
- package/dist/training/dto/create-training.dto.js.map +1 -1
- package/dist/training/training.controller.d.ts +144 -0
- package/dist/training/training.controller.d.ts.map +1 -1
- package/dist/training/training.service.d.ts +149 -0
- package/dist/training/training.service.d.ts.map +1 -1
- package/dist/training/training.service.js +332 -167
- package/dist/training/training.service.js.map +1 -1
- package/hedhog/data/image_type.yaml +10 -0
- package/hedhog/data/route.yaml +251 -0
- package/hedhog/data/setting_group.yaml +97 -0
- package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +139 -27
- package/hedhog/frontend/app/certificates/models/page.tsx.ejs +182 -29
- package/hedhog/frontend/app/classes/_components/classes-calendar-view.tsx.ejs +277 -0
- package/hedhog/frontend/app/classes/page.tsx.ejs +127 -20
- package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +69 -57
- package/hedhog/frontend/app/courses/[id]/_components/CourseIssuedCertificatesCard.tsx.ejs +168 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-ai-costs-tab.tsx.ejs +191 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +81 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +12 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +141 -30
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +13 -13
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +69 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +11 -23
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +267 -19
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +114 -86
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +239 -31
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +344 -59
- package/hedhog/frontend/app/courses/[id]/structure/_components/lesson-video-preview.tsx.ejs +200 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +1 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +3 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +1 -8
- package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -7
- package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-ai-costs.ts.ejs +40 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +2 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +25 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +148 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +157 -8
- package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +1 -22
- package/hedhog/frontend/app/courses/page.tsx.ejs +66 -13
- package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +6 -0
- package/hedhog/frontend/app/enterprise/_components/enterprise-classes-calendar-tab.tsx.ejs +264 -0
- package/hedhog/frontend/app/enterprise/page.tsx.ejs +104 -47
- package/hedhog/frontend/app/exams/page.tsx.ejs +38 -4
- package/hedhog/frontend/app/instructors/page.tsx.ejs +87 -46
- package/hedhog/frontend/app/paths/page.tsx.ejs +650 -168
- package/hedhog/frontend/app/training/page.tsx.ejs +38 -4
- package/hedhog/frontend/messages/en.json +41 -12
- package/hedhog/frontend/messages/pt.json +44 -13
- package/hedhog/query/triggers.sql +33 -0
- package/hedhog/table/course_ai_usage.yaml +46 -0
- package/hedhog/table/course_enrollment.yaml +3 -0
- package/hedhog/table/course_lesson.yaml +3 -0
- package/hedhog/table/course_lesson_answer.yaml +37 -0
- package/hedhog/table/course_lesson_transcription_segment.yaml +8 -0
- package/hedhog/table/learning_path.yaml +6 -0
- package/hedhog/table/learning_path_module.yaml +22 -0
- package/hedhog/table/learning_path_step.yaml +9 -6
- package/hedhog/table/lesson_view_event.yaml +66 -0
- package/package.json +8 -7
- package/src/certificate/certificate.controller.ts +2 -0
- package/src/certificate/certificate.service.ts +99 -0
- package/src/course/course-ai-usage.service.ts +221 -0
- package/src/course/course-audio-transcription.service.ts +471 -43
- package/src/course/course-export-scorm12.service.ts +149 -5
- package/src/course/course-export.service.ts +1 -0
- package/src/course/course-lesson.controller.ts +59 -6
- package/src/course/course-structure.controller.ts +19 -1
- package/src/course/course-structure.service.ts +184 -10
- package/src/course/course-transcription-translation.service.ts +293 -0
- package/src/course/course-video-agent-pipeline.service.ts +471 -0
- package/src/course/course-video-hls.service.ts +30 -10
- package/src/course/course.module.ts +9 -0
- package/src/course/course.service.ts +46 -1
- package/src/course/dto/create-course-bulk-job.dto.ts +7 -3
- package/src/course/dto/create-course-export.dto.ts +6 -0
- package/src/course/ffmpeg.util.ts +65 -0
- package/src/course/lms-bulk-upload-automation.service.ts +33 -8
- package/src/course/lms-bulk-upload.service.ts +20 -1
- package/src/course/subtitle.util.ts +220 -0
- package/src/enterprise/training/training-student.service.ts +224 -4
- package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +14 -0
- package/src/lesson-xp-map/lesson-xp-map.module.ts +2 -1
- package/src/lms.module.ts +14 -0
- package/src/platforma/dto/heartbeat.dto.ts +30 -0
- package/src/platforma/handlers/emit-certificate.handler.ts +117 -0
- package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -0
- package/src/platforma/platforma-heartbeat.service.ts +33 -0
- package/src/platforma/platforma-performance.service.ts +606 -0
- package/src/platforma/platforma-search.service.ts +48 -0
- package/src/platforma/platforma-video.service.ts +59 -3
- package/src/platforma/platforma.controller.ts +130 -0
- package/src/realtime/lms-realtime.controller.ts +27 -1
- package/src/realtime/lms-realtime.service.ts +2 -1
- package/src/training/dto/create-training.dto.ts +36 -0
- 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;
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|