Files
yaoyaoai/scripts/nodejs/subtitle_levenshtein.js
949036910@qq.com 474f6823fe 111
2026-06-01 17:27:16 +08:00

266 lines
7.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
};