934 lines
26 KiB
JavaScript
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);
|