This commit is contained in:
949036910@qq.com
2026-06-01 17:27:16 +08:00
parent 8970f51fc0
commit 474f6823fe
3 changed files with 790 additions and 26 deletions

View 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,
};