68 lines
3.0 KiB
JavaScript
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 };
|