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