SmartMeeting/speakr/static/js/realtime-asr-session.js

934 lines
26 KiB
JavaScript

(function (global) {
"use strict";
const namespace = (global.Speakr = global.Speakr || {});
function appendInt16Samples(current, incoming) {
const merged = new Int16Array(current.length + incoming.length);
merged.set(current);
merged.set(incoming, current.length);
return merged;
}
function createPcmStreamer({
label,
recorder,
isActive,
sendChunk,
chunkSize,
warnSamples,
hardLimitSamples,
outputSampleRate,
}) {
let sampleBuffer = new Int16Array();
function reset() {
sampleBuffer = new Int16Array();
}
function onProcess(buffer, powerLevel, bufferDuration, bufferSampleRate) {
if (!isActive()) {
return;
}
const latestFrame = buffer && buffer[buffer.length - 1];
if (!latestFrame) {
return;
}
const resampled = recorder.SampleData(
[latestFrame],
bufferSampleRate,
outputSampleRate
).data;
sampleBuffer = appendInt16Samples(sampleBuffer, resampled);
if (sampleBuffer.length > hardLimitSamples) {
const overflowSamples = sampleBuffer.length - hardLimitSamples;
console.warn(
`[${label}] Dropping stale local audio buffer: ${overflowSamples} samples`
);
sampleBuffer = sampleBuffer.slice(-hardLimitSamples);
}
while (sampleBuffer.length >= chunkSize) {
const chunk = sampleBuffer.slice(0, chunkSize);
const accepted = sendChunk(chunk);
if (!accepted) {
break;
}
sampleBuffer = sampleBuffer.slice(chunkSize);
}
if (sampleBuffer.length > warnSamples) {
console.warn(
`[${label}] Audio buffer backlog: ${sampleBuffer.length} samples (${(
sampleBuffer.length / outputSampleRate
).toFixed(2)}s)`
);
}
}
return {
onProcess,
reset,
};
}
namespace.createRealtimeAsrSession = function createRealtimeAsrSession({
Recorder,
WebSocketConnectMethod,
getMicStream,
getSystemStream,
getPausedTranscription,
setMainText,
setPreviewText,
setOfflineText,
getLastTimer,
setLastTimer,
getConfigWords,
shouldShowSpeaker,
getSegments,
setSegments,
getSegmentRenderStartIndex,
getRealtimeEpoch,
getTimestampBarrier,
setTimestampBarrier,
getEditCarryover,
setEditCarryover,
setPendingCorrectionCount,
onActiveChange,
}) {
const ASR_CHUNK_SIZE = 960;
const ASR_BUFFER_WARN_SAMPLES = 20000;
const ASR_BUFFER_HARD_LIMIT_SAMPLES = 16000 * 5;
const ASR_SAMPLE_RATE = 16000;
let webReader = null;
let webReader2 = null;
let rec = null;
let rec2 = null;
let asrAudioContext = null;
let asrAudioContext2 = null;
let isActive = false;
let resetTranscriptionFlag = false;
let recText = "";
let offlineText = "";
let recTextOnline = "";
let offlineSentenceHandoffText = "";
let recTextOffline = "";
let pendingSentenceQueue = [];
let pendingSentenceSequence = 0;
let lastOnlineTimestamp = null;
let pendingCorrectionCount = 0;
let onlineStreamer = null;
let offlineStreamer = null;
function setActive(nextValue) {
isActive = !!nextValue;
if (typeof onActiveChange === "function") {
onActiveChange(isActive);
}
}
function updatePendingCorrectionCount(delta) {
pendingCorrectionCount = Math.max(0, pendingCorrectionCount + delta);
if (typeof setPendingCorrectionCount === "function") {
setPendingCorrectionCount(pendingCorrectionCount);
}
}
function extractTimestampMs(timestampPayload) {
if (!timestampPayload) {
return null;
}
try {
const timestampData =
typeof timestampPayload === "string"
? JSON.parse(timestampPayload)
: timestampPayload;
if (!Array.isArray(timestampData) || timestampData.length === 0) {
return null;
}
const lastItem = timestampData[timestampData.length - 1];
if (!Array.isArray(lastItem) || lastItem.length === 0) {
return null;
}
const endOrStart =
lastItem.length > 1 ? Number(lastItem[1]) : Number(lastItem[0]);
return Number.isFinite(endOrStart) ? endOrStart : null;
} catch (error) {
return null;
}
}
function getRealtimeCarryoverTail(text, maxLength = 160) {
return (text || "").slice(-maxLength);
}
function buildRealtimeEditCarryover(baseText) {
const tailText = getRealtimeCarryoverTail(baseText);
if (tailText.length < 4) {
return null;
}
return {
tailText,
};
}
function trimOfflineTextWithCarryover(rawText) {
const incomingText = rawText || "";
const realtimeEditCarryover = getEditCarryover
? getEditCarryover()
: null;
if (!realtimeEditCarryover || !incomingText) {
return incomingText;
}
const tailText = realtimeEditCarryover.tailText || "";
const minOverlapLength = 4;
const maxOverlapLength = Math.min(80, tailText.length, incomingText.length);
for (let size = maxOverlapLength; size >= minOverlapLength; size--) {
const overlapText = tailText.slice(-size);
const matchIndex = incomingText.lastIndexOf(overlapText);
if (matchIndex === -1) {
continue;
}
const remainingText = incomingText.slice(matchIndex + overlapText.length);
setEditCarryover({
tailText: getRealtimeCarryoverTail(tailText + remainingText),
});
return remainingText;
}
setEditCarryover(null);
return incomingText;
}
function isStaleRealtimeMessage(
messageTimestampMs,
messageEpoch = getRealtimeEpoch()
) {
if (messageEpoch !== getRealtimeEpoch()) {
return true;
}
const realtimeTimestampBarrierMs = getTimestampBarrier();
return (
Number.isFinite(messageTimestampMs) &&
realtimeTimestampBarrierMs >= 0 &&
messageTimestampMs <= realtimeTimestampBarrierMs
);
}
function getLatestRealtimeTimestamp() {
const latestSegmentTimestamp = getSegments().reduce(
(latest, segment) =>
Number.isFinite(segment && segment.timestampMs)
? Math.max(latest, segment.timestampMs)
: latest,
-1
);
const latestPendingTimestamp = pendingSentenceQueue.reduce(
(latest, item) =>
Number.isFinite(item.timestampMs)
? Math.max(latest, item.timestampMs)
: latest,
-1
);
const latestOnlineTimestamp = Number.isFinite(lastOnlineTimestamp)
? lastOnlineTimestamp
: -1;
return Math.max(
latestSegmentTimestamp,
latestPendingTimestamp,
latestOnlineTimestamp
);
}
function getPendingSentencePreviewText() {
return pendingSentenceQueue
.filter((item) => item.epoch === getRealtimeEpoch())
.map((item) => item.correctedText || item.rawText)
.join("");
}
function syncRealtimePreview() {
setPreviewText(
getPendingSentencePreviewText() +
offlineSentenceHandoffText +
recTextOnline
);
}
function clearRealtimePipeline({ preserveTimestampBarrier = false } = {}) {
if (onlineStreamer) {
onlineStreamer.reset();
}
if (offlineStreamer) {
offlineStreamer.reset();
}
recText = "";
offlineText = "";
recTextOnline = "";
offlineSentenceHandoffText = "";
recTextOffline = "";
pendingSentenceQueue = [];
pendingSentenceSequence = 0;
lastOnlineTimestamp = null;
setPreviewText("");
setOfflineText("");
if (!preserveTimestampBarrier) {
setTimestampBarrier(-1);
}
syncRealtimePreview();
}
function findPendingSentenceBySource(
sourceText,
pendingTimestamp,
messageEpoch = getRealtimeEpoch()
) {
const normalizedSourceText = sourceText || "";
const normalizedTimestamp = pendingTimestamp || "";
return (
pendingSentenceQueue.find(
(item) =>
item.epoch === messageEpoch &&
item.sourceText === normalizedSourceText &&
item.timestamp === normalizedTimestamp
) ||
getSegments().find(
(item) =>
item.epoch === messageEpoch &&
item.sourceText === normalizedSourceText &&
item.timestamp === normalizedTimestamp
) ||
null
);
}
function ensurePendingSentence(
sourceText,
pendingTimestamp,
messageEpoch = getRealtimeEpoch()
) {
const timestampMs = extractTimestampMs(pendingTimestamp);
if (isStaleRealtimeMessage(timestampMs, messageEpoch)) {
return null;
}
const existingItem = findPendingSentenceBySource(
sourceText,
pendingTimestamp,
messageEpoch
);
if (existingItem) {
return existingItem;
}
const trimmedText = trimOfflineTextWithCarryover(sourceText || "");
if (!trimmedText) {
syncRealtimePreview();
return null;
}
const pendingItem = {
id: pendingSentenceSequence++,
sourceText: sourceText || "",
rawText: trimmedText,
timestamp: pendingTimestamp || "",
timestampMs,
correctedText: "",
processed: false,
correctionRequested: false,
epoch: messageEpoch,
};
pendingSentenceQueue.push(pendingItem);
syncRealtimePreview();
return pendingItem;
}
function addSegment(segment) {
const segments = getSegments();
segments.push(segment);
setSegments(segments);
}
function flushProcessedPendingSentences() {
let didFlush = false;
while (pendingSentenceQueue.length > 0) {
if (pendingSentenceQueue[0].epoch !== getRealtimeEpoch()) {
pendingSentenceQueue.shift();
continue;
}
if (!pendingSentenceQueue[0].processed) {
break;
}
const item = pendingSentenceQueue.shift();
addSegment({
text: item.correctedText || item.rawText,
sourceText: item.sourceText,
timestamp: item.timestamp,
timestampMs: item.timestampMs,
speaker: null,
processed: true,
epoch: item.epoch,
});
didFlush = true;
}
if (didFlush) {
renderFullText();
} else {
syncRealtimePreview();
}
}
function finalizePendingSentence(
pendingSentenceId,
finalText,
messageEpoch = getRealtimeEpoch()
) {
const target = pendingSentenceQueue.find(
(item) => item.id === pendingSentenceId && item.epoch === messageEpoch
);
if (!target) {
return;
}
target.correctedText = finalText || target.rawText || "";
target.processed = true;
target.correctionRequested = false;
flushProcessedPendingSentences();
}
function addNewlineAfterPeriod(str) {
return str.replace(/[。?!]/g, "$&\n");
}
function handleWithTimestamp(tmptext, tmptime, keywordStr, timeThreshold) {
if (!tmptime || tmptext.length <= 0) return tmptext;
const keywordList = keywordStr
.split(",")
.map((k) => k.trim())
.filter((k) => k.length > 0);
const keywordsAsRegex = keywordList.map((k) => new RegExp(k));
const segments = tmptext.match(/[^。!?]+[。!?]?/g) || [];
const jsontime = JSON.parse(tmptime);
let charIndex = 0;
let textWithTime = "";
function removeLeadingPunctuation(text) {
return text.replace(/^[,。!?、;:,\.!?;:]+/, "");
}
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (!segment || segment === "undefined") continue;
const nowTime = jsontime[charIndex][0] / 1000;
if (
getLastTimer() > 0 &&
nowTime - getLastTimer() > timeThreshold
) {
const lastChar = textWithTime.slice(-1);
const endsWithPunc = /[。!?]/.test(lastChar);
const processedSegment = removeLeadingPunctuation(segment);
textWithTime +=
(endsWithPunc ? "\n" : "。\n") + processedSegment;
} else {
const hitKeyword = keywordsAsRegex.some((reg) => reg.test(segment));
if (hitKeyword) {
removeLeadingPunctuation(segment);
} else {
textWithTime += segment;
}
}
setLastTimer(nowTime);
charIndex++;
}
return textWithTime;
}
function getSpeakerBeforeIndex(endIndex) {
let previousSpeaker = null;
for (let i = 0; i < endIndex; i++) {
const segment = getSegments()[i];
if (segment && segment.speaker) {
previousSpeaker = segment.speaker;
}
}
return previousSpeaker;
}
function buildSegmentText(segments, initialSpeaker = null) {
let displayText = "";
let lastRenderedSpeaker = initialSpeaker;
segments.forEach((segment) => {
if (shouldShowSpeaker()) {
if (
segment.speaker &&
segment.speaker !== lastRenderedSpeaker &&
!segment.speaker.includes("SPK")
) {
displayText +=
(displayText ? "\n" : "") + segment.speaker + ":\n";
lastRenderedSpeaker = segment.speaker;
} else if (
segment.speaker &&
segment.speaker !== lastRenderedSpeaker
) {
displayText +=
(displayText ? "\n" : "") + segment.speaker + ":\n";
lastRenderedSpeaker = segment.speaker;
}
}
const textContent = handleWithTimestamp(
segment.text,
segment.timestamp,
getConfigWords(),
6
);
displayText += textContent;
});
return displayText;
}
function renderFullText() {
const segmentRenderStartIndex = getSegmentRenderStartIndex();
const initialSpeaker =
segmentRenderStartIndex > 0
? getSpeakerBeforeIndex(segmentRenderStartIndex)
: null;
const displayText = buildSegmentText(
getSegments().slice(segmentRenderStartIndex),
initialSpeaker
);
recText = displayText;
setMainText(getPausedTranscription() + recText);
syncRealtimePreview();
}
function resetIfRequested() {
if (resetTranscriptionFlag) {
console.log("重置实时转录状态(暂停/恢复后)");
clearRealtimePipeline();
resetTranscriptionFlag = false;
}
}
function handleOnlineMessage(jsonMsg) {
const parsedMessage = JSON.parse(jsonMsg.data);
const text = "" + parsedMessage["text"];
const asrModel = parsedMessage["mode"];
const timestamp = parsedMessage["timestamp"];
resetIfRequested();
const messageEpoch = getRealtimeEpoch();
if (asrModel === "2pass-online") {
const currentTimestamp = extractTimestampMs(timestamp);
if (isStaleRealtimeMessage(currentTimestamp, messageEpoch)) {
syncRealtimePreview();
return;
}
let shouldBreak = false;
if (lastOnlineTimestamp !== null && currentTimestamp !== null) {
const timeDiff = (currentTimestamp - lastOnlineTimestamp) / 1000;
if (timeDiff >= 3 && /。\s*$/.test(text)) {
shouldBreak = true;
}
}
recTextOnline += shouldBreak ? text + "\n" : text;
lastOnlineTimestamp = currentTimestamp;
} else if (asrModel === "2pass-offline") {
recTextOnline = "";
offlineSentenceHandoffText = text || recTextOnline || "";
recTextOnline = "";
lastOnlineTimestamp = null;
}
syncRealtimePreview();
}
async function handleOfflineMessage(jsonMsg) {
const parsedMessage = JSON.parse(jsonMsg.data);
const asrModelType = parsedMessage["mode"];
const timestamp = parsedMessage["timestamp"];
const messageEpoch = getRealtimeEpoch();
const messageTimestampMs = extractTimestampMs(timestamp);
if (asrModelType === "2pass-offline") {
if (isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
return;
}
const text = "" + parsedMessage["text"];
recTextOnline = "";
lastOnlineTimestamp = null;
const pendingSentence = ensurePendingSentence(
text,
timestamp,
messageEpoch
);
offlineSentenceHandoffText = "";
syncRealtimePreview();
if (
!pendingSentence ||
pendingSentence.processed ||
pendingSentence.correctionRequested
) {
return;
}
pendingSentence.correctionRequested = true;
updatePendingCorrectionCount(1);
try {
const response = await fetch(`/tool/speakr/api/asr/correct`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: pendingSentence.rawText }),
});
if (!response.ok) {
throw new Error("ASR correction failed");
}
const data = await response.json();
const correctedText = data.corrected_text || pendingSentence.rawText;
finalizePendingSentence(
pendingSentence.id,
correctedText,
messageEpoch
);
console.log("LLM矫正完成");
} catch (error) {
finalizePendingSentence(
pendingSentence.id,
pendingSentence.rawText,
messageEpoch
);
console.error("LLM矫正出错:", error);
} finally {
updatePendingCorrectionCount(-1);
}
} else if (asrModelType === "offline-speaker") {
if (isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
return;
}
const nowSpeaker =
(parsedMessage["segments"] &&
parsedMessage["segments"][0] &&
parsedMessage["segments"][0].speaker) ||
"未命名演讲者";
const segments = getSegments();
let targetIndex = -1;
for (let i = segments.length - 1; i >= 0; i--) {
const segment = segments[i];
if (!segment || segment.epoch !== messageEpoch) {
continue;
}
if (
segment.speaker === null &&
Number.isFinite(messageTimestampMs) &&
Number.isFinite(segment.timestampMs) &&
segment.timestampMs === messageTimestampMs
) {
targetIndex = i;
break;
}
if (targetIndex === -1 && segment.speaker === null) {
targetIndex = i;
}
}
if (targetIndex !== -1) {
segments[targetIndex].speaker = nowSpeaker;
setSegments(segments);
renderFullText();
}
}
}
async function getMicrophoneInfo() {
if (
!navigator.mediaDevices ||
!navigator.mediaDevices.enumerateDevices
) {
console.warn("当前浏览器不支持获取设备信息");
return null;
}
const devices = await navigator.mediaDevices.enumerateDevices();
const mics = devices.filter((device) => device.kind === "audioinput");
const defaultMic =
mics.find((device) => device.deviceId === "default") || mics[0];
return {
name: (defaultMic && defaultMic.label) || "未知麦克风",
deviceId: (defaultMic && defaultMic.deviceId) || "",
all: mics.map((device) => ({
name: device.label,
deviceId: device.deviceId,
})),
};
}
async function openMixedStream(channel) {
try {
if (channel === "online") {
const micInfo = await getMicrophoneInfo();
if (micInfo) {
console.log("🎤 当前麦克风:", micInfo.name, micInfo);
}
}
const micStream = getMicStream();
const systemStream = getSystemStream();
let contextRef = channel === "online" ? asrAudioContext : asrAudioContext2;
if (contextRef) {
try {
contextRef.close();
} catch (error) {}
}
contextRef = new (global.AudioContext || global.webkitAudioContext)();
if (channel === "online") {
asrAudioContext = contextRef;
} else {
asrAudioContext2 = contextRef;
}
const destination = contextRef.createMediaStreamDestination();
const micSource = contextRef.createMediaStreamSource(micStream);
micSource.connect(destination);
if (systemStream && systemStream.getAudioTracks().length > 0) {
const sysSource = contextRef.createMediaStreamSource(systemStream);
sysSource.connect(destination);
} else {
console.log("⚠️ 未检测到系统音频,使用麦克风录音");
}
const recorder = channel === "online" ? rec : rec2;
recorder.open(
() => {
recorder.start(destination.stream);
},
(msg) => {
console.error("❌ 录音权限被拒绝:", msg);
}
);
} catch (error) {
console.error("初始化录音失败:", error);
}
}
function stop() {
setActive(false);
if (webReader) {
webReader.wsStop();
}
if (webReader2) {
webReader2.wsStop();
}
}
async function finish(timeoutMs = 3500) {
setActive(false);
const finishPromises = [];
if (webReader && typeof webReader.wsFinish === "function") {
finishPromises.push(webReader.wsFinish(timeoutMs));
} else if (webReader) {
webReader.wsStop();
}
if (webReader2 && typeof webReader2.wsFinish === "function") {
finishPromises.push(webReader2.wsFinish(timeoutMs));
} else if (webReader2) {
webReader2.wsStop();
}
if (finishPromises.length > 0) {
await Promise.allSettled(finishPromises);
}
}
function cleanup() {
stop();
if (rec) {
try {
rec.close();
} catch (error) {
console.warn("关闭旧rec失败:", error);
}
rec = null;
}
if (rec2) {
try {
rec2.close();
} catch (error) {
console.warn("关闭旧rec2失败:", error);
}
rec2 = null;
}
if (asrAudioContext) {
try {
asrAudioContext.close();
} catch (error) {}
asrAudioContext = null;
}
if (asrAudioContext2) {
try {
asrAudioContext2.close();
} catch (error) {}
asrAudioContext2 = null;
}
}
function createRecorders() {
onlineStreamer = createPcmStreamer({
label: "Online",
recorder: Recorder,
isActive: () => isActive,
sendChunk: (chunk) => webReader && webReader.wsSend(chunk),
chunkSize: ASR_CHUNK_SIZE,
warnSamples: ASR_BUFFER_WARN_SAMPLES,
hardLimitSamples: ASR_BUFFER_HARD_LIMIT_SAMPLES,
outputSampleRate: ASR_SAMPLE_RATE,
});
offlineStreamer = createPcmStreamer({
label: "Offline",
recorder: Recorder,
isActive: () => isActive,
sendChunk: (chunk) => webReader2 && webReader2.wsSend(chunk),
chunkSize: ASR_CHUNK_SIZE,
warnSamples: ASR_BUFFER_WARN_SAMPLES,
hardLimitSamples: ASR_BUFFER_HARD_LIMIT_SAMPLES,
outputSampleRate: ASR_SAMPLE_RATE,
});
rec = Recorder({
type: "pcm",
bitRate: 16,
sampleRate: ASR_SAMPLE_RATE,
onProcess: onlineStreamer.onProcess,
});
rec2 = Recorder({
type: "pcm",
bitRate: 16,
sampleRate: ASR_SAMPLE_RATE,
onProcess: offlineStreamer.onProcess,
});
}
function setupConnections() {
webReader = new WebSocketConnectMethod({
msgHandle: handleOnlineMessage,
stateHandle(connState) {
if (connState === 0) {
openMixedStream("online");
} else if (connState === 2) {
stop();
}
},
});
webReader2 = new WebSocketConnectMethod({
msgHandle: handleOfflineMessage,
stateHandle(connState) {
if (connState === 0) {
openMixedStream("offline");
} else if (connState === 2) {
stop();
}
},
});
}
function start() {
setupConnections();
createRecorders();
const onlineStarted = webReader.wsStart();
const offlineStarted = webReader2.wsStart("offline");
if (onlineStarted === 1 || offlineStarted === 1) {
setActive(true);
}
return onlineStarted === 1 && offlineStarted === 1;
}
function restart() {
resetTranscriptionFlag = true;
setPreviewText("");
let started = false;
if (webReader) {
const ret = webReader.wsStart();
if (ret === 1) {
started = true;
}
}
if (webReader2) {
try {
const ret = webReader2.wsStart("offline");
if (ret === 1) {
started = true;
}
} catch (error) {
console.warn("Failed to restart offline WS:", error);
}
}
if (started) {
setActive(true);
}
return started;
}
return {
start,
restart,
stop,
finish,
cleanup,
clearRealtimePipeline,
getLatestRealtimeTimestamp,
buildRealtimeEditCarryover,
renderFullText,
getPendingCorrectionCount() {
return pendingCorrectionCount;
},
};
};
})(window);