GPT-Voice / app /static /app.js
mcuo's picture
Upload 5 files
8a61d56 verified
Raw
History Blame Contribute Delete
10.4 kB
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();