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