(function installControlBridge() { const allowedOrigins = window.DH_ALLOWED_PARENT_ORIGINS || ["*"]; const statusElement = document.getElementById("status"); let audioContext = null; let currentSource = null; let currentRequestId = null; let ready = false; let volume = 1; function isAllowedOrigin(origin) { return allowedOrigins.includes("*") || allowedOrigins.includes(origin); } function send(target, targetOrigin, type, payload = {}) { if (!target || typeof target.postMessage !== "function") return; target.postMessage({ type, avatarId: window.DH_BOOTSTRAP.avatarId, ...payload }, targetOrigin); } function notifyParent(type, payload = {}) { if (window.parent === window) return; if (allowedOrigins.includes("*")) { send(window.parent, "*", type, payload); return; } for (const origin of allowedOrigins) { send(window.parent, origin, type, payload); } } function requireReady() { if (!ready || !window.Module || !window.DigitalHumanRuntime) { throw new Error("digital human runtime is not ready"); } } function markReady() { ready = true; if (statusElement) statusElement.style.display = "none"; notifyParent("digital-human.ready", {}); } function clearWasmAudio() { if (window.Module && typeof window.Module._clearAudio === "function") { window.Module._clearAudio(); } } function stopAudio(emitEvent = true) { if (currentSource) { currentSource.onended = null; try { currentSource.stop(); } catch (_) { /* already stopped */ } currentSource.disconnect(); currentSource = null; } clearWasmAudio(); if (emitEvent && currentRequestId) { notifyParent("digital-human.speaking-end", { requestId: currentRequestId, reason: "stopped", }); } currentRequestId = null; } async function resolveAudioBuffer(message) { if (message.audioBuffer instanceof ArrayBuffer) { return message.audioBuffer; } if (ArrayBuffer.isView(message.audioBuffer)) { return message.audioBuffer.buffer.slice( message.audioBuffer.byteOffset, message.audioBuffer.byteOffset + message.audioBuffer.byteLength ); } if (message.audioBase64) { const binary = atob(message.audioBase64); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index++) { bytes[index] = binary.charCodeAt(index); } return bytes.buffer; } if (message.audioUrl) { const response = await fetch(message.audioUrl); if (!response.ok) { throw new Error(`audio download failed with HTTP ${response.status}`); } return await response.arrayBuffer(); } throw new Error("audioBuffer, audioBase64, or audioUrl is required"); } async function playAudio(message) { requireReady(); const compressedAudio = await resolveAudioBuffer(message); stopAudio(false); const wasmBytes = new Uint8Array(compressedAudio); const pointer = window.Module._malloc(wasmBytes.byteLength); try { window.Module.HEAPU8.set(wasmBytes, pointer); window.Module._setAudioBuffer(pointer, wasmBytes.byteLength); } finally { window.Module._free(pointer); } audioContext = audioContext || new (window.AudioContext || window.webkitAudioContext)(); if (audioContext.state === "suspended") { await audioContext.resume(); } const decoded = await audioContext.decodeAudioData(compressedAudio.slice(0)); const gain = audioContext.createGain(); gain.gain.value = volume; gain.connect(audioContext.destination); currentSource = audioContext.createBufferSource(); currentSource.buffer = decoded; currentSource.connect(gain); currentRequestId = message.requestId || crypto.randomUUID(); currentSource.onended = () => { const requestId = currentRequestId; currentSource = null; currentRequestId = null; // Natural playback completion must release the same WASM buffer as // an explicit stop. Otherwise the browser audio is silent while the // lip-sync runtime keeps consuming stale samples. clearWasmAudio(); notifyParent("digital-human.speaking-end", { requestId, reason: "ended", }); }; currentSource.start(0); notifyParent("digital-human.speaking-start", { requestId: currentRequestId, duration: decoded.duration, }); } async function handleCommand(event) { if (!isAllowedOrigin(event.origin)) return; const message = event.data || {}; if (typeof message.type !== "string" || !message.type.startsWith("digital-human.")) { return; } try { switch (message.type) { case "digital-human.play-audio": await playAudio(message); break; case "digital-human.stop": stopAudio(true); break; case "digital-human.pause": requireReady(); window.DigitalHumanRuntime.pause(); if (audioContext) await audioContext.suspend(); break; case "digital-human.resume": requireReady(); window.DigitalHumanRuntime.resume(); if (audioContext) await audioContext.resume(); break; case "digital-human.set-background": requireReady(); window.DigitalHumanRuntime.setBackground( message.backgroundUrl || null, message.backgroundType || "video" ); break; case "digital-human.set-volume": volume = Math.max(0, Math.min(1, Number(message.volume))); break; case "digital-human.get-state": send(event.source, event.origin, "digital-human.state", { requestId: message.requestId, ready, speaking: Boolean(currentSource), paused: window.DigitalHumanRuntime?.isPaused() ?? false, }); return; default: return; } send(event.source, event.origin, "digital-human.command-accepted", { requestId: message.requestId, command: message.type, }); } catch (error) { send(event.source, event.origin, "digital-human.error", { requestId: message.requestId, message: error instanceof Error ? error.message : String(error), }); } } window.addEventListener("message", handleCommand); window.addEventListener("digital-human-runtime-ready", markReady); // The runtime can finish between the preceding script tag and bridge // installation. Preserve the ready state so fast local bundles cannot lose // the only handshake event. if (window.DH_RUNTIME_READY) queueMicrotask(markReady); window.addEventListener("error", (event) => { if (statusElement) statusElement.textContent = "数字人加载失败"; notifyParent("digital-human.error", { message: event.message || "viewer error" }); }); })();