MacBook pro commited on
Commit
eba025d
·
1 Parent(s): 8728a8f

Refine avatar diagnostics and prune legacy assets

Browse files
.github/copilot-instructions.md CHANGED
@@ -1,24 +1,71 @@
1
  Prime Directive:
2
- Deliver production-ready, systemic solutions to root causes. Prioritize core utility and absolute system integrity. There is zero tolerance for surface patches, brittle fixes, or non-functional code.
3
- Mandatory Protocol:
4
- Map the System: Before acting, map all relevant logic flows, data transformations, and dependencies. Identify all side effects.
5
- Isolate Root Cause: Diagnose the fundamental issue with code-based evidence. Ensure the fix is systemic and permanent.
6
- Align with Utility: Every change must advance the project's core objective. Reject low-impact optimizations.
7
- Implementation Mandates:
8
- Code Integrity: All code must be robust, generalizable, and directly executable. Prohibit all hardcoding, duplicated functionality, and placeholder logic.
9
- Quality & Security: Enforce static typing, descriptive naming, and strict linting. Validate all I/O, eliminate unsafe calls, and add regression guards.
10
- Testing: Test coverage must target both the symptom and its root cause. The full test suite must pass without warnings.
11
- Execution Workflow:
12
- Analyze system flow.
13
- Confirm root cause.
14
- Plan solution.
15
- Implement the robust fix.
16
- Validate with all tests.
17
- Document systemic insights.
18
-
19
- Project: Implements an AI avatar by streaming a user's local audio and video to a Hugging Face GPU server for immediate processing. In the cloud, the system performs simultaneous generative face swapping—animating a source image's identity with the user's live motion—and real-time voice conversion, which morphs the user's speech to a target profile while preserving the original prosody. The fully synchronized audio-visual output is then streamed back to the local machine, functioning as an integrated virtual camera and microphone for seamless use in communication platforms like Zoom and WhatsApp.
20
-
21
- Operational instructions:
22
- - All implementations must be architected for the huggingface space located at https://huggingface.co/spaces/Islamckennon/mirage
23
- - After every change, push to github and huggingface, then await user feedback for next steps.
24
- - All code must be archhitected towards project real-world functionality only.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- const vid = els.remoteVideo; const overlay = els.avatarOverlay;
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 (overlay){
208
- if (blackSampleConsecutive >= BLACK_CONSECUTIVE_LIMIT){
209
- overlay.style.opacity = 1;
210
- overlay.innerHTML = '<span>Receiving black/placeholder frames... (pipeline warming or no source)</span>';
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);