266 lines
7.5 KiB
JavaScript
266 lines
7.5 KiB
JavaScript
"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,
|
||
};
|