(() => { "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); })();