Spaces:
Paused
Paused
MacBook pro
commited on
Commit
·
eba025d
1
Parent(s):
8728a8f
Refine avatar diagnostics and prune legacy assets
Browse files- .github/copilot-instructions.md +70 -23
- static/README.static.md +8 -0
- static/app.js +0 -490
- static/index.html +1 -0
- static/webrtc_client.js +0 -4
- static/webrtc_enterprise.js +76 -9
- static/worklet.js +0 -87
.github/copilot-instructions.md
CHANGED
|
@@ -1,24 +1,71 @@
|
|
| 1 |
Prime Directive:
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
Prime Directive:
|
| 2 |
+
|
| 3 |
+
Ship production code. Core utility only. Zero tolerance for patches or broken code.
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
MANDATORY Protocol (Non-negotiable):
|
| 8 |
+
|
| 9 |
+
1. Map all existing system flows, dependencies, side effects BEFORE coding
|
| 10 |
+
|
| 11 |
+
2. Diagnose root cause with code evidence - fix must be systemic
|
| 12 |
+
|
| 13 |
+
3. Every change MUST advance project's core objective
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
Implementation Rules:
|
| 18 |
+
|
| 19 |
+
- Code: Robust, generalizable, executable. NO hardcoding/duplication/placeholders
|
| 20 |
+
|
| 21 |
+
- Quality: Static typing, descriptive names, validate ALL I/O, eliminate unsafe calls
|
| 22 |
+
|
| 23 |
+
- Testing: Cover symptom AND root cause. Full suite passes clean
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
Issue Resolution Protocol:
|
| 28 |
+
|
| 29 |
+
- Use error logs + user feedback to isolate exact failure point
|
| 30 |
+
|
| 31 |
+
- Trace execution path from failure backwards to root cause
|
| 32 |
+
|
| 33 |
+
- Fix ONLY the identified issue - no speculative changes
|
| 34 |
+
|
| 35 |
+
- Verify fix resolves original problem without side effects
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
Operational Simplicity:
|
| 40 |
+
|
| 41 |
+
- DEFAULT to simple solutions - complexity requires justification
|
| 42 |
+
|
| 43 |
+
- New features MUST prove real-world utility for THIS project
|
| 44 |
+
|
| 45 |
+
- Reject abstractions that don't directly serve avatar streaming
|
| 46 |
+
|
| 47 |
+
- If implementation > 50 lines, question if simpler path exists
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
Workflow (ENFORCE):
|
| 52 |
+
|
| 53 |
+
1. Analyze → 2. Diagnose → 3. Plan → 4. Implement → 5. Test → 6. Document
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
Project:
|
| 58 |
+
|
| 59 |
+
AI avatar: Stream local A/V → HuggingFace Spacce withh a10 GPU → Realtime face-swap + voice conversion → Stream back as virtual camera/mic for Zoom/WhatsApp.
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
Operations:
|
| 64 |
+
|
| 65 |
+
- Target: https://huggingface.co/spaces/Islamckennon/mirage
|
| 66 |
+
|
| 67 |
+
- Push GitHub/HuggingFace after EVERY change
|
| 68 |
+
|
| 69 |
+
- Await user feedback before proceeding
|
| 70 |
+
|
| 71 |
+
- Production functionality ONLY - no demos/experiments
|
static/README.static.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Static Assets
|
| 2 |
+
|
| 3 |
+
Only the enterprise WebRTC client is served in production.
|
| 4 |
+
|
| 5 |
+
- `index.html` – main UI shell
|
| 6 |
+
- `webrtc_enterprise.js` – sole runtime script
|
| 7 |
+
|
| 8 |
+
Legacy bundles (`app.js`, `webrtc_prod.js`, `worklet.js`) were removed intentionally.
|
static/app.js
DELETED
|
@@ -1,490 +0,0 @@
|
|
| 1 |
-
/* DEPRECATED (dev WebSocket client). Removed for production. Use webrtc_prod.js */
|
| 2 |
-
// This file intentionally contains no executable code in production deployments.
|
| 3 |
-
// It remains only to avoid broken references from older pages; index.html does not load it.
|
| 4 |
-
export {};
|
| 5 |
-
|
| 6 |
-
// Globals
|
| 7 |
-
let audioWs = null;
|
| 8 |
-
let videoWs = null;
|
| 9 |
-
let audioContext = null;
|
| 10 |
-
let processorNode = null;
|
| 11 |
-
let playerNode = null;
|
| 12 |
-
let lastVideoSentTs = 0;
|
| 13 |
-
let remoteImageURL = null;
|
| 14 |
-
let isRunning = false;
|
| 15 |
-
let pipelineInitialized = false;
|
| 16 |
-
let referenceSet = false;
|
| 17 |
-
let virtualCameraStream = null;
|
| 18 |
-
let metricsInterval = null;
|
| 19 |
-
|
| 20 |
-
// Configuration
|
| 21 |
-
const videoMaxFps = 20; // Increased for real-time avatar
|
| 22 |
-
const videoFrameIntervalMs = 1000 / videoMaxFps;
|
| 23 |
-
|
| 24 |
-
// DOM elements
|
| 25 |
-
const LOG_EL = document.getElementById('log');
|
| 26 |
-
const INIT_BTN = document.getElementById('initBtn');
|
| 27 |
-
const START_BTN = document.getElementById('startBtn');
|
| 28 |
-
const STOP_BTN = document.getElementById('stopBtn');
|
| 29 |
-
const LOCAL_VID = document.getElementById('localVid');
|
| 30 |
-
const REMOTE_VID_IMG = document.getElementById('remoteVid');
|
| 31 |
-
const REMOTE_AUDIO = document.getElementById('remoteAudio');
|
| 32 |
-
const STATUS_DIV = document.getElementById('statusDiv');
|
| 33 |
-
const REFERENCE_INPUT = document.getElementById('referenceInput');
|
| 34 |
-
const VIRTUAL_CAM_BTN = document.getElementById('virtualCamBtn');
|
| 35 |
-
const VIRTUAL_CANVAS = document.getElementById('virtualCanvas');
|
| 36 |
-
|
| 37 |
-
function log(msg) {
|
| 38 |
-
const ts = new Date().toISOString().split('T')[1].replace('Z','');
|
| 39 |
-
LOG_EL.textContent += `[${ts}] ${msg}\n`;
|
| 40 |
-
LOG_EL.scrollTop = LOG_EL.scrollHeight;
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
-
function showStatus(message, type = 'info') {
|
| 44 |
-
STATUS_DIV.innerHTML = `<div class="status ${type}">${message}</div>`;
|
| 45 |
-
setTimeout(() => STATUS_DIV.innerHTML = '', 5000);
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
function wsURL(path) {
|
| 49 |
-
const proto = (location.protocol === 'https:') ? 'wss:' : 'ws:';
|
| 50 |
-
return `${proto}//${location.host}${path}`;
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
// Initialize AI Pipeline
|
| 54 |
-
async function initializePipeline() {
|
| 55 |
-
INIT_BTN.disabled = true;
|
| 56 |
-
INIT_BTN.textContent = 'Initializing...';
|
| 57 |
-
|
| 58 |
-
try {
|
| 59 |
-
log('Initializing AI pipeline...');
|
| 60 |
-
const response = await fetch('/initialize', { method: 'POST' });
|
| 61 |
-
const result = await response.json();
|
| 62 |
-
|
| 63 |
-
if (result.status === 'success' || result.status === 'already_initialized') {
|
| 64 |
-
pipelineInitialized = true;
|
| 65 |
-
showStatus('AI pipeline initialized successfully!', 'success');
|
| 66 |
-
log('AI pipeline ready');
|
| 67 |
-
|
| 68 |
-
// Enable controls
|
| 69 |
-
START_BTN.disabled = false;
|
| 70 |
-
REFERENCE_INPUT.disabled = false;
|
| 71 |
-
|
| 72 |
-
// Start metrics updates
|
| 73 |
-
startMetricsUpdates();
|
| 74 |
-
} else {
|
| 75 |
-
showStatus(`Initialization failed: ${result.message}`, 'error');
|
| 76 |
-
log(`Pipeline init failed: ${result.message}`);
|
| 77 |
-
}
|
| 78 |
-
} catch (error) {
|
| 79 |
-
showStatus(`Initialization error: ${error.message}`, 'error');
|
| 80 |
-
log(`Init error: ${error}`);
|
| 81 |
-
} finally {
|
| 82 |
-
INIT_BTN.disabled = false;
|
| 83 |
-
INIT_BTN.textContent = 'Initialize AI Pipeline';
|
| 84 |
-
}
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
// Handle reference image upload
|
| 88 |
-
async function handleReferenceUpload(event) {
|
| 89 |
-
const file = event.target.files[0];
|
| 90 |
-
if (!file) return;
|
| 91 |
-
|
| 92 |
-
log('Uploading reference image...');
|
| 93 |
-
|
| 94 |
-
try {
|
| 95 |
-
const formData = new FormData();
|
| 96 |
-
formData.append('file', file);
|
| 97 |
-
|
| 98 |
-
const response = await fetch('/set_reference', {
|
| 99 |
-
method: 'POST',
|
| 100 |
-
body: formData
|
| 101 |
-
});
|
| 102 |
-
|
| 103 |
-
const result = await response.json();
|
| 104 |
-
|
| 105 |
-
if (result.status === 'success') {
|
| 106 |
-
referenceSet = true;
|
| 107 |
-
showStatus('Reference image set successfully!', 'success');
|
| 108 |
-
log('Reference image configured');
|
| 109 |
-
VIRTUAL_CAM_BTN.disabled = false;
|
| 110 |
-
} else {
|
| 111 |
-
showStatus(`Reference setup failed: ${result.message}`, 'error');
|
| 112 |
-
log(`Reference error: ${result.message}`);
|
| 113 |
-
}
|
| 114 |
-
} catch (error) {
|
| 115 |
-
showStatus(`Upload error: ${error.message}`, 'error');
|
| 116 |
-
log(`Reference upload error: ${error}`);
|
| 117 |
-
}
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
async function setupAudio(stream) {
|
| 121 |
-
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
| 122 |
-
if (audioContext.state === 'suspended') {
|
| 123 |
-
try { await audioContext.resume(); } catch (e) { log('AudioContext resume failed'); }
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
// Worklet loading
|
| 127 |
-
try {
|
| 128 |
-
await audioContext.audioWorklet.addModule('/static/worklet.js');
|
| 129 |
-
} catch (e) {
|
| 130 |
-
log('Failed to load worklet.js - audio processing disabled.');
|
| 131 |
-
console.error(e);
|
| 132 |
-
return;
|
| 133 |
-
}
|
| 134 |
-
|
| 135 |
-
// Enhanced chunk configuration for real-time processing
|
| 136 |
-
const chunkMs = 160; // Keep at 160ms for balance between latency and quality
|
| 137 |
-
const samplesPerChunk = Math.round(audioContext.sampleRate * (chunkMs / 1000));
|
| 138 |
-
|
| 139 |
-
log(`Audio chunk config: sampleRate=${audioContext.sampleRate}Hz chunkMs=${chunkMs}ms samplesPerChunk=${samplesPerChunk}`);
|
| 140 |
-
|
| 141 |
-
processorNode = new AudioWorkletNode(audioContext, 'pcm-chunker', {
|
| 142 |
-
processorOptions: { samplesPerChunk }
|
| 143 |
-
});
|
| 144 |
-
playerNode = new AudioWorkletNode(audioContext, 'pcm-player');
|
| 145 |
-
|
| 146 |
-
// Capture mic
|
| 147 |
-
const source = audioContext.createMediaStreamSource(stream);
|
| 148 |
-
source.connect(processorNode);
|
| 149 |
-
|
| 150 |
-
// Keep worklet active
|
| 151 |
-
const gain = audioContext.createGain();
|
| 152 |
-
gain.gain.value = 0;
|
| 153 |
-
processorNode.connect(gain).connect(audioContext.destination);
|
| 154 |
-
|
| 155 |
-
processorNode.port.onmessage = (event) => {
|
| 156 |
-
if (!audioWs || audioWs.readyState !== WebSocket.OPEN) return;
|
| 157 |
-
const ab = event.data;
|
| 158 |
-
if (ab instanceof ArrayBuffer) audioWs.send(ab);
|
| 159 |
-
};
|
| 160 |
-
|
| 161 |
-
// Connect playback node
|
| 162 |
-
playerNode.connect(audioContext.destination);
|
| 163 |
-
log('Audio nodes ready (enhanced for AI processing)');
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
let _rxChunks = 0;
|
| 167 |
-
function setupAudioWebSocket() {
|
| 168 |
-
audioWs = new WebSocket(wsURL('/audio'));
|
| 169 |
-
audioWs.binaryType = 'arraybuffer';
|
| 170 |
-
audioWs.onopen = () => log('Audio WebSocket connected');
|
| 171 |
-
audioWs.onclose = () => log('Audio WebSocket disconnected');
|
| 172 |
-
audioWs.onerror = (e) => log('Audio WebSocket error');
|
| 173 |
-
audioWs.onmessage = (evt) => {
|
| 174 |
-
if (!(evt.data instanceof ArrayBuffer)) return;
|
| 175 |
-
|
| 176 |
-
const src = evt.data;
|
| 177 |
-
const copyBuf = src.slice(0);
|
| 178 |
-
|
| 179 |
-
// Amplitude analysis for voice activity detection
|
| 180 |
-
const view = new Int16Array(src);
|
| 181 |
-
let min = 32767, max = -32768;
|
| 182 |
-
for (let i = 0; i < view.length; i++) {
|
| 183 |
-
const v = view[i];
|
| 184 |
-
if (v < min) min = v;
|
| 185 |
-
if (v > max) max = v;
|
| 186 |
-
}
|
| 187 |
-
|
| 188 |
-
// Forward to player
|
| 189 |
-
if (playerNode) playerNode.port.postMessage(copyBuf, [copyBuf]);
|
| 190 |
-
|
| 191 |
-
_rxChunks++;
|
| 192 |
-
if ((_rxChunks % 30) === 0) { // Reduced logging frequency
|
| 193 |
-
log(`Audio processed: ${_rxChunks} chunks, amp:[${min},${max}]`);
|
| 194 |
-
}
|
| 195 |
-
};
|
| 196 |
-
}
|
| 197 |
-
|
| 198 |
-
async function setupVideo(stream) {
|
| 199 |
-
const track = stream.getVideoTracks()[0];
|
| 200 |
-
if (!track) {
|
| 201 |
-
log('No video track found');
|
| 202 |
-
return;
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
const processor = new MediaStreamTrackProcessor({ track });
|
| 206 |
-
const reader = processor.readable.getReader();
|
| 207 |
-
|
| 208 |
-
const canvas = document.createElement('canvas');
|
| 209 |
-
canvas.width = 512; // Increased resolution for AI processing
|
| 210 |
-
canvas.height = 512;
|
| 211 |
-
const ctx = canvas.getContext('2d');
|
| 212 |
-
|
| 213 |
-
async function readLoop() {
|
| 214 |
-
try {
|
| 215 |
-
const { value: frame, done } = await reader.read();
|
| 216 |
-
if (done) return;
|
| 217 |
-
|
| 218 |
-
const now = performance.now();
|
| 219 |
-
const elapsed = now - lastVideoSentTs;
|
| 220 |
-
const needSend = elapsed >= videoFrameIntervalMs;
|
| 221 |
-
|
| 222 |
-
if (needSend && frame) {
|
| 223 |
-
try {
|
| 224 |
-
// Draw frame with improved quality
|
| 225 |
-
if ('displayWidth' in frame && 'displayHeight' in frame) {
|
| 226 |
-
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
|
| 227 |
-
} else {
|
| 228 |
-
const bmp = await createImageBitmap(frame);
|
| 229 |
-
ctx.drawImage(bmp, 0, 0, canvas.width, canvas.height);
|
| 230 |
-
bmp.close && bmp.close();
|
| 231 |
-
}
|
| 232 |
-
|
| 233 |
-
// Send to AI pipeline with higher quality
|
| 234 |
-
await new Promise((res, rej) => {
|
| 235 |
-
canvas.toBlob((blob) => {
|
| 236 |
-
if (!blob) return res();
|
| 237 |
-
blob.arrayBuffer().then((ab) => {
|
| 238 |
-
if (videoWs && videoWs.readyState === WebSocket.OPEN) {
|
| 239 |
-
videoWs.send(ab);
|
| 240 |
-
}
|
| 241 |
-
res();
|
| 242 |
-
}).catch(rej);
|
| 243 |
-
}, 'image/jpeg', 0.8); // Higher quality for AI processing
|
| 244 |
-
});
|
| 245 |
-
|
| 246 |
-
lastVideoSentTs = now;
|
| 247 |
-
} catch (err) {
|
| 248 |
-
log('Video frame processing error');
|
| 249 |
-
console.error(err);
|
| 250 |
-
}
|
| 251 |
-
}
|
| 252 |
-
|
| 253 |
-
frame.close && frame.close();
|
| 254 |
-
readLoop();
|
| 255 |
-
} catch (err) {
|
| 256 |
-
log('Video read loop error');
|
| 257 |
-
console.error(err);
|
| 258 |
-
}
|
| 259 |
-
}
|
| 260 |
-
readLoop();
|
| 261 |
-
}
|
| 262 |
-
|
| 263 |
-
function setupVideoWebSocket() {
|
| 264 |
-
videoWs = new WebSocket(wsURL('/video'));
|
| 265 |
-
videoWs.binaryType = 'arraybuffer';
|
| 266 |
-
videoWs.onopen = () => log('Video WebSocket connected');
|
| 267 |
-
videoWs.onclose = () => log('Video WebSocket disconnected');
|
| 268 |
-
videoWs.onerror = () => log('Video WebSocket error');
|
| 269 |
-
videoWs.onmessage = (evt) => {
|
| 270 |
-
if (!(evt.data instanceof ArrayBuffer)) return;
|
| 271 |
-
|
| 272 |
-
// Display AI-processed video
|
| 273 |
-
const blob = new Blob([evt.data], { type: 'image/jpeg' });
|
| 274 |
-
if (remoteImageURL) URL.revokeObjectURL(remoteImageURL);
|
| 275 |
-
remoteImageURL = URL.createObjectURL(blob);
|
| 276 |
-
REMOTE_VID_IMG.src = remoteImageURL;
|
| 277 |
-
|
| 278 |
-
// Update virtual camera if enabled
|
| 279 |
-
updateVirtualCamera(evt.data);
|
| 280 |
-
};
|
| 281 |
-
}
|
| 282 |
-
|
| 283 |
-
// Virtual Camera Support
|
| 284 |
-
function updateVirtualCamera(imageData) {
|
| 285 |
-
if (!virtualCameraStream) return;
|
| 286 |
-
|
| 287 |
-
try {
|
| 288 |
-
// Create image from received data
|
| 289 |
-
const blob = new Blob([imageData], { type: 'image/jpeg' });
|
| 290 |
-
const img = new Image();
|
| 291 |
-
|
| 292 |
-
img.onload = () => {
|
| 293 |
-
// Draw to virtual canvas
|
| 294 |
-
const ctx = VIRTUAL_CANVAS.getContext('2d');
|
| 295 |
-
VIRTUAL_CANVAS.width = 512;
|
| 296 |
-
VIRTUAL_CANVAS.height = 512;
|
| 297 |
-
ctx.drawImage(img, 0, 0, 512, 512);
|
| 298 |
-
};
|
| 299 |
-
|
| 300 |
-
img.src = URL.createObjectURL(blob);
|
| 301 |
-
} catch (error) {
|
| 302 |
-
console.error('Virtual camera update error:', error);
|
| 303 |
-
}
|
| 304 |
-
}
|
| 305 |
-
|
| 306 |
-
async function enableVirtualCamera() {
|
| 307 |
-
try {
|
| 308 |
-
if (!VIRTUAL_CANVAS.captureStream) {
|
| 309 |
-
showStatus('Virtual camera not supported in this browser', 'error');
|
| 310 |
-
return;
|
| 311 |
-
}
|
| 312 |
-
|
| 313 |
-
// Create virtual camera stream from canvas
|
| 314 |
-
virtualCameraStream = VIRTUAL_CANVAS.captureStream(30);
|
| 315 |
-
|
| 316 |
-
// Try to create a virtual camera device (browser-dependent)
|
| 317 |
-
if (navigator.mediaDevices.getDisplayMedia) {
|
| 318 |
-
log('Virtual camera enabled - canvas stream ready');
|
| 319 |
-
showStatus('Virtual camera enabled! Use canvas stream in video apps.', 'success');
|
| 320 |
-
VIRTUAL_CAM_BTN.textContent = 'Virtual Camera Active';
|
| 321 |
-
VIRTUAL_CAM_BTN.disabled = true;
|
| 322 |
-
} else {
|
| 323 |
-
showStatus('Virtual camera API not available', 'error');
|
| 324 |
-
}
|
| 325 |
-
} catch (error) {
|
| 326 |
-
showStatus(`Virtual camera error: ${error.message}`, 'error');
|
| 327 |
-
log(`Virtual camera error: ${error}`);
|
| 328 |
-
}
|
| 329 |
-
}
|
| 330 |
-
|
| 331 |
-
// Metrics and Performance Monitoring
|
| 332 |
-
function startMetricsUpdates() {
|
| 333 |
-
if (metricsInterval) clearInterval(metricsInterval);
|
| 334 |
-
|
| 335 |
-
metricsInterval = setInterval(async () => {
|
| 336 |
-
try {
|
| 337 |
-
const response = await fetch('/pipeline_status');
|
| 338 |
-
const data = await response.json();
|
| 339 |
-
|
| 340 |
-
if (data.initialized && data.stats) {
|
| 341 |
-
const stats = data.stats;
|
| 342 |
-
|
| 343 |
-
document.getElementById('fpsValue').textContent = stats.video_fps?.toFixed(1) || '0';
|
| 344 |
-
document.getElementById('latencyValue').textContent =
|
| 345 |
-
Math.round(stats.avg_video_latency_ms || 0) + 'ms';
|
| 346 |
-
document.getElementById('gpuValue').textContent =
|
| 347 |
-
stats.gpu_memory_used?.toFixed(1) + 'GB' || 'N/A';
|
| 348 |
-
document.getElementById('statusValue').textContent =
|
| 349 |
-
stats.models_loaded ? 'Active' : 'Loading';
|
| 350 |
-
}
|
| 351 |
-
} catch (error) {
|
| 352 |
-
console.error('Metrics update error:', error);
|
| 353 |
-
}
|
| 354 |
-
}, 2000); // Update every 2 seconds
|
| 355 |
-
}
|
| 356 |
-
|
| 357 |
-
async function start() {
|
| 358 |
-
if (!pipelineInitialized) {
|
| 359 |
-
showStatus('Please initialize the AI pipeline first', 'error');
|
| 360 |
-
return;
|
| 361 |
-
}
|
| 362 |
-
|
| 363 |
-
START_BTN.disabled = true;
|
| 364 |
-
START_BTN.textContent = 'Starting...';
|
| 365 |
-
|
| 366 |
-
log('Requesting media access...');
|
| 367 |
-
|
| 368 |
-
try {
|
| 369 |
-
const stream = await navigator.mediaDevices.getUserMedia({
|
| 370 |
-
audio: true,
|
| 371 |
-
video: {
|
| 372 |
-
width: 640,
|
| 373 |
-
height: 480,
|
| 374 |
-
frameRate: 30
|
| 375 |
-
}
|
| 376 |
-
});
|
| 377 |
-
|
| 378 |
-
LOCAL_VID.srcObject = stream;
|
| 379 |
-
log('Media access granted');
|
| 380 |
-
|
| 381 |
-
// Setup WebSocket connections
|
| 382 |
-
setupAudioWebSocket();
|
| 383 |
-
setupVideoWebSocket();
|
| 384 |
-
|
| 385 |
-
// Setup audio and video processing
|
| 386 |
-
await setupAudio(stream);
|
| 387 |
-
await setupVideo(stream);
|
| 388 |
-
|
| 389 |
-
isRunning = true;
|
| 390 |
-
START_BTN.style.display = 'none';
|
| 391 |
-
STOP_BTN.disabled = false;
|
| 392 |
-
STOP_BTN.style.display = 'inline-block';
|
| 393 |
-
|
| 394 |
-
log(`Real-time AI avatar started: ${videoMaxFps} fps, 160ms audio chunks`);
|
| 395 |
-
showStatus('AI Avatar system is now running!', 'success');
|
| 396 |
-
|
| 397 |
-
} catch (error) {
|
| 398 |
-
showStatus(`Media access failed: ${error.message}`, 'error');
|
| 399 |
-
log(`getUserMedia failed: ${error}`);
|
| 400 |
-
START_BTN.disabled = false;
|
| 401 |
-
START_BTN.textContent = 'Start Capture';
|
| 402 |
-
}
|
| 403 |
-
}
|
| 404 |
-
|
| 405 |
-
function stop() {
|
| 406 |
-
log('Stopping AI avatar system...');
|
| 407 |
-
|
| 408 |
-
// Close WebSocket connections
|
| 409 |
-
if (audioWs) {
|
| 410 |
-
audioWs.close();
|
| 411 |
-
audioWs = null;
|
| 412 |
-
}
|
| 413 |
-
if (videoWs) {
|
| 414 |
-
videoWs.close();
|
| 415 |
-
videoWs = null;
|
| 416 |
-
}
|
| 417 |
-
|
| 418 |
-
// Stop media tracks
|
| 419 |
-
if (LOCAL_VID.srcObject) {
|
| 420 |
-
LOCAL_VID.srcObject.getTracks().forEach(track => track.stop());
|
| 421 |
-
LOCAL_VID.srcObject = null;
|
| 422 |
-
}
|
| 423 |
-
|
| 424 |
-
// Reset audio context
|
| 425 |
-
if (audioContext) {
|
| 426 |
-
audioContext.close();
|
| 427 |
-
audioContext = null;
|
| 428 |
-
}
|
| 429 |
-
|
| 430 |
-
// Reset UI
|
| 431 |
-
isRunning = false;
|
| 432 |
-
START_BTN.disabled = false;
|
| 433 |
-
START_BTN.textContent = 'Start Capture';
|
| 434 |
-
START_BTN.style.display = 'inline-block';
|
| 435 |
-
STOP_BTN.disabled = true;
|
| 436 |
-
STOP_BTN.style.display = 'none';
|
| 437 |
-
|
| 438 |
-
log('System stopped');
|
| 439 |
-
showStatus('AI Avatar system stopped', 'info');
|
| 440 |
-
}
|
| 441 |
-
|
| 442 |
-
// Event Listeners
|
| 443 |
-
INIT_BTN.addEventListener('click', initializePipeline);
|
| 444 |
-
START_BTN.addEventListener('click', start);
|
| 445 |
-
STOP_BTN.addEventListener('click', stop);
|
| 446 |
-
REFERENCE_INPUT.addEventListener('change', handleReferenceUpload);
|
| 447 |
-
VIRTUAL_CAM_BTN.addEventListener('click', enableVirtualCamera);
|
| 448 |
-
|
| 449 |
-
// Debug functions
|
| 450 |
-
function testTone(seconds = 1, freq = 440) {
|
| 451 |
-
if (!audioContext || !playerNode) {
|
| 452 |
-
log('testTone: audio not ready');
|
| 453 |
-
return;
|
| 454 |
-
}
|
| 455 |
-
|
| 456 |
-
const sampleRate = audioContext.sampleRate;
|
| 457 |
-
const total = Math.floor(sampleRate * seconds);
|
| 458 |
-
const int16 = new Int16Array(total);
|
| 459 |
-
|
| 460 |
-
for (let i = 0; i < total; i++) {
|
| 461 |
-
const s = Math.sin(2 * Math.PI * freq * (i / sampleRate));
|
| 462 |
-
int16[i] = s * 32767;
|
| 463 |
-
}
|
| 464 |
-
|
| 465 |
-
const chunk = Math.floor(sampleRate * 0.25);
|
| 466 |
-
for (let off = 0; off < int16.length; off += chunk) {
|
| 467 |
-
const view = int16.subarray(off, Math.min(off + chunk, int16.length));
|
| 468 |
-
const copy = new Int16Array(view.length);
|
| 469 |
-
copy.set(view);
|
| 470 |
-
playerNode.port.postMessage(copy.buffer, [copy.buffer]);
|
| 471 |
-
}
|
| 472 |
-
|
| 473 |
-
log(`Test tone ${freq}Hz for ${seconds}s injected`);
|
| 474 |
-
}
|
| 475 |
-
|
| 476 |
-
// Global API for debugging
|
| 477 |
-
window.__mirage = {
|
| 478 |
-
start,
|
| 479 |
-
stop,
|
| 480 |
-
initializePipeline,
|
| 481 |
-
audioWs: () => audioWs,
|
| 482 |
-
videoWs: () => videoWs,
|
| 483 |
-
testTone,
|
| 484 |
-
pipelineInitialized: () => pipelineInitialized,
|
| 485 |
-
referenceSet: () => referenceSet
|
| 486 |
-
};
|
| 487 |
-
|
| 488 |
-
// Auto-initialize on load for development
|
| 489 |
-
log('Mirage Real-time AI Avatar System loaded');
|
| 490 |
-
log('Click "Initialize AI Pipeline" to begin setup');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/index.html
CHANGED
|
@@ -524,6 +524,7 @@
|
|
| 524 |
<span class="stage-step" data-stage="offer-sent">Offer</span>
|
| 525 |
<span class="stage-step" data-stage="ice-gathering">ICE</span>
|
| 526 |
<span class="stage-step" data-stage="answer-received">Answer</span>
|
|
|
|
| 527 |
<span class="stage-step" data-stage="remote-media">Video</span>
|
| 528 |
<span class="stage-step" data-stage="connected">Ready</span>
|
| 529 |
</div>
|
|
|
|
| 524 |
<span class="stage-step" data-stage="offer-sent">Offer</span>
|
| 525 |
<span class="stage-step" data-stage="ice-gathering">ICE</span>
|
| 526 |
<span class="stage-step" data-stage="answer-received">Answer</span>
|
| 527 |
+
<span class="stage-step" data-stage="finalizing">Finalize</span>
|
| 528 |
<span class="stage-step" data-stage="remote-media">Video</span>
|
| 529 |
<span class="stage-step" data-stage="connected">Ready</span>
|
| 530 |
</div>
|
static/webrtc_client.js
DELETED
|
@@ -1,4 +0,0 @@
|
|
| 1 |
-
/* Legacy dev WebRTC bootstrap (no-op in production). */
|
| 2 |
-
(function(){
|
| 3 |
-
// intentionally empty
|
| 4 |
-
})();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/webrtc_enterprise.js
CHANGED
|
@@ -152,7 +152,7 @@
|
|
| 152 |
}
|
| 153 |
|
| 154 |
/* ---------------- Stage / Timeline Management ---------------- */
|
| 155 |
-
const stageOrder = ['init','local-media','offer-sent','ice-gathering','answer-received','remote-media','connected'];
|
| 156 |
function setStage(newStage){
|
| 157 |
if(!els.stageTimeline) return;
|
| 158 |
if(!stageOrder.includes(newStage)) return;
|
|
@@ -169,6 +169,36 @@
|
|
| 169 |
}
|
| 170 |
setStage('init');
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
/* --------------- Frame Counter & Black Frame Detection --------------- */
|
| 173 |
let framePollTimer = null;
|
| 174 |
let blackDetectTimer = null;
|
|
@@ -185,12 +215,31 @@
|
|
| 185 |
if (j && j.frames_emitted != null && els.frameCounterDisplay) {
|
| 186 |
els.frameCounterDisplay.textContent = 'Frames:' + j.frames_emitted;
|
| 187 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
} catch(_){ }
|
| 189 |
}, 2000);
|
| 190 |
}
|
| 191 |
function startBlackDetection(){
|
| 192 |
if(blackDetectTimer) clearInterval(blackDetectTimer);
|
| 193 |
-
|
| 194 |
if(!vid) return;
|
| 195 |
const canvas = document.createElement('canvas');
|
| 196 |
const ctx = canvas.getContext('2d');
|
|
@@ -204,13 +253,10 @@
|
|
| 204 |
for (let i=0;i<data.length;i+=4){ sum += (data[i]*0.2126 + data[i+1]*0.7152 + data[i+2]*0.0722); count++; }
|
| 205 |
const avg = sum / count;
|
| 206 |
if (avg < BLACK_THRESHOLD) blackSampleConsecutive++; else blackSampleConsecutive = 0;
|
| 207 |
-
if (
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
} else if (blackSampleConsecutive === 0 && overlay.innerText.includes('black/placeholder')) {
|
| 212 |
-
overlay.style.opacity = 0;
|
| 213 |
-
}
|
| 214 |
}
|
| 215 |
} catch(_){ }
|
| 216 |
}, 1000);
|
|
@@ -488,6 +534,7 @@
|
|
| 488 |
setSystemStatus('connected', 'Avatar stream received');
|
| 489 |
setAvatarStatus('connected', 'Active');
|
| 490 |
setStage('remote-media');
|
|
|
|
| 491 |
|
| 492 |
let stream;
|
| 493 |
if (ev.streams && ev.streams[0]) {
|
|
@@ -534,11 +581,13 @@
|
|
| 534 |
setAvatarStatus('idle', 'Disconnected');
|
| 535 |
if (els.avatarWrapper) els.avatarWrapper.classList.remove('active');
|
| 536 |
};
|
|
|
|
| 537 |
|
| 538 |
tr.onmute = () => {
|
| 539 |
log('video track muted');
|
| 540 |
setAvatarStatus('warning', 'Muted');
|
| 541 |
};
|
|
|
|
| 542 |
|
| 543 |
tr.onunmute = () => {
|
| 544 |
log('video track unmuted');
|
|
@@ -546,16 +595,19 @@
|
|
| 546 |
};
|
| 547 |
|
| 548 |
} else if (tr && tr.kind === 'audio') {
|
|
|
|
| 549 |
setSystemStatus('connected', 'Audio stream received');
|
| 550 |
}
|
| 551 |
} catch(e) {
|
| 552 |
log('ontrack error', e);
|
| 553 |
setAvatarStatus('error', 'Connection Error');
|
|
|
|
| 554 |
}
|
| 555 |
};
|
| 556 |
|
| 557 |
// Data channel setup
|
| 558 |
state.control = state.pc.createDataChannel('control');
|
|
|
|
| 559 |
|
| 560 |
state.control.onopen = () => {
|
| 561 |
setSystemStatus('connected', 'WebRTC connection established');
|
|
@@ -649,6 +701,9 @@
|
|
| 649 |
const answer = await r.json();
|
| 650 |
await state.pc.setRemoteDescription(new RTCSessionDescription(answer));
|
| 651 |
setStage('answer-received');
|
|
|
|
|
|
|
|
|
|
| 652 |
log('WebRTC negotiation complete');
|
| 653 |
|
| 654 |
} catch(e) {
|
|
@@ -657,6 +712,7 @@
|
|
| 657 |
showToast('Failed to establish connection', 'error');
|
| 658 |
state.connecting = false;
|
| 659 |
setButtonLoading(els.connect, false);
|
|
|
|
| 660 |
throw e;
|
| 661 |
}
|
| 662 |
}
|
|
@@ -676,6 +732,16 @@
|
|
| 676 |
clearInterval(state.metricsTimer);
|
| 677 |
state.metricsTimer = null;
|
| 678 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 679 |
|
| 680 |
// Close connections
|
| 681 |
if (state.control) {
|
|
@@ -738,6 +804,7 @@
|
|
| 738 |
els.connect.disabled = false;
|
| 739 |
els.disconnect.disabled = true;
|
| 740 |
setSystemStatus('idle', 'Disconnected');
|
|
|
|
| 741 |
showToast('Connection terminated', 'warning');
|
| 742 |
}
|
| 743 |
|
|
|
|
| 152 |
}
|
| 153 |
|
| 154 |
/* ---------------- Stage / Timeline Management ---------------- */
|
| 155 |
+
const stageOrder = ['init','local-media','offer-sent','ice-gathering','answer-received','finalizing','remote-media','connected'];
|
| 156 |
function setStage(newStage){
|
| 157 |
if(!els.stageTimeline) return;
|
| 158 |
if(!stageOrder.includes(newStage)) return;
|
|
|
|
| 169 |
}
|
| 170 |
setStage('init');
|
| 171 |
|
| 172 |
+
const overlayState = { visible: true, message: 'Avatar feed will appear here', mode: 'idle' };
|
| 173 |
+
function setAvatarOverlay(visible, message, mode){
|
| 174 |
+
const overlay = els.avatarOverlay;
|
| 175 |
+
if(!overlay) return;
|
| 176 |
+
const nextMessage = (message !== undefined && message !== null) ? message : overlayState.message;
|
| 177 |
+
const nextMode = mode || overlayState.mode;
|
| 178 |
+
if(nextMessage !== overlayState.message){
|
| 179 |
+
overlay.innerHTML = `<span>${nextMessage}</span>`;
|
| 180 |
+
overlayState.message = nextMessage;
|
| 181 |
+
}
|
| 182 |
+
if(nextMode !== overlayState.mode){
|
| 183 |
+
overlay.dataset.state = nextMode;
|
| 184 |
+
overlayState.mode = nextMode;
|
| 185 |
+
}
|
| 186 |
+
if(overlayState.visible !== visible){
|
| 187 |
+
overlay.style.opacity = visible ? 1 : 0;
|
| 188 |
+
overlayState.visible = visible;
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
function showAvatarOverlay(message, mode){
|
| 192 |
+
setAvatarOverlay(true, message, mode);
|
| 193 |
+
}
|
| 194 |
+
function hideAvatarOverlay(force=false){
|
| 195 |
+
if(!force && !['waiting','warming','black','info'].includes(overlayState.mode)){
|
| 196 |
+
return;
|
| 197 |
+
}
|
| 198 |
+
setAvatarOverlay(false, null, 'active');
|
| 199 |
+
}
|
| 200 |
+
setAvatarOverlay(true, overlayState.message, overlayState.mode);
|
| 201 |
+
|
| 202 |
/* --------------- Frame Counter & Black Frame Detection --------------- */
|
| 203 |
let framePollTimer = null;
|
| 204 |
let blackDetectTimer = null;
|
|
|
|
| 215 |
if (j && j.frames_emitted != null && els.frameCounterDisplay) {
|
| 216 |
els.frameCounterDisplay.textContent = 'Frames:' + j.frames_emitted;
|
| 217 |
}
|
| 218 |
+
if(!j || j.active === false){
|
| 219 |
+
setAvatarOverlay(true, 'Avatar feed will appear here', 'idle');
|
| 220 |
+
return;
|
| 221 |
+
}
|
| 222 |
+
if(overlayState.mode === 'error'){
|
| 223 |
+
return;
|
| 224 |
+
}
|
| 225 |
+
if(j.source_bound === false){
|
| 226 |
+
showAvatarOverlay('Awaiting camera stream…', 'waiting');
|
| 227 |
+
} else if(j.placeholder_active){
|
| 228 |
+
showAvatarOverlay('Avatar pipeline warming up…', 'warming');
|
| 229 |
+
} else if((j.real_frames || 0) > 0 && overlayState.mode !== 'black'){
|
| 230 |
+
hideAvatarOverlay();
|
| 231 |
+
}
|
| 232 |
+
if(typeof j.luma_last === 'number' && j.luma_last <= 5 && (j.real_frames || 0) > 0){
|
| 233 |
+
showAvatarOverlay('Frames detected but extremely dark', 'info');
|
| 234 |
+
} else if (overlayState.mode === 'info' && (j.real_frames || 0) > 0) {
|
| 235 |
+
hideAvatarOverlay(true);
|
| 236 |
+
}
|
| 237 |
} catch(_){ }
|
| 238 |
}, 2000);
|
| 239 |
}
|
| 240 |
function startBlackDetection(){
|
| 241 |
if(blackDetectTimer) clearInterval(blackDetectTimer);
|
| 242 |
+
const vid = els.remoteVideo;
|
| 243 |
if(!vid) return;
|
| 244 |
const canvas = document.createElement('canvas');
|
| 245 |
const ctx = canvas.getContext('2d');
|
|
|
|
| 253 |
for (let i=0;i<data.length;i+=4){ sum += (data[i]*0.2126 + data[i+1]*0.7152 + data[i+2]*0.0722); count++; }
|
| 254 |
const avg = sum / count;
|
| 255 |
if (avg < BLACK_THRESHOLD) blackSampleConsecutive++; else blackSampleConsecutive = 0;
|
| 256 |
+
if (blackSampleConsecutive >= BLACK_CONSECUTIVE_LIMIT){
|
| 257 |
+
showAvatarOverlay('Receiving black frames… (pipeline warming or no source)', 'black');
|
| 258 |
+
} else if (blackSampleConsecutive === 0 && overlayState.mode === 'black') {
|
| 259 |
+
hideAvatarOverlay();
|
|
|
|
|
|
|
|
|
|
| 260 |
}
|
| 261 |
} catch(_){ }
|
| 262 |
}, 1000);
|
|
|
|
| 534 |
setSystemStatus('connected', 'Avatar stream received');
|
| 535 |
setAvatarStatus('connected', 'Active');
|
| 536 |
setStage('remote-media');
|
| 537 |
+
showAvatarOverlay('Waiting for avatar frames…', 'waiting');
|
| 538 |
|
| 539 |
let stream;
|
| 540 |
if (ev.streams && ev.streams[0]) {
|
|
|
|
| 581 |
setAvatarStatus('idle', 'Disconnected');
|
| 582 |
if (els.avatarWrapper) els.avatarWrapper.classList.remove('active');
|
| 583 |
};
|
| 584 |
+
hideAvatarOverlay();
|
| 585 |
|
| 586 |
tr.onmute = () => {
|
| 587 |
log('video track muted');
|
| 588 |
setAvatarStatus('warning', 'Muted');
|
| 589 |
};
|
| 590 |
+
showAvatarOverlay('Avatar stream error', 'error');
|
| 591 |
|
| 592 |
tr.onunmute = () => {
|
| 593 |
log('video track unmuted');
|
|
|
|
| 595 |
};
|
| 596 |
|
| 597 |
} else if (tr && tr.kind === 'audio') {
|
| 598 |
+
setAvatarOverlay(true, 'Avatar feed will appear here', 'idle');
|
| 599 |
setSystemStatus('connected', 'Audio stream received');
|
| 600 |
}
|
| 601 |
} catch(e) {
|
| 602 |
log('ontrack error', e);
|
| 603 |
setAvatarStatus('error', 'Connection Error');
|
| 604 |
+
showAvatarOverlay('Avatar stream muted', 'info');
|
| 605 |
}
|
| 606 |
};
|
| 607 |
|
| 608 |
// Data channel setup
|
| 609 |
state.control = state.pc.createDataChannel('control');
|
| 610 |
+
hideAvatarOverlay();
|
| 611 |
|
| 612 |
state.control.onopen = () => {
|
| 613 |
setSystemStatus('connected', 'WebRTC connection established');
|
|
|
|
| 701 |
const answer = await r.json();
|
| 702 |
await state.pc.setRemoteDescription(new RTCSessionDescription(answer));
|
| 703 |
setStage('answer-received');
|
| 704 |
+
setStage('finalizing');
|
| 705 |
+
setSystemStatus('connecting', 'Finalizing connection...');
|
| 706 |
+
showAvatarOverlay('Preparing avatar stream…', 'waiting');
|
| 707 |
log('WebRTC negotiation complete');
|
| 708 |
|
| 709 |
} catch(e) {
|
|
|
|
| 712 |
showToast('Failed to establish connection', 'error');
|
| 713 |
state.connecting = false;
|
| 714 |
setButtonLoading(els.connect, false);
|
| 715 |
+
setAvatarOverlay(true, 'Avatar feed will appear here', 'idle');
|
| 716 |
throw e;
|
| 717 |
}
|
| 718 |
}
|
|
|
|
| 732 |
clearInterval(state.metricsTimer);
|
| 733 |
state.metricsTimer = null;
|
| 734 |
}
|
| 735 |
+
if (framePollTimer) {
|
| 736 |
+
clearInterval(framePollTimer);
|
| 737 |
+
framePollTimer = null;
|
| 738 |
+
}
|
| 739 |
+
if (blackDetectTimer) {
|
| 740 |
+
clearInterval(blackDetectTimer);
|
| 741 |
+
blackDetectTimer = null;
|
| 742 |
+
}
|
| 743 |
+
blackSampleConsecutive = 0;
|
| 744 |
+
setAvatarOverlay(true, 'Avatar feed will appear here', 'idle');
|
| 745 |
|
| 746 |
// Close connections
|
| 747 |
if (state.control) {
|
|
|
|
| 804 |
els.connect.disabled = false;
|
| 805 |
els.disconnect.disabled = true;
|
| 806 |
setSystemStatus('idle', 'Disconnected');
|
| 807 |
+
setStage('init');
|
| 808 |
showToast('Connection terminated', 'warning');
|
| 809 |
}
|
| 810 |
|
static/worklet.js
DELETED
|
@@ -1,87 +0,0 @@
|
|
| 1 |
-
class PCMChunker extends AudioWorkletProcessor {
|
| 2 |
-
constructor(options) {
|
| 3 |
-
super();
|
| 4 |
-
// samplesPerChunk is injected from main thread (B8 sets 160ms @16kHz = 2560 samples)
|
| 5 |
-
this.samplesPerChunk = (options && options.processorOptions && options.processorOptions.samplesPerChunk) || 16000;
|
| 6 |
-
this.buffer = new Float32Array(this.samplesPerChunk);
|
| 7 |
-
this.offset = 0;
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
process(inputs) {
|
| 11 |
-
const input = inputs[0];
|
| 12 |
-
if (input && input[0]) {
|
| 13 |
-
const data = input[0];
|
| 14 |
-
let i = 0;
|
| 15 |
-
while (i < data.length) {
|
| 16 |
-
const space = this.samplesPerChunk - this.offset;
|
| 17 |
-
const toCopy = Math.min(space, data.length - i);
|
| 18 |
-
this.buffer.set(data.subarray(i, i + toCopy), this.offset);
|
| 19 |
-
this.offset += toCopy;
|
| 20 |
-
i += toCopy;
|
| 21 |
-
if (this.offset >= this.samplesPerChunk) {
|
| 22 |
-
const out = new Int16Array(this.samplesPerChunk);
|
| 23 |
-
for (let j = 0; j < this.samplesPerChunk; j++) {
|
| 24 |
-
let s = this.buffer[j];
|
| 25 |
-
if (s > 1) s = 1; else if (s < -1) s = -1;
|
| 26 |
-
out[j] = s < 0 ? s * 32768 : s * 32767;
|
| 27 |
-
}
|
| 28 |
-
const buf = out.buffer;
|
| 29 |
-
this.port.postMessage(buf, [buf]);
|
| 30 |
-
this.offset = 0;
|
| 31 |
-
}
|
| 32 |
-
}
|
| 33 |
-
}
|
| 34 |
-
return true;
|
| 35 |
-
}
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
registerProcessor('pcm-chunker', PCMChunker);
|
| 39 |
-
|
| 40 |
-
// PCM player pulls Int16 buffers from a queue pushed via port messages and outputs Float32 samples.
|
| 41 |
-
class PCMPlayer extends AudioWorkletProcessor {
|
| 42 |
-
constructor() {
|
| 43 |
-
super();
|
| 44 |
-
this.queue = [];
|
| 45 |
-
this.current = null;
|
| 46 |
-
this.offset = 0;
|
| 47 |
-
this.samplesPerBuffer = 0;
|
| 48 |
-
this.port.onmessage = (e) => {
|
| 49 |
-
const d = e.data;
|
| 50 |
-
if (d instanceof ArrayBuffer) {
|
| 51 |
-
this.queue.push(new Int16Array(d));
|
| 52 |
-
} else if (d instanceof Int16Array) {
|
| 53 |
-
this.queue.push(d);
|
| 54 |
-
}
|
| 55 |
-
};
|
| 56 |
-
}
|
| 57 |
-
process(_inputs, outputs) {
|
| 58 |
-
const output = outputs[0][0];
|
| 59 |
-
if (!output) return true;
|
| 60 |
-
let i = 0;
|
| 61 |
-
while (i < output.length) {
|
| 62 |
-
if (!this.current) {
|
| 63 |
-
this.current = this.queue.shift();
|
| 64 |
-
this.offset = 0;
|
| 65 |
-
if (!this.current) {
|
| 66 |
-
// Fill rest with silence
|
| 67 |
-
while (i < output.length) output[i++] = 0;
|
| 68 |
-
break;
|
| 69 |
-
}
|
| 70 |
-
}
|
| 71 |
-
const remain = this.current.length - this.offset;
|
| 72 |
-
const needed = output.length - i;
|
| 73 |
-
const toCopy = Math.min(remain, needed);
|
| 74 |
-
for (let j = 0; j < toCopy; j++) {
|
| 75 |
-
output[i + j] = this.current[this.offset + j] / 32768;
|
| 76 |
-
}
|
| 77 |
-
i += toCopy;
|
| 78 |
-
this.offset += toCopy;
|
| 79 |
-
if (this.offset >= this.current.length) {
|
| 80 |
-
this.current = null;
|
| 81 |
-
}
|
| 82 |
-
}
|
| 83 |
-
return true;
|
| 84 |
-
}
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
registerProcessor('pcm-player', PCMPlayer);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|