"use strict"; /** * 文案与 ASR 时间轴对齐(对齐 Electron ZimuShengcheng / Levenshtein) */ const PUNCTUATION_RE = /[,。!?、;:\u201C\u201D"「」『』()【】《》,.!?;:'"]/; const DEFAULT_CHAR_DURATION_MS = 150; const TIME_GAP_SPLIT_THRESHOLD_MS = 500; function levenshteinDistance(a, b) { const n = a.length; const m = b.length; const dp = Array.from({ length: n + 1 }, () => Array(m + 1).fill(0)); for (let i = 0; i <= n; i++) dp[i][0] = i; for (let j = 0; j <= m; j++) dp[0][j] = j; for (let i = 1; i <= n; i++) { for (let j = 1; j <= m; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost); } } return dp[n][m]; } function findBestMatch(userText, asrText) { const n = userText.length; const m = asrText.length; const dp = Array.from({ length: n + 1 }, () => Array(m + 1).fill(0)); const bt = Array.from({ length: n + 1 }, () => Array(m + 1).fill("")); for (let i = 0; i <= n; i++) { dp[i][0] = i; bt[i][0] = "delete"; } for (let j = 0; j <= m; j++) { dp[0][j] = j; bt[0][j] = "insert"; } for (let i = 1; i <= n; i++) { for (let j = 1; j <= m; j++) { const cost = userText[i - 1] === asrText[j - 1] ? 0 : 1; const del = dp[i - 1][j] + 1; const ins = dp[i][j - 1] + 1; const sub = dp[i - 1][j - 1] + cost; const best = Math.min(del, ins, sub); dp[i][j] = best; if (best === del) bt[i][j] = "delete"; else if (best === ins) bt[i][j] = "insert"; else if (cost === 0) bt[i][j] = "match"; else bt[i][j] = "replace"; } } const alignments = []; let i = n; let j = m; while (i > 0 || j > 0) { const op = bt[i][j]; if (op === "match") { alignments.unshift({ userChar: userText[i - 1], asrChar: asrText[j - 1], operation: "match", asrIndex: j - 1, }); i -= 1; j -= 1; } else if (op === "replace") { alignments.unshift({ userChar: userText[i - 1], asrChar: asrText[j - 1], operation: "replace", asrIndex: j - 1, }); i -= 1; j -= 1; } else if (op === "delete") { alignments.unshift({ userChar: userText[i - 1], asrChar: "", operation: "delete", asrIndex: j, }); i -= 1; } else { alignments.unshift({ userChar: "", asrChar: asrText[j - 1], operation: "insert", asrIndex: j - 1, }); j -= 1; } } const distance = dp[n][m]; const similarity = 1 - distance / Math.max(n, m, 1); return { alignments, distance, similarity }; } function buildSegmentCharTimings(segment) { const text = segment.text || ""; const len = text.length; if (len === 0) return []; const step = (segment.end - segment.start) / Math.max(len, 1); const fallback = Array.from({ length: len }, (_, idx) => ({ start: segment.start + idx * step, end: segment.start + (idx + 1) * step, })); const words = Array.isArray(segment.words) ? segment.words : []; if (words.length === 0) return fallback; const mapped = Array(len).fill(null); let searchFrom = 0; for (const w of words) { const word = String(w?.word ?? w?.text ?? ""); const start = Number(w?.start ?? w?.beginTime ?? w?.begin_time); const end = Number(w?.end ?? w?.endTime ?? w?.end_time); if (!word || !Number.isFinite(start) || !Number.isFinite(end) || end <= start) continue; const pos = text.indexOf(word, searchFrom); if (pos === -1) continue; const wordStep = (end - start) / Math.max(word.length, 1); for (let k = 0; k < word.length && pos + k < len; k++) { mapped[pos + k] = { start: start + k * wordStep, end: start + (k + 1) * wordStep }; } searchFrom = pos + word.length; } return mapped.map((item, idx) => item || fallback[idx]); } function buildCharTimeMap(segments) { const map = new Map(); let index = 0; for (const segment of segments) { for (const timing of buildSegmentCharTimings(segment)) { map.set(index, timing); index += 1; } } return map; } function mapToTimestamps(alignments, charMap) { const segments = []; let text = ""; let start = 0; let end = 0; let corrected = false; let isFirstChar = true; for (let y = 0; y < alignments.length; y++) { const row = alignments[y]; const timing = row.asrIndex != null && row.asrIndex >= 0 ? charMap.get(row.asrIndex) : null; const isPunct = row.userChar && PUNCTUATION_RE.test(row.userChar); if (row.operation === "match" || row.operation === "replace") { if (!isPunct) text += row.userChar; if (row.operation === "replace") corrected = true; if (timing) { if (isFirstChar) { start = timing.start; isFirstChar = false; } end = timing.end; } } else if (row.operation === "insert") { if (timing) { if (isFirstChar) { start = timing.start; isFirstChar = false; } end = timing.end; } } else if (row.operation === "delete") { if (!isPunct) { text += row.userChar; const t = isFirstChar ? start : end; if (isFirstChar) { start = t; isFirstChar = false; } if (!timing) end = t + DEFAULT_CHAR_DURATION_MS; } corrected = true; } const isLast = y === alignments.length - 1; const nextRow = alignments[y + 1]; const nextTiming = nextRow && nextRow.asrIndex != null && nextRow.asrIndex >= 0 ? charMap.get(nextRow.asrIndex) : null; const gapSplit = !isLast && timing && nextTiming && nextTiming.start - end > TIME_GAP_SPLIT_THRESHOLD_MS; if ((isLast || isPunct || gapSplit) && text.length > 0) { if (end <= start) end = start + text.length * DEFAULT_CHAR_DURATION_MS; segments.push({ text, start, end, corrected, }); start = end; text = ""; corrected = false; isFirstChar = true; } } if (text.length > 0) { if (end <= start) end = start + text.length * DEFAULT_CHAR_DURATION_MS; segments.push({ text, start, end, corrected }); } return segments; } /** * @param {string} userText * @param {Array<{ text: string, start: number, end: number, words?: unknown[] }>} asrSegments */ function correctTextWithLevenshtein(userText, asrSegments) { const user = String(userText || "").replace(/\s+/g, ""); const list = Array.isArray(asrSegments) ? asrSegments : []; if (!user || list.length === 0) { return { segments: [], similarity: 0 }; } const asrFullText = list.map((s) => s.text || "").join(""); const match = findBestMatch(user, asrFullText); const charMap = buildCharTimeMap(list); const corrected = mapToTimestamps(match.alignments, charMap); const MIN_GAP_MS = 50; const out = corrected .map((seg) => ({ text: String(seg.text || "").trim(), start: Math.round(seg.start), end: Math.round(seg.end), })) .filter((seg) => seg.text); for (let i = 0; i < out.length - 1; i++) { const cur = out[i]; const next = out[i + 1]; if (cur.end >= next.start) { cur.end = Math.max(cur.start + MIN_GAP_MS, next.start - MIN_GAP_MS); } if (cur.end <= cur.start) cur.end = cur.start + MIN_GAP_MS; } return { segments: out, similarity: match.similarity }; } function similarity(a, b) { const max = Math.max(a.length, b.length); if (max === 0) return 1; return 1 - levenshteinDistance(a, b) / max; } module.exports = { correctTextWithLevenshtein, similarity, };