Ai_Assistant/client/_archive/app-MOBILE.js
2026-05-24 13:31:30 +02:00

723 lines
26 KiB
JavaScript

// app.js (rewritten) - persistent audio + single-gesture unlock + VRM integration
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
import {
createVRMAnimationClip,
VRMAnimationLoaderPlugin,
VRMLookAtQuaternionProxy,
} from "@pixiv/three-vrm-animation";
import { VRM_PATH, WS_URL } from "./config.js";
import { loadVRM } from "./vrmLoader.js";
import { AudioManager } from "./audioManager.js";
import { AnimationManager } from "./animationManager.js";
import { loadMixamoAnimation } from "./loadMixamoAnimation.js";
import { showSubtitleStreaming } from "./subtitles.js";
import { initWebcam, takePictureAndUpload } from "./webcam.js";
import { connectWS, initUI } from "./ui.js";
import { initVRMClickDetector } from "./vrmClickDetector.js";
// ---- small helper ------------------------------------------------
function ensureAbsoluteUrl(url) {
try {
new URL(url);
return url;
} catch (e) {
if (!url) return url;
if (url.startsWith("/")) return `${location.origin}${url}`;
return `${location.origin}/${url}`;
}
}
// ---- trimAnimationClip (same as yours, kept) ---------------------
function trimAnimationClip(clip, startTime, endTime) {
startTime = Math.max(0, startTime || 0);
const fullDuration = clip.duration;
endTime =
typeof endTime === "number" && endTime >= 0
? Math.min(fullDuration, endTime)
: fullDuration;
if (endTime <= startTime) {
console.warn("trimAnimationClip: invalid range", {
startTime,
endTime,
duration: fullDuration,
});
return null;
}
const newTracks = [];
for (const track of clip.tracks) {
const times = track.times;
const values = track.values;
const stride = track.getValueSize();
const keptTimes = [];
const keptValues = [];
for (let i = 0; i < times.length; i++) {
const t = times[i];
if (t >= startTime && t <= endTime) {
keptTimes.push(t - startTime);
const baseIndex = i * stride;
for (let s = 0; s < stride; s++)
keptValues.push(values[baseIndex + s]);
}
}
if (keptTimes.length > 0) {
const TimesArray = Float32Array;
const ValuesArray = Float32Array;
const newTimes = new TimesArray(keptTimes);
const newValues = new ValuesArray(keptValues);
let NewTrack;
try {
NewTrack = new track.constructor(
track.name,
newTimes,
newValues,
track.getInterpolation
? track.getInterpolation()
: undefined,
);
} catch (err) {
NewTrack = new track.constructor(
track.name,
newTimes,
newValues,
);
}
newTracks.push(NewTrack);
}
}
const newDuration = endTime - startTime;
const newName = `${clip.name || "vrma_clip"}_trimmed_${startTime.toFixed(3)}-${endTime.toFixed(3)}`;
return new THREE.AnimationClip(newName, newDuration, newTracks);
}
// ---- connect UI early to avoid race issues ------------------------
function handleServerMessage(msg) {
/* kept for compatibility; actual handling below */
}
connectWS(handleServerMessage);
initUI();
// ---- webcam init (await) -----------------------------------------
await initWebcam();
// ---- globals -----------------------------------------------------
let currentMixer = null;
let vrm = null;
let renderer = null;
let scene = null;
let camera = null;
let controls = null;
let animationMgr = null;
const clock = new THREE.Clock();
let currentVrm = null;
let currentAction = null;
// ---- PlaybackController (single-file, persistent) ----------------
class PlaybackController {
constructor(audioMgr) {
this.audioMgr = audioMgr;
this._inited = false;
this._unlocked = false;
this.el = null; // persistent audio element
this._analyserAttached = false;
}
initPersistent() {
if (this._inited) return;
this._inited = true;
// create or reuse audio element and attach to audioMgr
if (!this.audioMgr.audioElement) {
const a = document.createElement("audio");
a.crossOrigin = "anonymous";
a.preload = "auto";
a.playsInline = true;
a.setAttribute("playsinline", "");
a.setAttribute("webkit-playsinline", "");
a.style.display = "none";
document.body.appendChild(a);
this.audioMgr.audioElement = a;
}
this.el = this.audioMgr.audioElement;
// try to create audioContext & analyser if audioMgr doesn't already have them
try {
if (!this.audioMgr.audioContext) {
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) {
this.audioMgr.audioContext = new AC();
// keep it suspended until user gesture
}
}
// if we have context but not analyser, create one and hook element
if (this.audioMgr.audioContext && !this.audioMgr.analyser) {
try {
const src =
this.audioMgr.audioContext.createMediaElementSource(
this.el,
);
const analyser =
this.audioMgr.audioContext.createAnalyser();
analyser.fftSize = 2048;
src.connect(analyser);
analyser.connect(this.audioMgr.audioContext.destination);
this.audioMgr.analyser = analyser;
this.audioMgr.timeDomainData = new Uint8Array(
analyser.fftSize,
);
this.audioMgr.freqData = new Uint8Array(
analyser.frequencyBinCount,
);
this._analyserAttached = true;
} catch (e) {
// some browsers won't allow createMediaElementSource until context resumed/gesture; we'll retry later
console.warn("Could not attach analyser now:", e);
}
}
// visibility resume helper
document.addEventListener("visibilitychange", async () => {
if (
document.visibilityState === "visible" &&
this.audioMgr.audioContext &&
this.audioMgr.audioContext.state === "suspended"
) {
try {
await this.audioMgr.audioContext.resume();
console.log(
"🔁 resumed audio context on visibilitychange",
);
} catch (e) {}
}
});
} catch (e) {
console.warn("PlaybackController init error:", e);
}
}
// Unlock ONCE (call from user gesture). Returns true on success.
async unlockOnce() {
// If already unlocked, return true
if (this._unlocked) return true;
this.initPersistent();
// 1) Try to resume / create AudioContext
let ctx = null;
try {
if (!this.audioMgr.audioContext) {
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) {
this.audioMgr.audioContext = new AC();
console.log("🔧 created AudioContext (unlock)");
}
}
ctx = this.audioMgr.audioContext;
if (ctx && ctx.state === "suspended") {
try {
await ctx.resume();
} catch (e) {
console.warn("resume() failed:", e);
}
}
} catch (e) {
console.warn("AudioContext creation/resume failed:", e);
}
// 2) Prefer AudioContext silent buffer (works on many browsers)
try {
if (ctx && ctx.state === "running") {
const sampleRate = ctx.sampleRate || 44100;
const length = Math.max(1, Math.floor(sampleRate * 0.01)); // tiny
const buffer = ctx.createBuffer(1, length, sampleRate);
const src = ctx.createBufferSource();
src.buffer = buffer;
src.connect(ctx.destination);
src.start(0);
await new Promise((res) => setTimeout(res, 40));
try {
src.stop();
} catch (e) {}
this._unlocked = true;
// ensure analyser is attached now if not already
this._tryAttachAnalyser();
console.log("🔓 unlocked via AudioContext silent buffer");
return true;
}
} catch (e) {
console.warn("silent buffer unlock failed:", e);
}
// 3) Fallback: muted play/pause on persistent element (wait for playing)
try {
const el = this.el;
if (!el) throw new Error("no audio element");
const hadSrc = !!el.src;
if (!hadSrc) {
// tiny silent data URI to allow play event
el.src =
"data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YQAAAAA=";
}
el.muted = true;
el.playsInline = true;
el.setAttribute("playsinline", "");
el.setAttribute("webkit-playsinline", "");
// create promise for 'playing' or timeout
const p = new Promise((resolve, reject) => {
let done = false;
const onPlaying = () => {
if (!done) {
done = true;
cleanup();
resolve(true);
}
};
const onError = () => {
if (!done) {
done = true;
cleanup();
reject(new Error("audio error"));
}
};
const timeoutId = setTimeout(() => {
if (!done) {
done = true;
cleanup();
reject(new Error("timeout"));
}
}, 1200);
function cleanup() {
el.removeEventListener("playing", onPlaying);
el.removeEventListener("error", onError);
clearTimeout(timeoutId);
}
el.addEventListener("playing", onPlaying);
el.addEventListener("error", onError);
// try play (may throw)
try {
const prom = el.play();
if (prom && prom.catch) prom.catch(() => {}); // swallow immediate rejection, rely on events
} catch (err) {
cleanup();
reject(err);
}
});
await p;
// pause but keep src for future playback
try {
el.pause();
el.currentTime = 0;
} catch (e) {}
el.muted = false;
this._unlocked = true;
this._tryAttachAnalyser();
console.log("🔓 unlocked via muted audio element fallback");
return true;
} catch (e) {
console.warn("muted element fallback failed:", e);
}
console.warn("unlockOnce: could not unlock audio on this gesture");
return false;
}
_tryAttachAnalyser() {
try {
if (
this.audioMgr.audioContext &&
this.el &&
!this.audioMgr.analyser
) {
try {
const src =
this.audioMgr.audioContext.createMediaElementSource(
this.el,
);
const analyser =
this.audioMgr.audioContext.createAnalyser();
analyser.fftSize = 2048;
src.connect(analyser);
analyser.connect(this.audioMgr.audioContext.destination);
this.audioMgr.analyser = analyser;
this.audioMgr.timeDomainData = new Uint8Array(
analyser.fftSize,
);
this.audioMgr.freqData = new Uint8Array(
analyser.frequencyBinCount,
);
this._analyserAttached = true;
console.log("🎛️ analyser attached to persistent element");
} catch (e) {
// might fail in some browsers until after resume; ignore
console.warn(
"attachAnalyser failed (will retry later):",
e,
);
}
}
} catch (e) {
console.warn("error in _tryAttachAnalyser:", e);
}
}
// play url (reuses same element)
async playAudioUrl(url) {
if (!url) return false;
this.initPersistent();
// Ensure unlocked before playing
if (!this._unlocked) {
console.warn(
"playAudioUrl: audio not unlocked yet. Call unlockOnce via a user gesture (tap/keydown).",
);
// we'll still try to auto-unlock (may fail)
try {
await this.unlockOnce();
} catch (e) {}
}
const abs = ensureAbsoluteUrl(url);
const el = this.el;
// stop any current playback and reset
try {
el.pause();
el.currentTime = 0;
} catch (e) {}
// set src only if changed to avoid reload thrash
if (!el.src || el.src !== abs) {
el.src = abs;
try {
el.load();
} catch (e) {}
}
// ensure AudioContext running
try {
if (
this.audioMgr.audioContext &&
this.audioMgr.audioContext.state === "suspended"
) {
await this.audioMgr.audioContext.resume();
}
} catch (e) {
/* ignore */
}
// play
try {
await el.play();
// ensure analyser available for lip sync (try attach if missing)
this._tryAttachAnalyser();
// if audioMgr has analyzeAudio method, call it (no harm)
if (typeof this.audioMgr.analyzeAudio === "function") {
try {
await this.audioMgr.analyzeAudio();
} catch (e) {}
}
console.log("▶️ play started", abs);
return true;
} catch (err) {
console.warn(
"play() blocked, attempting muted-first fallback:",
err,
);
}
// muted-first fallback
try {
el.muted = true;
await el.play();
await new Promise((r) => setTimeout(r, 80));
el.muted = false;
this._tryAttachAnalyser();
if (typeof this.audioMgr.analyzeAudio === "function")
try {
await this.audioMgr.analyzeAudio();
} catch (e) {}
console.log("▶️ play started via muted-first path", abs);
return true;
} catch (err) {
console.warn("muted-first failed:", err);
}
// transient fallback (rare)
try {
const tmp = new Audio(abs);
tmp.playsInline = true;
tmp.crossOrigin = "anonymous";
tmp.setAttribute("playsinline", "");
tmp.setAttribute("webkit-playsinline", "");
document.body.appendChild(tmp);
await tmp.play();
el.src = abs;
try {
el.load();
} catch (e) {}
tmp.pause();
tmp.remove();
this._tryAttachAnalyser();
if (typeof this.audioMgr.analyzeAudio === "function")
try {
await this.audioMgr.analyzeAudio();
} catch (e) {}
console.log("▶️ transient played", abs);
return true;
} catch (err) {
console.warn("transient fallback failed:", err);
}
console.error("All playback strategies failed for", abs);
return false;
}
}
// ---- main IIFE --------------------------------------------------
(async () => {
// renderer, camera, scene
renderer = new THREE.WebGLRenderer({ alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(
30,
window.innerWidth / window.innerHeight,
0.1,
20,
);
camera.position.set(0, 1, 0.9);
controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.1, 0);
controls.update();
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.position.set(3, 15, -5);
scene.add(dirLight);
scene.add(new THREE.AmbientLight(0xffffff, 2.1));
// load VRM
const vrmData = await loadVRM(VRM_PATH, scene);
vrm = vrmData.vrm;
const loader = vrmData.loader;
currentMixer = new THREE.AnimationMixer(vrm.scene);
// init click detector (make clicks unlock audio too)
initVRMClickDetector(renderer, camera, vrm, (region, partName) => {
try {
if (window._playback) window._playback.unlockOnce();
} catch (e) {}
console.log("📩 VRM clicked region:", region, partName);
});
// create managers
const audioMgr = new AudioManager(vrm);
animationMgr = new AnimationManager(
vrm,
audioMgr,
renderer,
scene,
camera,
controls,
);
// expose for debugging
window.audioMgr = audioMgr;
window.animationMgr = animationMgr;
// create playback controller and attach a one-time unlock listener (user gesture)
window._playback = new PlaybackController(audioMgr);
const onFirstGesture = async () => {
try {
await window._playback.unlockOnce();
} catch (e) {
console.warn("unlockOnce direct error:", e);
}
// remove listeners after first unlock
window.removeEventListener("pointerdown", onFirstGesture, {
capture: true,
});
window.removeEventListener("touchend", onFirstGesture, {
capture: true,
});
window.removeEventListener("keydown", onFirstGesture, {
capture: true,
});
};
window.addEventListener("pointerdown", onFirstGesture, { capture: true });
window.addEventListener("touchend", onFirstGesture, { capture: true });
window.addEventListener("keydown", onFirstGesture, { capture: true });
// start animation loop
clock.start();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
if (currentMixer) currentMixer.update(delta);
if (animationMgr) animationMgr.update(delta);
if (vrm) vrm.update(delta);
renderer.render(scene, camera);
controls.update();
}
animate();
// websocket handling
const ws = new WebSocket(WS_URL);
ws.onopen = () => {
console.log("WS connected");
};
ws.onerror = (err) => console.error("WS error", err);
ws.onmessage = async ({ data }) => {
let msg;
try {
msg = JSON.parse(data);
} catch {
return;
}
console.log("WS msg:", msg);
if (msg.type === "start_animation") {
const {
audio_path,
audio_text = "",
audio_duraction = 0,
expression = "neutral",
} = msg;
audioMgr.setExpression(expression);
try {
// ensure unlocked (best-effort) and play using single persistent element
try {
await window._playback.unlockOnce();
} catch (e) {
console.warn("unlockOnce thrown:", e);
}
const ok = await window._playback.playAudioUrl(audio_path);
if (!ok)
console.warn("playback failed (animation will still run)");
animationMgr.play();
// optional subtitles (in-browser)
// showSubtitleStreaming(audio_text, audio_duraction, 'letter');
} catch (e) {
console.error("Failed to start audio/animation:", e);
}
}
if (msg.type === "start_vrma") {
const {
animation_url,
play_once = false,
crop_start = 0.0,
crop_end = 0.0,
} = msg;
try {
const gltfVrma = await loader.loadAsync(animation_url);
const vrmAnimation = gltfVrma.userData.vrmAnimations[0];
let clip = createVRMAnimationClip(vrmAnimation, vrm);
const startTime = Math.max(0, parseFloat(crop_start) || 0);
const endTime = Math.max(
0,
clip.duration - (parseFloat(crop_end) || 0),
);
if (startTime > 0 || (parseFloat(crop_end) || 0) > 0) {
const trimmed = trimAnimationClip(clip, startTime, endTime);
if (trimmed) clip = trimmed;
else console.warn("VRMA trim returned null");
}
animationMgr.setVRMAPlaying(true);
if (!currentMixer)
currentMixer = new THREE.AnimationMixer(vrm.scene);
const newAction = currentMixer.clipAction(clip);
if (play_once) {
newAction.setLoop(THREE.LoopOnce, 0);
newAction.clampWhenFinished = true;
newAction.enabled = true;
} else {
newAction.setLoop(THREE.LoopRepeat, Infinity);
newAction.clampWhenFinished = false;
}
newAction.reset();
newAction.play();
if (currentAction && currentAction !== newAction)
currentAction.crossFadeTo(newAction, 0.5, false);
if (currentMixer._vrmaFinishedListener) {
try {
currentMixer.removeEventListener(
"finished",
currentMixer._vrmaFinishedListener,
);
} catch (e) {}
currentMixer._vrmaFinishedListener = null;
}
const onFinished = (e) => {
if (e.action === newAction) {
animationMgr.setVRMAPlaying(false);
if (play_once)
try {
newAction.stop();
} catch (e) {}
if (
currentMixer &&
currentMixer._vrmaFinishedListener
) {
currentMixer.removeEventListener(
"finished",
currentMixer._vrmaFinishedListener,
);
currentMixer._vrmaFinishedListener = null;
}
}
};
currentMixer._vrmaFinishedListener = onFinished;
currentMixer.addEventListener("finished", onFinished);
currentAction = newAction;
} catch (err) {
console.error("Failed to load VRMA animation:", err);
}
}
if (msg.type === "start_mixamo") {
const { animation_url } = msg;
try {
currentVrm = vrm;
const clip = await loadMixamoAnimation(
animation_url,
currentVrm,
);
const newAction = currentMixer.clipAction(clip);
newAction.reset().play();
if (currentAction && currentAction !== newAction)
currentAction.crossFadeTo(newAction, 0.5, false);
currentAction = newAction;
} catch (err) {
console.error("Failed to load MIXAMO animation:", err);
}
}
if (msg.type === "take_picture") {
console.log("📸 Received command to take picture from server.");
await takePictureAndUpload();
}
};
// resize handler
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
})(); // end IIFE