Lumi/plugins/lumi_transcription/backend/transcription/stabilizer.js
2026-07-22 11:01:49 +02:00

68 lines
3.0 KiB
JavaScript

const crypto = require("crypto");
class CaptionStabilizer {
constructor(options = {}) {
this.maxChars = options.maxChars || 80;
this.fragmentAfterMs = options.fragmentAfterMs || 1000;
this.now = options.now || Date.now;
this.tracks = new Map();
}
update(sourceUuid, hypothesis, details = {}) {
const now = this.now();
const words = tokenize(hypothesis);
let state = this.tracks.get(sourceUuid);
if (!state || details.newUtterance) {
state = { captionId: crypto.randomUUID(), revision: 0, stable: [], previous: [], startedAt: now, tailSince: now };
this.tracks.set(sourceUuid, state);
}
const common = commonPrefix(state.previous, words);
const agreement = commonPrefix(state.stable, words);
if (agreement.length < state.stable.length) {
words.splice(0, agreement.length, ...state.stable.slice(0, agreement.length));
}
const stableLimit = details.trailingIncomplete && !details.final
? Math.max(state.stable.length, common.length - 1)
: Math.max(state.stable.length, common.length);
const agreedCandidate = words.slice(0, stableLimit);
if (agreedCandidate.length > state.stable.length) state.stable = agreedCandidate;
const tail = words.slice(state.stable.length);
if (tail.join(" ") !== state.previous.slice(state.stable.length).join(" ")) state.tailSince = now;
const incompleteWord = Boolean(details.trailingIncomplete && tail.length && now - state.tailSince >= this.fragmentAfterMs && !details.final);
if (details.final) state.stable = words;
state.previous = words;
state.revision += 1;
const stableText = clip(state.stable.join(" "), this.maxChars);
const uncertainText = details.final ? "" : clip(tail.join(" "), Math.max(0, this.maxChars - stableText.length - 1));
const event = {
caption_id: state.captionId,
revision: state.revision,
stable_text: stableText,
uncertain_text: uncertainText,
final: Boolean(details.final),
incomplete_word: incompleteWord,
stabilization_ms: now - state.startedAt
};
if (details.final) this.tracks.delete(sourceUuid);
return event;
}
}
class LatestCaptionGate {
constructor() { this.revisions = new Map(); }
accept(event) {
const key = `${event.session_id}:${event.caption_id}`;
const current = this.revisions.get(key) || 0;
if (event.revision <= current) return false;
this.revisions.set(key, event.revision);
return true;
}
clearSession(sessionId) { for (const key of this.revisions.keys()) if (key.startsWith(`${sessionId}:`)) this.revisions.delete(key); }
}
function tokenize(value) { return String(value || "").trim().split(/\s+/).filter(Boolean); }
function commonPrefix(left, right) { let index = 0; while (index < left.length && index < right.length && left[index] === right[index]) index += 1; return right.slice(0, index); }
function clip(value, max) { if (value.length <= max) return value; return value.slice(0, max).trimEnd(); }
module.exports = { CaptionStabilizer, LatestCaptionGate, tokenize, commonPrefix };