feat(server): normalize timestamp

This commit is contained in:
DarkSky
2026-08-17 12:01:30 +08:00
parent 81fb82761c
commit b4c8548c09

View File

@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { AiJobStatus } from '@prisma/client'; import { AiJobStatus } from '@prisma/client';
import { import {
@@ -58,9 +58,13 @@ import { readStream } from './utils';
const TRANSCRIPT_SLICE_CONCURRENCY = 2; const TRANSCRIPT_SLICE_CONCURRENCY = 2;
const TRANSCRIPT_RETRY_DELAYS = [5_000, 15_000]; const TRANSCRIPT_RETRY_DELAYS = [5_000, 15_000];
const MAX_RECOVERABLE_TIMESTAMP_RATIO = 2;
const MIN_MILLISECOND_TIMESTAMP_RATIO = 100;
@Injectable() @Injectable()
export class CopilotTranscriptionService { export class CopilotTranscriptionService {
private readonly logger = new Logger(CopilotTranscriptionService.name);
constructor( constructor(
private readonly models: Models, private readonly models: Models,
private readonly storage: CopilotStorage, private readonly storage: CopilotStorage,
@@ -236,41 +240,70 @@ export class CopilotTranscriptionService {
const sliceIndex = info.index ?? fallbackIndex; const sliceIndex = info.index ?? fallbackIndex;
const response = TranscriptionResponseSchema.parse(output.value); const response = TranscriptionResponseSchema.parse(output.value);
const timestamps = response.flatMap(segment => [segment.s, segment.e]); const timestamps = response.flatMap(segment => [segment.s, segment.e]);
const maxTimestamp = Math.max(0, ...timestamps); const maxTs = Math.max(0, ...timestamps);
const mmssTimestamps = timestamps.map(timestamp => { const maxAllowed = durationSec === undefined ? Infinity : durationSec + 5;
const minutes = Math.floor(timestamp / 100); let scale = 1;
const seconds = timestamp - minutes * 100; let convertMmss = false;
return seconds < 60 ? minutes * 60 + seconds : null; if (durationSec !== undefined && maxTs > maxAllowed) {
}); const mmssTimestamps = timestamps.map(timestamp => {
const usesMmss = const minutes = Math.floor(timestamp / 100);
durationSec !== undefined && const seconds = timestamp - minutes * 100;
maxTimestamp > durationSec + 5 && return seconds < 60 ? minutes * 60 + seconds : null;
mmssTimestamps.every( });
timestamp => timestamp !== null && timestamp <= durationSec + 5 if (mmssTimestamps.every(ts => ts !== null && ts <= maxAllowed)) {
); convertMmss = true;
if ( } else if (
durationSec !== undefined && durationSec > 0 &&
maxTimestamp > durationSec + 5 && maxTs >= durationSec * MIN_MILLISECOND_TIMESTAMP_RATIO &&
!usesMmss maxTs / 1000 <= maxAllowed
) { ) {
throw new Error('Transcript slice timestamps exceed audio duration'); scale = 0.001;
} else if (maxTs <= durationSec * MAX_RECOVERABLE_TIMESTAMP_RATIO) {
scale = durationSec / maxTs;
} else {
scale = 1;
}
} }
return response.map((segment, index) => { let correctedTimestamps = 0;
const startSec = usesMmss const normalizeTimestamp = (timestamp: number, index: number) => {
? (mmssTimestamps[index * 2] ?? segment.s) const minutes = Math.floor(timestamp / 100);
: segment.s; const seconds = timestamp - minutes * 100;
const endSec = usesMmss const converted = convertMmss
? (mmssTimestamps[index * 2 + 1] ?? segment.e) ? minutes * 60 + seconds
: segment.e; : timestamp * scale;
const bounded =
durationSec === undefined
? Math.max(0, converted)
: Math.min(Math.max(converted, 0), durationSec);
if (bounded !== timestamp) correctedTimestamps += 1;
if (!Number.isFinite(bounded)) {
this.logger.warn(
`Invalid timestamp at position ${index} in transcript slice ${sliceIndex}`
);
return 0;
}
return bounded;
};
const segments = response.map((segment, index) => {
const startSec = normalizeTimestamp(segment.s, index * 2);
const endSec = normalizeTimestamp(segment.e, index * 2 + 1);
return { return {
sliceIndex, sliceIndex,
speaker: segment.a, speaker: segment.a,
startSec: Math.min(startSec, durationSec ?? startSec) + offset, startSec: startSec + offset,
endSec: Math.min(endSec, durationSec ?? endSec) + offset, endSec: endSec + offset,
text: segment.t, text: segment.t,
}; };
}); });
if (correctedTimestamps > 0) {
this.logger.warn(
`Normalized ${correctedTimestamps} out-of-range transcript timestamps for slice ${sliceIndex} (duration=${durationSec ?? 'unknown'}s, scale=${scale}, mmss=${convertMmss})`
);
}
return segments;
} }
private async generateStructuredValue( private async generateStructuredValue(