| const elements = { |
| start: document.querySelector("#startShare"), |
| stop: document.querySelector("#stopShare"), |
| loadSample: document.querySelector("#loadSample"), |
| toggleRig: document.querySelector("#toggleRig"), |
| toggleScreen: document.querySelector("#toggleScreen"), |
| openChatGPT: document.querySelector("#openChatGPT"), |
| video: document.querySelector("#sharedScreen"), |
| empty: document.querySelector("#emptyState"), |
| frame: document.querySelector("#rigFrame"), |
| shareStatus: document.querySelector("#shareStatus"), |
| audioStatus: document.querySelector("#audioStatus"), |
| rigStatus: document.querySelector("#rigStatus"), |
| meter: document.querySelector("#mouthMeter"), |
| value: document.querySelector("#mouthValue"), |
| sensitivity: document.querySelector("#sensitivity"), |
| message: document.querySelector("#message"), |
| }; |
|
|
| let displayStream = null; |
| let audioContext = null; |
| let analyser = null; |
| let samples = null; |
| let animationId = null; |
| let smoothedLevel = 0; |
| let rigMouthInput = null; |
| let rigAvailable = false; |
| let rigControlsVisible = false; |
|
|
| function setMessage(text, error = false) { |
| elements.message.textContent = text; |
| elements.message.classList.toggle("error", error); |
| } |
|
|
| async function loadRigStatus() { |
| try { |
| const response = await fetch("/api/status"); |
| const status = await response.json(); |
| if (!status.animeRigAvailable) { |
| elements.rigStatus.textContent = "未配置"; |
| elements.frame.hidden = true; |
| elements.toggleRig.disabled = true; |
| setMessage(`${status.expectedPath} にAnime2.5DRigのindex.htmlを配置してください。音声検証は実行できます。`); |
| return; |
| } |
| elements.rigStatus.textContent = "利用可能"; |
| rigAvailable = true; |
| elements.frame.src = status.animeRigUrl; |
| elements.frame.hidden = false; |
| elements.toggleRig.disabled = false; |
| elements.loadSample.disabled = false; |
| } catch (error) { |
| elements.rigStatus.textContent = "確認失敗"; |
| setMessage(`Anime2.5DRigの状態確認に失敗しました: ${error.message}`, true); |
| } |
| } |
|
|
| function findRigMouthInput() { |
| try { |
| const doc = elements.frame.contentDocument; |
| if (!doc) return null; |
| const knownMouthInput = doc.querySelector("#pMouthOpen"); |
| if (knownMouthInput) return knownMouthInput; |
| const sliders = [...doc.querySelectorAll('input[type="range"]')]; |
| return sliders.find((input) => { |
| const area = input.parentElement?.parentElement?.textContent || input.parentElement?.textContent || ""; |
| return area.includes("口") && area.includes("開き"); |
| }) || null; |
| } catch { |
| return null; |
| } |
| } |
|
|
| function applyRigDisplayMode() { |
| const doc = elements.frame.contentDocument; |
| if (!doc) return; |
| const styleId = "gpt-voice-avatar-only"; |
| doc.getElementById(styleId)?.remove(); |
| elements.frame.classList.toggle("controls-visible", rigControlsVisible); |
|
|
| if (rigControlsVisible) return; |
| const style = doc.createElement("style"); |
| style.id = styleId; |
| style.textContent = ` |
| html, body, #main, #stage { |
| width: 100% !important; |
| height: 100% !important; |
| min-width: 0 !important; |
| min-height: 0 !important; |
| margin: 0 !important; |
| padding: 0 !important; |
| overflow: hidden !important; |
| background: transparent !important; |
| } |
| header, #panel, #fps, #rigInfo, #drop, #readmeOverlay { |
| display: none !important; |
| } |
| #stage { |
| display: flex !important; |
| align-items: center !important; |
| justify-content: center !important; |
| } |
| #cv { |
| max-width: 100% !important; |
| max-height: 100% !important; |
| } |
| `; |
| doc.head.appendChild(style); |
| } |
|
|
| async function loadSampleRig() { |
| if (!rigAvailable || !elements.frame.contentDocument) { |
| setMessage("Anime2.5DRigがまだ利用できません。", true); |
| return; |
| } |
|
|
| try { |
| const doc = elements.frame.contentDocument; |
| const sampleButton = [...doc.querySelectorAll("button")].find((button) => |
| button.textContent?.includes("sample.psd") |
| ); |
|
|
| if (sampleButton) { |
| sampleButton.click(); |
| setMessage("Anime2.5DRigのsample.psdを読み込んでいます。"); |
| return; |
| } |
|
|
| const fileInput = doc.querySelector('input[type="file"]'); |
| if (!fileInput) throw new Error("PSDファイル入力が見つかりません"); |
|
|
| const sampleUrl = new URL("sample.psd", elements.frame.contentWindow.location.href); |
| const response = await fetch(sampleUrl); |
| if (!response.ok) throw new Error(`sample.psdの取得に失敗しました (${response.status})`); |
| const file = new File([await response.blob()], "sample.psd", { type: "image/vnd.adobe.photoshop" }); |
| const transfer = new DataTransfer(); |
| transfer.items.add(file); |
| fileInput.files = transfer.files; |
| fileInput.dispatchEvent(new Event("change", { bubbles: true })); |
| setMessage("/Anime2.5DRig/sample.psdを読み込みました。"); |
| } catch (error) { |
| setMessage(`サンプルキャラクターを読み込めませんでした: ${error.message}`, true); |
| } |
| } |
|
|
| function updateRigMouth(level) { |
| if (!rigMouthInput || !rigMouthInput.isConnected) { |
| rigMouthInput = findRigMouthInput(); |
| } |
| if (!rigMouthInput) return; |
| const min = Number(rigMouthInput.min || 0); |
| const max = Number(rigMouthInput.max || 1); |
| rigMouthInput.value = String(min + (max - min) * level); |
| rigMouthInput.dispatchEvent(new Event("input", { bubbles: true })); |
| } |
|
|
| function renderAudioLevel() { |
| if (!analyser || !samples) return; |
| analyser.getFloatTimeDomainData(samples); |
| let energy = 0; |
| for (const sample of samples) energy += sample * sample; |
| const rms = Math.sqrt(energy / samples.length); |
| const sensitivity = Number(elements.sensitivity.value); |
| const gated = Math.max(0, rms - 0.008); |
| const target = Math.min(1, gated * 18 * sensitivity); |
| const smoothing = target > smoothedLevel ? 0.42 : 0.14; |
| smoothedLevel += (target - smoothedLevel) * smoothing; |
|
|
| elements.meter.value = smoothedLevel; |
| elements.value.value = smoothedLevel.toFixed(2); |
| updateRigMouth(smoothedLevel); |
| animationId = requestAnimationFrame(renderAudioLevel); |
| } |
|
|
| async function startShare() { |
| if (!navigator.mediaDevices?.getDisplayMedia) { |
| setMessage("このブラウザは画面共有APIに対応していません。デスクトップ版Chromium系ブラウザで試してください。", true); |
| return; |
| } |
|
|
| try { |
| displayStream = await navigator.mediaDevices.getDisplayMedia({ |
| video: { frameRate: { ideal: 30, max: 30 } }, |
| audio: true, |
| preferCurrentTab: false, |
| selfBrowserSurface: "exclude", |
| surfaceSwitching: "include", |
| }); |
|
|
| const videoTrack = displayStream.getVideoTracks()[0]; |
| const audioTrack = displayStream.getAudioTracks()[0]; |
| elements.video.srcObject = displayStream; |
| elements.video.style.display = "block"; |
| elements.empty.style.display = "none"; |
| elements.shareStatus.textContent = videoTrack?.label || "共有中"; |
| elements.start.disabled = true; |
| elements.stop.disabled = false; |
| elements.toggleScreen.disabled = false; |
|
|
| videoTrack?.addEventListener("ended", stopShare, { once: true }); |
|
|
| if (!audioTrack) { |
| elements.audioStatus.textContent = "取得できませんでした"; |
| setMessage("音声トラックがありません。共有選択画面でChatGPTのタブを選び、「タブの音声を共有」を有効にしてください。", true); |
| return; |
| } |
|
|
| elements.audioStatus.textContent = audioTrack.label || "取得済み"; |
| audioContext = new AudioContext(); |
| await audioContext.resume(); |
| const audioOnlyStream = new MediaStream([audioTrack]); |
| const source = audioContext.createMediaStreamSource(audioOnlyStream); |
| analyser = audioContext.createAnalyser(); |
| analyser.fftSize = 1024; |
| analyser.smoothingTimeConstant = 0; |
| samples = new Float32Array(analyser.fftSize); |
| source.connect(analyser); |
| setMessage("音声トラックを取得しました。ChatGPTに応答させ、推定口開度が動くことを確認してください。"); |
| renderAudioLevel(); |
| } catch (error) { |
| if (error.name === "NotAllowedError") { |
| setMessage("画面共有がキャンセルされました。", false); |
| } else { |
| setMessage(`画面共有を開始できませんでした: ${error.message}`, true); |
| } |
| } |
| } |
|
|
| function stopShare() { |
| if (animationId) cancelAnimationFrame(animationId); |
| animationId = null; |
| analyser = null; |
| samples = null; |
| smoothedLevel = 0; |
| updateRigMouth(0); |
| elements.meter.value = 0; |
| elements.value.value = "0.00"; |
|
|
| if (displayStream) { |
| for (const track of displayStream.getTracks()) track.stop(); |
| } |
| displayStream = null; |
| elements.video.srcObject = null; |
| elements.video.style.display = "none"; |
| elements.empty.style.display = "grid"; |
|
|
| if (audioContext) audioContext.close().catch(() => {}); |
| audioContext = null; |
| elements.shareStatus.textContent = "停止済み"; |
| elements.audioStatus.textContent = "停止済み"; |
| elements.start.disabled = false; |
| elements.stop.disabled = true; |
| elements.toggleScreen.disabled = true; |
| elements.toggleScreen.textContent = "共有画面を隠す"; |
| } |
|
|
| elements.start.addEventListener("click", startShare); |
| elements.stop.addEventListener("click", stopShare); |
| elements.loadSample.addEventListener("click", loadSampleRig); |
| elements.toggleRig.addEventListener("click", () => { |
| rigControlsVisible = !rigControlsVisible; |
| applyRigDisplayMode(); |
| elements.toggleRig.textContent = rigControlsVisible ? "設定UIを隠す" : "設定UIを表示"; |
| }); |
| elements.toggleScreen.addEventListener("click", () => { |
| const hidden = elements.video.style.display !== "none"; |
| elements.video.style.display = hidden ? "none" : "block"; |
| elements.empty.style.display = "none"; |
| elements.toggleScreen.textContent = hidden ? "共有画面を表示" : "共有画面を隠す"; |
| }); |
| elements.openChatGPT.addEventListener("click", () => { |
| window.open("https://chatgpt.com/", "_blank", "noopener,noreferrer"); |
| }); |
| elements.frame.addEventListener("load", () => { |
| rigMouthInput = findRigMouthInput(); |
| elements.rigStatus.textContent = rigMouthInput ? "利用可能・口制御検出" : "利用可能・口制御未検出"; |
| loadSampleRig(); |
| applyRigDisplayMode(); |
| }); |
| window.addEventListener("beforeunload", stopShare); |
|
|
| loadRigStatus(); |
|
|