111
This commit is contained in:
265
scripts/nodejs/subtitle_levenshtein.js
Normal file
265
scripts/nodejs/subtitle_levenshtein.js
Normal file
@@ -0,0 +1,265 @@
|
||||
"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,
|
||||
};
|
||||
Reference in New Issue
Block a user