import {
decodeHtmlEntitiesOnce,
htmlFragmentToText,
} from "../shared/html-text";
import {
safeInlineText,
safeMarkdownText,
scalarText,
} from "../shared/record-utils";
export type TranscriptFormat = "auto" | "json" | "srt" | "text" | "vtt";
export interface TranscriptAdapterOptions {
/** Generic JSON and text sources require explicit selection at registration. */
format?: TranscriptFormat;
}
export interface TranscriptSegment {
externalId?: string;
text: string;
speaker?: string;
start?: string;
end?: string;
sourceLocator: string;
anchorKind: "cue" | "line" | "record";
anchorValue: string;
endAnchorValue?: string;
sessionId?: string;
sessionTitle?: string;
participants?: string[];
dateFields?: Record;
}
export type TranscriptParseEvent =
| { ok: true; segment: TranscriptSegment }
| {
ok: false;
sourceLocator?: string;
retryable: boolean;
tooLarge?: boolean;
};
const TIMESTAMP_PATTERN = /^(?:(\d{1,}):)?([0-5]?\d):([0-5]\d)[,.](\d{3})$/;
export const parseTranscriptTimestamp = (
value: unknown
): { text: string; milliseconds: number } | undefined => {
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
const milliseconds = Math.round(value * 1_000);
if (Number.isSafeInteger(milliseconds)) {
return { text: formatTranscriptTimestamp(milliseconds), milliseconds };
}
return undefined;
}
const scalar = scalarText(value);
if (!scalar) return undefined;
if (/^\d+(?:\.\d+)?$/.test(scalar)) {
const milliseconds = Math.round(Number(scalar) * 1_000);
if (Number.isSafeInteger(milliseconds)) {
return { text: formatTranscriptTimestamp(milliseconds), milliseconds };
}
}
const match = TIMESTAMP_PATTERN.exec(scalar);
if (!match) return undefined;
const hours = Number(match[1] ?? "0");
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const millisecondsPart = Number(match[4]);
const milliseconds =
(hours * 60 * 60 + minutes * 60 + seconds) * 1_000 + millisecondsPart;
if (!Number.isSafeInteger(milliseconds)) return undefined;
return { text: formatTranscriptTimestamp(milliseconds), milliseconds };
};
export const formatTranscriptTimestamp = (milliseconds: number): string => {
const totalSeconds = Math.floor(milliseconds / 1_000);
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const seconds = totalSeconds % 60;
const millis = milliseconds % 1_000;
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`;
};
const VALID_TRANSCRIPT_FORMATS = new Set([
"auto",
"json",
"srt",
"text",
"vtt",
]);
export const parseTranscriptAdapterOptions = (
options: TranscriptAdapterOptions
): Required => {
const format = options.format ?? "auto";
if (!VALID_TRANSCRIPT_FORMATS.has(format)) {
throw new Error("Unsupported transcript adapter format.");
}
return { format };
};
const vttVoiceSpeaker = (value: string): string | undefined => {
const source = value.trimStart();
if (
source.length < 3 ||
source[0] !== "<" ||
source[1]?.toLowerCase() !== "v"
)
return undefined;
const end = source.indexOf(">", 2);
if (end < 0) return undefined;
let cursor = 2;
if (source[cursor] === ".") {
while (
cursor < end &&
source[cursor] !== " " &&
source[cursor] !== "\t" &&
source[cursor] !== "\n" &&
source[cursor] !== "\r"
)
cursor += 1;
}
if (
source[cursor] !== " " &&
source[cursor] !== "\t" &&
source[cursor] !== "\n" &&
source[cursor] !== "\r"
)
return undefined;
while (
cursor < end &&
(source[cursor] === " " ||
source[cursor] === "\t" ||
source[cursor] === "\n" ||
source[cursor] === "\r")
)
cursor += 1;
const speaker = source.slice(cursor, end).trim();
if (!speaker || speaker.length > 80) return undefined;
return safeInlineText(decodeHtmlEntitiesOnce(speaker));
};
const speakerPrefix = (
value: string
): { speaker: string; text: string } | undefined => {
const separator = value.indexOf(":");
if (separator <= 0 || separator > 80) return undefined;
const speaker = value.slice(0, separator);
if (speaker.includes("\n")) return undefined;
const remainder = value.slice(separator + 1);
if (
remainder.length === 0 ||
(remainder[0] !== " " &&
remainder[0] !== "\t" &&
remainder[0] !== "\n" &&
remainder[0] !== "\r")
)
return undefined;
const text = remainder.trim();
if (!text) return undefined;
return { speaker: safeInlineText(speaker), text };
};
export const cleanTranscriptText = (
value: string
): { text: string; speaker?: string } => {
const speaker = vttVoiceSpeaker(value);
const cleaned = safeMarkdownText(htmlFragmentToText(value)).trim();
if (speaker) return { text: cleaned, speaker };
return speakerPrefix(cleaned) ?? { text: cleaned };
};