Files

1011 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
"use strict";
const TARGET_SAMPLE_RATE = 16000;
const MAX_RECORDING_MS = 28000;
const IDLE_BUFFER_RESET_MS = 8000;
const ENDPOINT_SILENCE_MS = 1050;
const WAKE_FOLLOWUP_TIMEOUT_MS = 6500;
const PREVIEW_INTERVAL_MS = 850;
const MIN_PREVIEW_AUDIO_MS = 320;
const STATUS_RESET_MS = 5000;
const MICROPHONE_CONNECT_TIMEOUT_MS = 12000;
const DEFAULT_WAKE_WORD = "你好,小迪";
const PREFERRED_MICROPHONE = /TAKSTAR\s*V2/i;
const ACTIVE_PHASES = new Set(["arming", "armed", "capturing", "finalizing"]);
const DEBUG = new URLSearchParams(window.location.search).get("speechDebug") === "1"
|| window.localStorage.getItem("speechDebug") === "1";
const speech = {
phase: "idle",
backendReady: false,
wakeWord: DEFAULT_WAKE_WORD,
inputMode: "wake",
assistantOpen: false,
microphonePermissionGranted: false,
permissionStatus: null,
wakeRestartTimer: null,
stream: null,
context: null,
contextPromise: null,
workletReady: false,
workletFailed: false,
source: null,
captureNode: null,
silentGain: null,
chunks: [],
totalSamples: 0,
sampleRate: 0,
startedAt: 0,
speechStartSample: null,
lastVoiceSample: null,
consecutiveVoiceSamples: 0,
noiseFloor: 0.003,
statusTimer: null,
statusPollTimer: null,
generation: 0,
utteranceId: "",
finishPromise: null,
endpointPromise: null,
endpointing: false,
previewSequence: 0,
lastAppliedPreviewSequence: 0,
lastPreviewAt: 0,
previewIntervalMs: PREVIEW_INTERVAL_MS,
previewInFlight: null,
previewQueued: false,
previewController: null,
finalController: null,
wakeDetected: false,
wakeDetectedAt: 0,
lastLiveText: "",
applyingResult: false,
lastResult: null,
};
const elements = () => ({
button: document.querySelector("#speechDictationButton"),
composer: document.querySelector("#speechDictationButton")?.closest(".command-composer"),
input: document.querySelector("#utterance"),
status: document.querySelector("#speechDictationStatus"),
hiddenLabel: document.querySelector("#speechDictationButton .speech-visually-hidden"),
});
function debug(stage, details = {}) {
if (!DEBUG) return;
console.debug("[speech]", { utteranceId: speech.utteranceId, stage, ...details });
}
function announce(message) {
const notify = window.CognitiveAgentConsole?.notify;
if (typeof notify === "function") notify(message);
}
function phaseLabel(phase) {
if (phase === "warming") return "语音模型正在后台加载";
if (phase === "arming") return speech.inputMode === "manual"
? "正在开始语音输入"
: "正在开启语音唤醒";
if (phase === "armed") return `开始语音输入,后台唤醒词:${speech.wakeWord}`;
if (phase === "capturing") return "结束本次语音输入";
if (phase === "finalizing") return "正在整理语音内容";
return "开始语音输入";
}
function configuredWakeWord(status) {
const primary = status?.wake_word && typeof status.wake_word === "object"
? status.wake_word.primary
: "";
return String(primary || DEFAULT_WAKE_WORD).trim() || DEFAULT_WAKE_WORD;
}
function speechStatusReadiness(status, responseOk = true) {
const loadState = String(status?.load_state || status?.warmup?.state || "idle");
return {
loadState,
ready: responseOk && status?.enabled === true
&& (status.loaded === true || !["loading", "error"].includes(loadState)),
};
}
function setPhase(phase, message = "") {
speech.phase = phase;
const { button, composer, status, hiddenLabel } = elements();
if (!button || !composer || !status) return;
window.clearTimeout(speech.statusTimer);
composer.dataset.speechState = phase;
status.textContent = message;
button.classList.toggle("is-armed", phase === "armed");
button.classList.toggle("is-listening", phase === "capturing");
button.classList.toggle("is-processing", ["arming", "finalizing"].includes(phase));
button.disabled = ["warming", "arming", "finalizing"].includes(phase)
|| (!speech.backendReady && !["armed", "capturing"].includes(phase));
button.setAttribute("aria-pressed", String(["armed", "capturing"].includes(phase)));
const label = phaseLabel(phase);
button.setAttribute("aria-label", label);
button.title = phase === "warming"
? "语音模型正在后台加载"
: phase === "armed"
? `正在后台等待“${speech.wakeWord}”,点击立即开始录音`
: phase === "capturing"
? "正在实时转录,点击可结束"
: speech.backendReady
? "开始语音输入"
: "语音识别模型未就绪";
if (hiddenLabel) hiddenLabel.textContent = label;
window.dispatchEvent(new CustomEvent("cognitive-agent:speech-state", {
detail: { phase, message, input_mode: speech.inputMode, wake_word: speech.wakeWord },
}));
if (["success", "error"].includes(phase)) {
speech.statusTimer = window.setTimeout(() => setPhase("idle"), STATUS_RESET_MS);
}
}
function microphoneErrorMessage(error) {
switch (error?.name) {
case "NotAllowedError":
return "未获得麦克风权限,请在浏览器地址栏允许访问麦克风。";
case "NotFoundError":
return "没有找到可用麦克风,请检查 TAKSTAR V2 接收器。";
case "NotReadableError":
return "麦克风正被其他程序占用,请关闭占用程序后重试。";
case "OverconstrainedError":
return "TAKSTAR V2 当前不可用,请重新连接接收器。";
case "SecurityError":
return "当前页面不允许访问麦克风,请使用 HTTPS 或本机地址。";
case "TimeoutError":
return "等待麦克风授权超时,请允许访问后重试。";
default:
return "无法启动麦克风,请检查设备连接和浏览器权限。";
}
}
function backgroundWakeEligible() {
return speech.backendReady
&& speech.assistantOpen
&& !document.hidden
&& !ACTIVE_PHASES.has(speech.phase)
&& !speech.stream;
}
function handleMicrophonePermissionChange() {
const state = speech.permissionStatus?.state || "unknown";
speech.microphonePermissionGranted = state === "granted";
if (state === "denied" && speech.stream) {
void cancelDictation("麦克风权限已关闭", { silent: speech.inputMode === "wake" });
return;
}
if (state === "granted") scheduleWakeStandby();
}
async function microphonePermissionState() {
if (speech.permissionStatus) {
if (speech.permissionStatus.state === "denied") return "denied";
if (speech.microphonePermissionGranted) return "granted";
return speech.permissionStatus.state;
}
if (!navigator.permissions?.query) {
return speech.microphonePermissionGranted ? "granted" : "unknown";
}
try {
const status = await navigator.permissions.query({ name: "microphone" });
speech.permissionStatus = status;
status.addEventListener?.("change", handleMicrophonePermissionChange);
return status.state;
} catch (_error) {
return speech.microphonePermissionGranted ? "granted" : "unknown";
}
}
async function maybeStartWakeStandby() {
window.clearTimeout(speech.wakeRestartTimer);
speech.wakeRestartTimer = null;
if (!backgroundWakeEligible()) return false;
const permissionState = await microphonePermissionState();
if (permissionState !== "granted" || !backgroundWakeEligible()) {
debug("wake_autostart_skipped", { permissionState });
return false;
}
return startWakeStandby({ automatic: true });
}
function scheduleWakeStandby(delay = 0) {
window.clearTimeout(speech.wakeRestartTimer);
speech.wakeRestartTimer = window.setTimeout(() => {
void maybeStartWakeStandby();
}, delay);
}
function setAssistantOpen(open) {
speech.assistantOpen = Boolean(open);
window.clearTimeout(speech.wakeRestartTimer);
speech.wakeRestartTimer = null;
if (!speech.assistantOpen) {
const stopping = cancelDictation("AI助手已收起,语音输入已停止", { silent: true });
void stopping.finally(() => {
// The drawer can be reopened while AudioContext suspension is still
// finishing. Re-check the latest UI state so that race does not lose
// the background wake listener.
if (speech.assistantOpen) scheduleWakeStandby();
});
return stopping;
}
scheduleWakeStandby();
return Promise.resolve();
}
async function getUserMediaWithTimeout(constraints) {
let expired = false;
let timer = 0;
const request = navigator.mediaDevices.getUserMedia(constraints);
const timeout = new Promise((_, reject) => {
timer = window.setTimeout(() => {
expired = true;
reject(new DOMException("microphone permission timed out", "TimeoutError"));
}, MICROPHONE_CONNECT_TIMEOUT_MS);
});
request.then((lateStream) => {
if (expired) lateStream.getTracks().forEach((track) => track.stop());
}).catch(() => {});
try {
return await Promise.race([request, timeout]);
} finally {
window.clearTimeout(timer);
}
}
async function preferredMicrophoneStream() {
const constraints = {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
};
let stream = await getUserMediaWithTimeout({ audio: constraints, video: false });
const currentTrack = stream.getAudioTracks()[0];
const devices = await navigator.mediaDevices.enumerateDevices();
const preferred = devices.find(
(device) => device.kind === "audioinput" && PREFERRED_MICROPHONE.test(device.label),
);
if (preferred && preferred.deviceId !== currentTrack?.getSettings?.().deviceId) {
stream.getTracks().forEach((track) => track.stop());
stream = await getUserMediaWithTimeout({
audio: { ...constraints, deviceId: { exact: preferred.deviceId } },
video: false,
});
}
return stream;
}
async function ensureAudioContext() {
if (speech.context && speech.context.state !== "closed") {
await speech.context.resume();
return speech.context;
}
if (speech.contextPromise) return speech.contextPromise;
speech.contextPromise = (async () => {
const AudioContext = window.AudioContext || window.webkitAudioContext;
const context = new AudioContext({ latencyHint: "interactive" });
await context.resume();
if (context.audioWorklet && window.AudioWorkletNode) {
try {
await context.audioWorklet.addModule("/speech-audio-worklet.js?v=sensevoice-5");
speech.workletReady = true;
} catch (error) {
speech.workletFailed = true;
debug("audio_worklet_fallback", { message: error?.message || String(error) });
}
} else {
speech.workletFailed = true;
}
speech.context = context;
return context;
})();
try {
return await speech.contextPromise;
} finally {
speech.contextPromise = null;
}
}
function audioRms(samples) {
let sum = 0;
for (let index = 0; index < samples.length; index += 1) sum += samples[index] * samples[index];
return Math.sqrt(sum / Math.max(1, samples.length));
}
function newUtteranceId(generation = speech.generation) {
return `web-${crypto.randomUUID?.() || `${Date.now()}-${generation}`}`;
}
function resetAttempt({ rotateUtterance = true } = {}) {
if (rotateUtterance) speech.utteranceId = newUtteranceId();
speech.chunks = [];
speech.totalSamples = 0;
speech.startedAt = performance.now();
speech.speechStartSample = null;
speech.lastVoiceSample = null;
speech.consecutiveVoiceSamples = 0;
speech.noiseFloor = 0.003;
speech.previewSequence = 0;
speech.lastAppliedPreviewSequence = 0;
speech.lastPreviewAt = 0;
speech.previewQueued = false;
speech.wakeDetected = false;
speech.wakeDetectedAt = 0;
speech.lastLiveText = "";
}
function clearLegacySpeechGuard(input) {
if (!input) return;
delete input.dataset.speechUncertain;
delete input.dataset.speechExecutionEligible;
delete input.dataset.speechSemanticStatus;
delete input.dataset.speechValue;
}
function replaceInput(value, stage) {
const { input } = elements();
if (!input) return;
speech.applyingResult = true;
clearLegacySpeechGuard(input);
input.value = String(value || "").slice(0, input.maxLength || 4000);
input.dataset.speechStage = stage;
input.dataset.speechUtteranceId = speech.utteranceId;
input.dispatchEvent(new Event("input", { bubbles: true }));
speech.applyingResult = false;
input.focus({ preventScroll: true });
}
function concatenateChunks({ trimEnd = false } = {}) {
const combined = new Float32Array(speech.totalSamples);
let offset = 0;
for (const chunk of speech.chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
const start = Math.max(0, Math.floor(speech.speechStartSample || 0));
const end = trimEnd
? Math.min(
combined.length,
Math.ceil((speech.lastVoiceSample || combined.length) + (speech.sampleRate * 0.45)),
)
: combined.length;
return combined.slice(start, end);
}
function downsample(samples, inputRate) {
if (inputRate === TARGET_SAMPLE_RATE) return samples;
const ratio = inputRate / TARGET_SAMPLE_RATE;
const output = new Float32Array(Math.max(1, Math.floor(samples.length / ratio)));
for (let outputIndex = 0; outputIndex < output.length; outputIndex += 1) {
const start = Math.floor(outputIndex * ratio);
const end = Math.max(start + 1, Math.min(samples.length, Math.floor((outputIndex + 1) * ratio)));
let sum = 0;
for (let inputIndex = start; inputIndex < end; inputIndex += 1) sum += samples[inputIndex];
output[outputIndex] = sum / (end - start);
}
return output;
}
function encodePcm16Wav(samples) {
const buffer = new ArrayBuffer(44 + (samples.length * 2));
const view = new DataView(buffer);
const writeAscii = (offset, value) => {
for (let index = 0; index < value.length; index += 1) view.setUint8(offset + index, value.charCodeAt(index));
};
writeAscii(0, "RIFF");
view.setUint32(4, 36 + (samples.length * 2), true);
writeAscii(8, "WAVE");
writeAscii(12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, TARGET_SAMPLE_RATE, true);
view.setUint32(28, TARGET_SAMPLE_RATE * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeAscii(36, "data");
view.setUint32(40, samples.length * 2, true);
for (let index = 0; index < samples.length; index += 1) {
const sample = Math.max(-1, Math.min(1, samples[index]));
view.setInt16(44 + (index * 2), sample < 0 ? sample * 32768 : sample * 32767, true);
}
return buffer;
}
function previewWav() {
if (speech.speechStartSample === null || !speech.sampleRate) return null;
const samples = concatenateChunks();
if ((samples.length / speech.sampleRate) * 1000 < MIN_PREVIEW_AUDIO_MS) return null;
return encodePcm16Wav(downsample(samples, speech.sampleRate));
}
function applyPreview(result, { generation, utteranceId, requestedSequence }) {
if (generation !== speech.generation || utteranceId !== speech.utteranceId) return false;
const sequence = Number(result.sequence ?? requestedSequence);
if (!Number.isFinite(sequence) || result.stale === true
|| sequence <= speech.lastAppliedPreviewSequence) {
debug("preview_ignored", { sequence, stale: result.stale === true });
return false;
}
speech.lastAppliedPreviewSequence = sequence;
const wakeWord = result.wake_word && typeof result.wake_word === "object"
? result.wake_word
: {};
const manualInput = speech.inputMode === "manual";
const detectedWake = manualInput || wakeWord.detected === true;
const liveText = String(manualInput
? (result.live_text || result.raw_text || result.text || "")
: (wakeWord.remainder ?? result.live_text ?? "")).trim();
if (!speech.wakeDetected && detectedWake) {
speech.wakeDetected = true;
speech.wakeDetectedAt = performance.now();
if (wakeWord.configured_word) speech.wakeWord = String(wakeWord.configured_word);
setPhase("capturing", manualInput
? "正在实时转录…"
: liveText
? "已唤醒,正在实时转录…"
: "已唤醒,请继续说…");
replaceInput("", "partial");
if (!manualInput) window.CognitiveAgentAssistant?.open?.();
debug(manualInput ? "manual_preview_started" : "wake_detected", {
wakeWord: speech.wakeWord,
sequence,
});
}
if (speech.wakeDetected && liveText) {
speech.lastLiveText = liveText;
replaceInput(liveText, "partial");
setPhase("capturing", "正在实时转录…");
}
window.dispatchEvent(new CustomEvent("cognitive-agent:speech-preview", { detail: result }));
return true;
}
async function requestPreview({ force = false } = {}) {
if (!["armed", "capturing"].includes(speech.phase)) return null;
if (speech.previewInFlight) {
speech.previewQueued = true;
return speech.previewInFlight;
}
const wav = previewWav();
if (!wav) return null;
const now = performance.now();
if (!force && now - speech.lastPreviewAt < speech.previewIntervalMs) return null;
speech.lastPreviewAt = now;
const generation = speech.generation;
const utteranceId = speech.utteranceId;
const requestedSequence = speech.previewSequence + 1;
speech.previewSequence = requestedSequence;
const controller = new AbortController();
speech.previewController = controller;
const timeout = window.setTimeout(() => controller.abort("preview_timeout"), 12000);
speech.previewInFlight = (async () => {
try {
const response = await fetch("/api/speech/preview", {
method: "POST",
credentials: "same-origin",
cache: "no-store",
headers: {
Accept: "application/json",
"Content-Type": "audio/wav",
"X-Speech-Utterance-Id": utteranceId,
"X-Speech-Preview-Sequence": String(requestedSequence),
"X-Speech-Input-Mode": speech.inputMode,
},
body: wav,
signal: controller.signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.message || `实时转录失败(HTTP ${response.status})`);
applyPreview(payload, { generation, utteranceId, requestedSequence });
debug("preview_result", { sequence: requestedSequence, payload });
return payload;
} catch (error) {
if (error?.name !== "AbortError" && generation === speech.generation) {
debug("preview_failed", { sequence: requestedSequence, message: error?.message || String(error) });
}
return null;
} finally {
window.clearTimeout(timeout);
}
})();
try {
return await speech.previewInFlight;
} finally {
speech.previewInFlight = null;
if (speech.previewController === controller) speech.previewController = null;
const queued = speech.previewQueued;
speech.previewQueued = false;
// Audio callbacks will schedule the next preview after the cadence has
// elapsed. Do not immediately replay a queued request: that would turn a
// warm offline decoder into an unbounded request loop under continuous
// speech. Endpoint flushing below still forces the newest snapshot.
if (queued) debug("preview_coalesced", { sequence: requestedSequence });
}
}
async function flushPreview() {
if (speech.previewInFlight) await speech.previewInFlight;
if (!["armed", "capturing"].includes(speech.phase)) return null;
speech.previewQueued = false;
return requestPreview({ force: true });
}
async function handleEndpoint(generation) {
if (speech.endpointPromise || generation !== speech.generation) return;
speech.endpointPromise = (async () => {
speech.endpointing = true;
try {
if (speech.phase === "armed" && speech.inputMode === "wake") {
await flushPreview();
if (generation !== speech.generation) return;
if (!speech.wakeDetected) {
resetAttempt();
setPhase("armed", `未检测到唤醒词,请说“${speech.wakeWord}”`);
return;
}
}
if (speech.phase !== "capturing") return;
if (!speech.lastLiveText && speech.inputMode === "wake") {
speech.lastVoiceSample = null;
speech.consecutiveVoiceSamples = 0;
setPhase("capturing", "已唤醒,请继续说…");
return;
}
await finishDictation();
} finally {
speech.endpointing = false;
}
})();
try {
await speech.endpointPromise;
} finally {
speech.endpointPromise = null;
}
}
function handleAudio(chunk, generation) {
if (!["armed", "capturing"].includes(speech.phase) || generation !== speech.generation) return;
speech.chunks.push(chunk);
speech.totalSamples += chunk.length;
const now = performance.now();
const elapsed = now - speech.startedAt;
const rms = audioRms(chunk);
if (speech.speechStartSample === null) {
speech.noiseFloor = (speech.noiseFloor * 0.92) + (rms * 0.08);
}
const voiceThreshold = Math.max(0.009, Math.min(0.035, speech.noiseFloor * 2.8));
if (rms >= voiceThreshold) {
speech.consecutiveVoiceSamples += chunk.length;
if (speech.consecutiveVoiceSamples >= speech.sampleRate * 0.09) {
if (speech.speechStartSample === null) {
speech.speechStartSample = Math.max(0, speech.totalSamples - (speech.sampleRate * 0.35));
setPhase(speech.wakeDetected ? "capturing" : "armed", speech.wakeDetected
? "正在实时转录…"
: `正在确认唤醒词“${speech.wakeWord}”…`);
debug("speech_started", { sample: speech.speechStartSample, voiceThreshold });
}
speech.lastVoiceSample = speech.totalSamples;
}
} else {
speech.consecutiveVoiceSamples = 0;
}
if (speech.speechStartSample !== null) {
void requestPreview();
const speechDurationMs = ((speech.totalSamples - speech.speechStartSample) / speech.sampleRate) * 1000;
if (
speech.lastVoiceSample !== null
&& speech.totalSamples - speech.lastVoiceSample >= speech.sampleRate * (ENDPOINT_SILENCE_MS / 1000)
) {
void handleEndpoint(generation);
} else if (speechDurationMs >= MAX_RECORDING_MS) {
if (speech.inputMode === "manual" || (speech.wakeDetected && speech.lastLiveText)) {
void finishDictation();
}
else void cancelDictation(`未检测到完整问题,请重新说“${speech.wakeWord}”`);
}
} else if (elapsed >= IDLE_BUFFER_RESET_MS) {
if (speech.inputMode === "manual") {
void cancelDictation("未检测到说话声,请重试")
.then(() => scheduleWakeStandby(1200));
} else {
resetAttempt();
setPhase("armed", `正在等待“${speech.wakeWord}”`);
}
}
if (speech.inputMode === "wake" && speech.phase === "capturing"
&& !speech.lastLiveText && speech.wakeDetectedAt
&& now - speech.wakeDetectedAt >= WAKE_FOLLOWUP_TIMEOUT_MS) {
void cancelDictation("唤醒后未检测到问题,请重试");
}
}
function connectCapture(context, generation) {
speech.source = context.createMediaStreamSource(speech.stream);
speech.silentGain = context.createGain();
speech.silentGain.gain.value = 0;
if (speech.workletReady && !speech.workletFailed) {
speech.captureNode = new AudioWorkletNode(context, "speech-capture-processor", {
numberOfInputs: 1,
numberOfOutputs: 1,
channelCount: 1,
});
speech.captureNode.port.onmessage = (event) => {
handleAudio(new Float32Array(event.data), generation);
};
debug("capture_connected", { implementation: "AudioWorklet" });
} else {
speech.captureNode = context.createScriptProcessor(2048, 1, 1);
speech.captureNode.onaudioprocess = (event) => {
handleAudio(new Float32Array(event.inputBuffer.getChannelData(0)), generation);
};
debug("capture_connected", { implementation: "ScriptProcessor-fallback" });
}
speech.source.connect(speech.captureNode);
speech.captureNode.connect(speech.silentGain);
speech.silentGain.connect(context.destination);
}
async function startCapture(inputMode, { automatic = false } = {}) {
if (!speech.backendReady) {
if (!automatic) setPhase("error", "语音识别模型未就绪");
return false;
}
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
if (!automatic) {
setPhase("error", "请使用 HTTPS 或 127.0.0.1 开启语音输入");
announce("当前页面不支持安全的麦克风访问");
}
return false;
}
if (ACTIVE_PHASES.has(speech.phase)) return false;
if (automatic) {
const permissionState = await microphonePermissionState();
if (permissionState !== "granted" || !backgroundWakeEligible()) return false;
}
const generation = speech.generation + 1;
speech.generation = generation;
speech.inputMode = inputMode;
resetAttempt();
if (inputMode === "manual") {
speech.wakeDetected = true;
speech.wakeDetectedAt = performance.now();
}
try {
setPhase("arming", inputMode === "manual" ? "正在开始录音…" : "正在连接唤醒监听…");
const stream = await preferredMicrophoneStream();
speech.microphonePermissionGranted = true;
if (generation !== speech.generation || document.hidden
|| (automatic && !speech.assistantOpen)) {
stream.getTracks().forEach((track) => track.stop());
return false;
}
speech.stream = stream;
const context = await ensureAudioContext();
if (generation !== speech.generation) {
stream.getTracks().forEach((track) => track.stop());
if (speech.stream === stream) speech.stream = null;
return false;
}
speech.sampleRate = context.sampleRate;
connectCapture(context, generation);
const trackLabel = stream.getAudioTracks()[0]?.label || "默认麦克风";
if (inputMode === "manual") {
replaceInput("", "partial");
setPhase("capturing", `正在实时转录 · ${trackLabel}`);
debug("manual_capture_started", { sampleRate: speech.sampleRate, trackLabel });
} else {
setPhase("armed", `后台等待“${speech.wakeWord}” · ${trackLabel}`);
debug("wake_listener_started", { sampleRate: speech.sampleRate, trackLabel });
}
return true;
} catch (error) {
if (generation !== speech.generation) return false;
await releaseMicrophone();
if (error?.name === "NotAllowedError") speech.microphonePermissionGranted = false;
const message = microphoneErrorMessage(error);
if (automatic) {
setPhase("idle");
debug("wake_listener_unavailable", { message });
} else {
setPhase("error", message);
announce(message);
}
return false;
}
}
async function startManualDictation() {
window.clearTimeout(speech.wakeRestartTimer);
speech.wakeRestartTimer = null;
if (speech.phase === "capturing") return finishDictation();
if (speech.phase === "armed" && speech.stream) {
abortRequests("manual_capture_started");
speech.inputMode = "manual";
resetAttempt();
speech.wakeDetected = true;
speech.wakeDetectedAt = performance.now();
replaceInput("", "partial");
setPhase("capturing", "正在实时转录…");
debug("wake_standby_promoted_to_manual", { sampleRate: speech.sampleRate });
return true;
}
if (ACTIVE_PHASES.has(speech.phase)) return false;
return startCapture("manual");
}
async function startWakeStandby({ automatic = true } = {}) {
if (speech.phase === "armed" && speech.inputMode === "wake" && speech.stream) return true;
if (ACTIVE_PHASES.has(speech.phase)) return false;
return startCapture("wake", { automatic });
}
function abortRequests(reason = "speech_cancelled") {
speech.previewController?.abort(reason);
speech.finalController?.abort(reason);
speech.previewController = null;
speech.finalController = null;
}
async function releaseMicrophone({ closeContext = false } = {}) {
if (speech.captureNode?.port) speech.captureNode.port.onmessage = null;
if (speech.captureNode && "onaudioprocess" in speech.captureNode) {
speech.captureNode.onaudioprocess = null;
}
for (const node of [speech.source, speech.captureNode, speech.silentGain]) {
try { node?.disconnect(); } catch (_error) { /* already disconnected */ }
}
speech.stream?.getTracks().forEach((track) => track.stop());
speech.stream = null;
speech.source = null;
speech.captureNode = null;
speech.silentGain = null;
if (closeContext && speech.context && speech.context.state !== "closed") {
try { await speech.context.close(); } catch (_error) { /* page is closing */ }
speech.context = null;
speech.workletReady = false;
speech.workletFailed = false;
} else if (speech.context?.state === "running") {
try { await speech.context.suspend(); } catch (_error) { /* already suspended */ }
}
}
async function transcribe(wav, utteranceId, inputMode = speech.inputMode) {
const controller = new AbortController();
speech.finalController = controller;
const timeout = window.setTimeout(() => controller.abort("final_timeout"), 45000);
try {
const response = await fetch("/api/speech/transcribe", {
method: "POST",
credentials: "same-origin",
cache: "no-store",
headers: {
Accept: "application/json",
"Content-Type": "audio/wav",
"X-Speech-Utterance-Id": utteranceId,
"X-Speech-Input-Mode": inputMode,
"X-Speech-Wake-Word-Required": inputMode === "wake" ? "1" : "0",
},
body: wav,
signal: controller.signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.message || `语音转写失败(HTTP ${response.status})`);
debug("server_result", payload);
return payload;
} finally {
window.clearTimeout(timeout);
if (speech.finalController === controller) speech.finalController = null;
}
}
function applyResult(result) {
const { input } = elements();
if (!input) throw new Error("没有找到 AI助手输入框");
const assistantText = String(
result.assistant_text || result.clean_text || result.live_text || result.raw_text || result.text || "",
).trim();
if (!assistantText) {
window.dispatchEvent(new CustomEvent("cognitive-agent:speech-result", { detail: result }));
return false;
}
replaceInput(assistantText, "final");
input.dataset.speechValue = input.value.trim();
window.dispatchEvent(new CustomEvent("cognitive-agent:speech-result", { detail: result }));
return true;
}
async function finishDictation() {
if (speech.finishPromise) return speech.finishPromise;
if (speech.phase !== "capturing"
|| (speech.inputMode === "wake" && !speech.wakeDetected)) return undefined;
speech.finishPromise = (async () => {
const generation = speech.generation;
const inputMode = speech.inputMode;
const hadSpeech = speech.speechStartSample !== null;
const samples = hadSpeech ? concatenateChunks({ trimEnd: true }) : null;
const inputRate = speech.sampleRate;
const utteranceId = speech.utteranceId;
setPhase("finalizing", "说话结束,正在整理表达…");
speech.endpointing = true;
speech.previewController?.abort("speech_finalizing");
await releaseMicrophone();
speech.chunks = [];
if (!hadSpeech || !samples?.length) {
setPhase("error", "未检测到有效问题,请重试");
scheduleWakeStandby(1200);
return;
}
try {
const wav = encodePcm16Wav(downsample(samples, inputRate));
const result = await transcribe(wav, utteranceId, inputMode);
if (generation !== speech.generation) return;
speech.lastResult = result;
const applied = applyResult(result);
const latency = Math.round(result.timings?.total_ms ?? result.inference_ms ?? 0);
if (!applied) {
setPhase("error", "没有识别出有效内容,请重试");
return;
}
setPhase("success", latency > 0 ? `语音已整理 · ${latency} ms` : "语音已整理,可以发送");
announce("语音内容已整理,可以发送给 AI助手");
} catch (error) {
if (generation !== speech.generation) return;
const message = error?.name === "AbortError" ? "语音转写已取消或超时,请重试。" : error.message;
setPhase("error", message || "语音转写失败,请重试");
announce(message || "语音转写失败");
} finally {
speech.endpointing = false;
if (generation === speech.generation) scheduleWakeStandby(1200);
}
})();
try {
return await speech.finishPromise;
} finally {
speech.finishPromise = null;
}
}
async function cancelDictation(message = "已关闭语音唤醒", { silent = false } = {}) {
window.clearTimeout(speech.wakeRestartTimer);
speech.wakeRestartTimer = null;
if (!ACTIVE_PHASES.has(speech.phase) && !speech.stream) return;
speech.generation += 1;
speech.endpointing = false;
speech.previewQueued = false;
abortRequests();
await releaseMicrophone();
speech.chunks = [];
if (silent) setPhase("idle");
else setPhase("error", message);
}
async function loadSpeechStatus() {
const { button } = elements();
if (!button) return;
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
button.disabled = true;
setPhase("error", "语音唤醒需要 HTTPS 或本机地址");
return;
}
try {
const response = await fetch("/api/speech/status", {
credentials: "same-origin",
cache: "no-store",
headers: { Accept: "application/json" },
});
const status = await response.json();
const { loadState, ready } = speechStatusReadiness(status, response.ok);
speech.backendReady = ready;
speech.wakeWord = configuredWakeWord(status);
const recommendedPreviewInterval = Number(status.live_preview?.recommended_interval_ms);
if (Number.isFinite(recommendedPreviewInterval)) {
speech.previewIntervalMs = Math.max(350, Math.min(2000, recommendedPreviewInterval));
}
debug("backend_status", status);
window.clearTimeout(speech.statusPollTimer);
if (response.ok && status.enabled === true && loadState === "loading") {
setPhase("warming", "语音模型正在后台加载,完成后即可唤醒…");
speech.statusPollTimer = window.setTimeout(loadSpeechStatus, 1000);
} else {
setPhase("idle");
if (speech.backendReady) scheduleWakeStandby();
}
if (!speech.backendReady && loadState !== "loading") {
button.disabled = true;
button.title = loadState === "error"
? "SenseVoiceSmall 模型加载失败"
: "SenseVoiceSmall 模型未部署";
}
} catch (_error) {
speech.backendReady = false;
button.disabled = true;
button.title = "无法连接语音识别服务";
}
}
function clearSpeechGuardOnManualEdit(event) {
if (speech.applyingResult || !event.isTrusted) return;
const { input } = elements();
clearLegacySpeechGuard(input);
if (input) {
delete input.dataset.speechStage;
delete input.dataset.speechUtteranceId;
}
const formalCaptureActive = speech.phase === "capturing"
|| speech.phase === "finalizing"
|| (speech.phase === "arming" && speech.inputMode === "manual");
if (formalCaptureActive) {
void cancelDictation("已停止语音输入,保留手工编辑内容", { silent: true });
}
}
function initialize() {
const { button, input } = elements();
if (!button) return;
button.addEventListener("click", () => {
if (speech.phase === "capturing") void finishDictation();
else void startManualDictation();
});
input?.addEventListener("input", clearSpeechGuardOnManualEdit);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && ACTIVE_PHASES.has(speech.phase)) {
event.preventDefault();
void cancelDictation();
}
});
document.addEventListener("visibilitychange", () => {
if (document.hidden) void cancelDictation("页面已隐藏,语音输入已停止", { silent: true });
else scheduleWakeStandby();
});
window.addEventListener("pagehide", () => {
window.clearTimeout(speech.statusPollTimer);
window.clearTimeout(speech.wakeRestartTimer);
speech.permissionStatus?.removeEventListener?.("change", handleMicrophonePermissionChange);
speech.generation += 1;
abortRequests("page_hidden");
void releaseMicrophone({ closeContext: true });
});
void loadSpeechStatus();
}
window.CognitiveAgentSpeech = {
get lastResult() { return speech.lastResult; },
get phase() { return speech.phase; },
get wakeWord() { return speech.wakeWord; },
get inputMode() { return speech.inputMode; },
get debugEnabled() { return DEBUG; },
startManualDictation,
startWakeStandby,
setAssistantOpen,
stop(message = "已关闭语音唤醒") {
return cancelDictation(message, { silent: true });
},
};
if (window.__N7WEB_SPEECH_TEST__ === true) {
window.CognitiveAgentSpeech.__test = {
applyPreview,
applyResult,
configuredWakeWord,
speechStatusReadiness,
startManualDictation,
startWakeStandby,
finishDictation,
handleAudio,
cancelDictation,
releaseMicrophone,
transcribe,
initialize,
setAssistantOpen,
setPhase,
state: speech,
};
}
document.addEventListener("DOMContentLoaded", initialize);
})();