2357 lines
85 KiB
TypeScript
2357 lines
85 KiB
TypeScript
// [TSM.ID].[11031972] — All Rights Reserved. Proprietary & Confidential.
|
||
/* eslint-disable */
|
||
// @ts-nocheck
|
||
import { XCUPulsarCodec } from './xcu-pulsar-codec';
|
||
import { XCUResonanceCodec } from './xcu-resonance-codec';
|
||
|
||
// Frame type constants for XCU Binary Protocol v3
|
||
const FRAME_VIDEO_DELTA = 0;
|
||
const FRAME_VIDEO_KEY = 1;
|
||
const FRAME_AUDIO = 2;
|
||
const FRAME_CONTROL = 3;
|
||
const FRAME_HEARTBEAT = 5;
|
||
const FRAME_LEDGER = 6;
|
||
const FRAME_PULSAR = 10;
|
||
|
||
class XCUWebGLFilter {
|
||
public canvas: HTMLCanvasElement;
|
||
private gl: WebGLRenderingContext;
|
||
private program: WebGLProgram;
|
||
private positionLocation: number;
|
||
private texCoordLocation: number;
|
||
private texture: WebGLTexture;
|
||
private resolutionLocation: WebGLUniformLocation;
|
||
private timeLocation: WebGLUniformLocation;
|
||
private modeLocation: WebGLUniformLocation;
|
||
|
||
constructor(width: number, height: number) {
|
||
this.canvas = document.createElement('canvas');
|
||
this.canvas.width = width;
|
||
this.canvas.height = height;
|
||
const gl = this.canvas.getContext('webgl')!;
|
||
this.gl = gl;
|
||
|
||
const vs = `
|
||
attribute vec2 a_position;
|
||
attribute vec2 a_texCoord;
|
||
varying vec2 v_texCoord;
|
||
void main() {
|
||
gl_Position = vec4(a_position * vec2(1.0, -1.0), 0.0, 1.0);
|
||
v_texCoord = a_texCoord;
|
||
}
|
||
`;
|
||
|
||
const fs = `
|
||
precision mediump float;
|
||
uniform sampler2D u_image;
|
||
uniform vec2 u_resolution;
|
||
uniform float u_time;
|
||
uniform int u_mode;
|
||
varying vec2 v_texCoord;
|
||
|
||
float rand(vec2 co){ return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453); }
|
||
|
||
void main() {
|
||
vec2 uv = v_texCoord;
|
||
vec4 color = texture2D(u_image, uv);
|
||
vec2 px = 1.0 / u_resolution;
|
||
|
||
// Mode 1, 3, 5: Beauty Filter (Fast 5-tap Bilateral)
|
||
if (u_mode == 1 || u_mode == 3 || u_mode == 5) {
|
||
vec4 s1 = texture2D(u_image, uv + vec2(px.x, 0.0));
|
||
vec4 s2 = texture2D(u_image, uv + vec2(-px.x, 0.0));
|
||
vec4 s3 = texture2D(u_image, uv + vec2(0.0, px.y));
|
||
vec4 s4 = texture2D(u_image, uv + vec2(0.0, -px.y));
|
||
|
||
vec4 sum = color;
|
||
float w = 1.0;
|
||
|
||
float d1 = exp(-pow(length(s1.rgb - color.rgb), 2.0) * 50.0); sum += s1 * d1; w += d1;
|
||
float d2 = exp(-pow(length(s2.rgb - color.rgb), 2.0) * 50.0); sum += s2 * d2; w += d2;
|
||
float d3 = exp(-pow(length(s3.rgb - color.rgb), 2.0) * 50.0); sum += s3 * d3; w += d3;
|
||
float d4 = exp(-pow(length(s4.rgb - color.rgb), 2.0) * 50.0); sum += s4 * d4; w += d4;
|
||
|
||
color = sum / w;
|
||
color.rgb = mix(color.rgb, color.rgb * 1.05 + 0.02, 0.5);
|
||
}
|
||
|
||
// Naive Edge Segmentation
|
||
float s00 = length(texture2D(u_image, uv + vec2(-px.x, -px.y)).rgb);
|
||
float s22 = length(texture2D(u_image, uv + vec2(px.x, px.y)).rgb);
|
||
float edge = abs(s00 - s22);
|
||
float distCenter = distance(uv, vec2(0.5, 0.5));
|
||
bool isBg = (distCenter > 0.35 && edge < 0.15);
|
||
|
||
// Mode 2, 3: Hologram BG
|
||
if ((u_mode == 2 || u_mode == 3) && isBg) {
|
||
float m = fract(uv.y * 10.0 - u_time * 2.0 + rand(vec2(uv.x, 0.0)));
|
||
vec3 bg = vec3(0.0, m * 0.5, m * 0.2);
|
||
color.rgb = mix(color.rgb, bg, 0.85);
|
||
}
|
||
|
||
// Mode 4, 5: Bokeh Blur BG (Fast 5-tap box blur)
|
||
if ((u_mode == 4 || u_mode == 5) && isBg) {
|
||
vec4 b1 = texture2D(u_image, uv + vec2(px.x*4.0, 0.0));
|
||
vec4 b2 = texture2D(u_image, uv + vec2(-px.x*4.0, 0.0));
|
||
vec4 b3 = texture2D(u_image, uv + vec2(0.0, px.y*4.0));
|
||
vec4 b4 = texture2D(u_image, uv + vec2(0.0, -px.y*4.0));
|
||
vec4 blurSum = color + b1 + b2 + b3 + b4;
|
||
color.rgb = mix(color.rgb, (blurSum / 5.0).rgb, 0.95);
|
||
}
|
||
|
||
gl_FragColor = color;
|
||
}
|
||
`;
|
||
|
||
const createShader = (type: number, source: string) => {
|
||
const shader = gl.createShader(type)!;
|
||
gl.shaderSource(shader, source);
|
||
gl.compileShader(shader);
|
||
return shader;
|
||
};
|
||
|
||
const vShader = createShader(gl.VERTEX_SHADER, vs);
|
||
const fShader = createShader(gl.FRAGMENT_SHADER, fs);
|
||
this.program = gl.createProgram()!;
|
||
gl.attachShader(this.program, vShader);
|
||
gl.attachShader(this.program, fShader);
|
||
gl.linkProgram(this.program);
|
||
|
||
this.positionLocation = gl.getAttribLocation(this.program, 'a_position');
|
||
this.texCoordLocation = gl.getAttribLocation(this.program, 'a_texCoord');
|
||
this.resolutionLocation = gl.getUniformLocation(this.program, 'u_resolution')!;
|
||
this.timeLocation = gl.getUniformLocation(this.program, 'u_time')!;
|
||
this.modeLocation = gl.getUniformLocation(this.program, 'u_mode')!;
|
||
|
||
const positionBuffer = gl.createBuffer();
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||
-1.0, -1.0, 1.0, -1.0, -1.0, 1.0,
|
||
-1.0, 1.0, 1.0, -1.0, 1.0, 1.0
|
||
]), gl.STATIC_DRAW);
|
||
|
||
const texCoordBuffer = gl.createBuffer();
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||
0.0, 1.0, 1.0, 1.0, 0.0, 0.0,
|
||
0.0, 0.0, 1.0, 1.0, 1.0, 0.0
|
||
]), gl.STATIC_DRAW);
|
||
|
||
this.texture = gl.createTexture()!;
|
||
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||
|
||
gl.useProgram(this.program);
|
||
gl.enableVertexAttribArray(this.positionLocation);
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||
gl.vertexAttribPointer(this.positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||
|
||
gl.enableVertexAttribArray(this.texCoordLocation);
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||
gl.vertexAttribPointer(this.texCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||
}
|
||
|
||
public render(video: HTMLVideoElement, mode: number) {
|
||
const gl = this.gl;
|
||
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
|
||
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
|
||
|
||
gl.useProgram(this.program);
|
||
gl.uniform2f(this.resolutionLocation, gl.canvas.width, gl.canvas.height);
|
||
gl.uniform1f(this.timeLocation, performance.now() / 1000.0);
|
||
gl.uniform1i(this.modeLocation, mode);
|
||
|
||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||
}
|
||
}
|
||
|
||
export class XCUQuantumMatrix {
|
||
private transport: WebTransport | null = null;
|
||
private stream: WebTransportBidirectionalStream | null = null;
|
||
private streamWriter: WritableStreamDefaultWriter | null = null;
|
||
private streamReader: ReadableStreamDefaultReader | null = null;
|
||
private videoDecoders: Map<number, VideoDecoder> = new Map();
|
||
private canvasCtxMap: Map<number, CanvasRenderingContext2D> = new Map();
|
||
private firstKeyFrameReceived: Map<number, boolean> = new Map();
|
||
private videoEncoder: VideoEncoder | null = null;
|
||
private isRunning: boolean = false;
|
||
private natPingInterval: any = null;
|
||
private mediaStream: MediaStream | null = null;
|
||
private ws: WebSocket | null = null;
|
||
private _frameCount: number = 0;
|
||
public onAudioLevel: ((level: number) => void) | null = null;
|
||
private vadAudioCtx: AudioContext | null = null;
|
||
private vadAnalyser: AnalyserNode | null = null;
|
||
private vadInterval: any = null;
|
||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||
public participantRole: "PANELIST" | "AUDIENCE" = "PANELIST";
|
||
public videoEngineMode: "auto" | "canvas" | "webcodecs" = "auto";
|
||
public audioEngineMode: "auto" | "pcm" | "xcu-neural" | "xcu-resonance" = "auto";
|
||
public activeVideoCodec: string = "STANDBY";
|
||
public activeAudioCodec: string = "STANDBY";
|
||
public targetFps: 15 | 30 | 60 = 30; // Default 30fps — user can set via Matrix UI
|
||
public fpsConfirmed60: boolean = false; // AutoPilot: true setelah konfirmasi device support 60fps
|
||
private fpsProbeCount: number = 0; // Counter untuk probe 60fps
|
||
public currentBandwidth: number = 0;
|
||
public displayName: string = ''; // Display name for this participant (email or custom)
|
||
private autoPilotInterval: any = null;
|
||
private isSwappingEncoder: boolean = false;
|
||
private audioEncoder: unknown = null;
|
||
private audioDecoders: Map<number, unknown> = new Map();
|
||
private trackProcessor: unknown = null;
|
||
private trackGenerator: unknown = null;
|
||
private activeCodecStr: string = "avc1.42E01F";
|
||
|
||
public useVirtualBg: number = 0; // 0=off, 2=Hologram, 4=Bokeh
|
||
public useBeautyFilter: boolean = false;
|
||
|
||
private pulsarEncoder: XCUPulsarCodec | null = null;
|
||
private pulsarDecoders: Map<number, XCUPulsarCodec> = new Map();
|
||
private pulsarCanvas: HTMLCanvasElement | null = null;
|
||
private pulsarCtx: CanvasRenderingContext2D | null = null;
|
||
private usePulsarCodec: boolean = false; // Primary codec flag
|
||
|
||
private participantLastSeen: Map<number, number> = new Map();
|
||
private ghostCleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||
private isMicMuted: boolean = false;
|
||
private isEncodingCanvas: boolean = false;
|
||
private reconnectAttempt: number = 0; // Exponential backoff counter
|
||
private maxReconnectDelay: number = 30000; // Max 30s between reconnects
|
||
private adaptiveQuality: number = 0.92; // Smart JPEG quality (0.5-0.95)
|
||
private adaptiveResScale: number = 1.0; // Dynamic resolution scale (0.5-1.0)
|
||
private framesDropped: number = 0; // Frame drop counter for telemetry
|
||
private lastBandwidthEstimate: number = 10; // Mbps from real TX rate
|
||
private audioJitterMap: Map<number, number> = new Map();
|
||
private videoRenderQueue: Map<number, { frame: any, targetTime: number, isWebCodec: boolean }[]> = new Map();
|
||
private videoSyncLoopActive: boolean = false;
|
||
|
||
private startVideoSyncLoop() {
|
||
if (this.videoSyncLoopActive) return;
|
||
this.videoSyncLoopActive = true;
|
||
console.log("[QUANTUM MATRIX] A/V Sync Render Loop Started [TSM.ID].[11031972]");
|
||
|
||
const loop = () => {
|
||
if (!this.videoSyncLoopActive) return;
|
||
requestAnimationFrame(loop);
|
||
const now = performance.now();
|
||
|
||
this.videoRenderQueue.forEach((queue, senderId) => {
|
||
if (queue.length === 0) return;
|
||
|
||
queue.sort((a, b) => a.targetTime - b.targetTime);
|
||
|
||
const ctx = this.canvasCtxMap.get(senderId);
|
||
if (!ctx) {
|
||
queue.forEach(item => {
|
||
if (item.isWebCodec && item.frame.close) {
|
||
try { item.frame.close(); } catch(e) {}
|
||
}
|
||
});
|
||
queue.length = 0;
|
||
return;
|
||
}
|
||
|
||
while (queue.length > 0) {
|
||
const item = queue[0];
|
||
if (now >= item.targetTime) {
|
||
queue.shift();
|
||
|
||
if (now - item.targetTime > 150) {
|
||
if (item.isWebCodec && item.frame.close) {
|
||
try { item.frame.close(); } catch(e) {}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (item.isWebCodec) {
|
||
const vf = item.frame as VideoFrame;
|
||
ctx.canvas.width = vf.displayWidth;
|
||
ctx.canvas.height = vf.displayHeight;
|
||
ctx.drawImage(vf, 0, 0);
|
||
vf.close();
|
||
} else {
|
||
const img = item.frame as HTMLImageElement;
|
||
ctx.canvas.width = img.width;
|
||
ctx.canvas.height = img.height;
|
||
ctx.drawImage(img, 0, 0);
|
||
}
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
};
|
||
requestAnimationFrame(loop);
|
||
}
|
||
|
||
private stopVideoSyncLoop() {
|
||
this.videoSyncLoopActive = false;
|
||
this.videoRenderQueue.forEach((queue) => {
|
||
queue.forEach(item => {
|
||
if (item.isWebCodec && item.frame.close) {
|
||
try { item.frame.close(); } catch(e) {}
|
||
}
|
||
});
|
||
queue.length = 0;
|
||
});
|
||
this.videoRenderQueue.clear();
|
||
}
|
||
|
||
public onRemoteAudio?: (participantId: number, stream: MediaStream) => void;
|
||
public onModuleUnlocked?: (moduleId: number) => void;
|
||
public onQuantumResonance?: (senderId: number, reactionType: string) => void;
|
||
public onSovereignSignal?: (type: string, payload?: any) => void;
|
||
|
||
public isDesktop: boolean = false;
|
||
public pulsarCodec: any = null;
|
||
public resonanceCodec: XCUResonanceCodec = new XCUResonanceCodec();
|
||
// REAL Traffic Counters — NO DUMMY (counts actual ws.send/ws.onmessage bytes)
|
||
public trafficStats = {
|
||
tx: { video: 0, audio: 0, control: 0, total: 0 },
|
||
rx: { video: 0, audio: 0, control: 0, total: 0 },
|
||
rates: {
|
||
txVideo: 0, txAudio: 0, txTotal: 0,
|
||
rxVideo: 0, rxAudio: 0, rxTotal: 0,
|
||
},
|
||
_lastSnapshot: {
|
||
ts: 0,
|
||
txVideo: 0, txAudio: 0, txTotal: 0,
|
||
rxVideo: 0, rxAudio: 0, rxTotal: 0,
|
||
},
|
||
startTime: 0,
|
||
wsState: 'CLOSED' as string,
|
||
};
|
||
private trafficInterval: ReturnType<typeof setInterval> | null = null;
|
||
|
||
public pulsarWasmMemory: WebAssembly.Memory | null = null;
|
||
|
||
public downlinkAudioCtx: AudioContext | null = null;
|
||
public downlinkAudioDest: MediaStreamAudioDestinationNode | null = null;
|
||
private downlinkAudioEl: HTMLAudioElement | null = null;
|
||
|
||
private initDownlinkAudio() {
|
||
if (!this.downlinkAudioCtx) {
|
||
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
|
||
this.downlinkAudioCtx = new AudioContextClass();
|
||
(this.downlinkAudioCtx as any).nextPlayTime = this.downlinkAudioCtx!.currentTime;
|
||
|
||
this.downlinkAudioDest = this.downlinkAudioCtx!.createMediaStreamDestination();
|
||
this.downlinkAudioEl = document.createElement('audio');
|
||
this.downlinkAudioEl.autoplay = true;
|
||
this.downlinkAudioEl.srcObject = this.downlinkAudioDest.stream;
|
||
document.body.appendChild(this.downlinkAudioEl);
|
||
this.downlinkAudioEl.play().catch(e => console.warn("[AEC] Audio auto-play dicekal browser", e));
|
||
console.log("[AEC MATRIX] Web Audio to DOM Pipeline Active");
|
||
}
|
||
}
|
||
private e2eeKey: CryptoKey | null = null;
|
||
private e2eeKeyStr: string = "";
|
||
|
||
public async setE2EEKey(keyStr: string) {
|
||
if (!keyStr || keyStr === "NO_KEY" || keyStr === "none") {
|
||
this.e2eeKey = null;
|
||
this.e2eeKeyStr = "";
|
||
console.log("[QUANTUM MATRIX] E2EE Kriptografi DINONAKTIFKAN.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Derivasi Password ke CryptoKey AES-GCM (PBKDF2) - True Hardware Acceleration
|
||
const enc = new TextEncoder();
|
||
const keyMaterial = await crypto.subtle.importKey(
|
||
"raw",
|
||
enc.encode(keyStr),
|
||
"PBKDF2",
|
||
false,
|
||
["deriveBits", "deriveKey"]
|
||
);
|
||
|
||
// Salt tetap agar sinkron antar perangkat
|
||
const salt = enc.encode("XCOM_ULTRA_QUANTUM_SALT_2026");
|
||
|
||
this.e2eeKey = await crypto.subtle.deriveKey(
|
||
{
|
||
name: "PBKDF2",
|
||
salt: salt,
|
||
iterations: 100000,
|
||
hash: "SHA-256"
|
||
},
|
||
keyMaterial,
|
||
{ name: "AES-GCM", length: 256 },
|
||
false,
|
||
["encrypt", "decrypt"]
|
||
);
|
||
this.e2eeKeyStr = keyStr;
|
||
console.log("[QUANTUM MATRIX] 🛡️ E2EE Kriptografi AKTIF (AES-GCM 256-bit)");
|
||
} catch (_e) {
|
||
console.error("[QUANTUM MATRIX] Gagal merakit E2EE Key:", _e);
|
||
}
|
||
}
|
||
|
||
constructor(
|
||
private roomName: string,
|
||
public participantId: number,
|
||
private jwtToken: string // Entitlement Token
|
||
) {
|
||
this.isDesktop = !/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||
navigator.userAgent
|
||
);
|
||
console.log(`[QUANTUM MATRIX] Node ${this.participantId} Initialized.`);
|
||
|
||
// ANTI 3-SECOND AUDIO BUG: Eagerly create AudioContext so it can be resumed by global click events
|
||
try {
|
||
this.initDownlinkAudio();
|
||
(this.downlinkAudioCtx as any).nextPlayTime = this.downlinkAudioCtx.currentTime;
|
||
} catch (_e) {
|
||
console.warn("AudioContext failed to initialize early", _e);
|
||
}
|
||
}
|
||
|
||
// Callbacks
|
||
public onParticipantJoined: ((id: number) => void) | null = null;
|
||
public onParticipantLeft: ((id: number) => void) | null = null;
|
||
public onParticipantNameReceived: ((id: number, name: string) => void) | null = null;
|
||
public onQuantumDataReceived:
|
||
| ((senderId: number, payload: string) => void)
|
||
| null = null;
|
||
public onLocalStream: ((stream: MediaStream) => void) | null = null;
|
||
public onActiveSpeakerChanged: ((speakerId: number) => void) | null = null;
|
||
public onAudioLevel: ((level: number) => void) | null = null;
|
||
private vadLocalAudioCtx: AudioContext | null = null;
|
||
private uplinkAudioCtx: AudioContext | null = null;
|
||
private uplinkScriptNode: ScriptProcessorNode | null = null;
|
||
private uplinkAudioSource: MediaStreamAudioSourceNode | null = null;
|
||
|
||
public registerCanvas(participantId: number, canvasId: string) {
|
||
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
|
||
if (canvas) {
|
||
const ctx = canvas.getContext("2d");
|
||
if (ctx) this.canvasCtxMap.set(participantId, ctx);
|
||
console.log(
|
||
`[QUANTUM MATRIX] Canvas didaftarkan untuk Partisipan ${participantId}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
|
||
/** Try to find and register canvas by participant ID */
|
||
private tryAutoRegisterCanvas(participantId: number): boolean {
|
||
if (this.canvasCtxMap.has(participantId)) return true;
|
||
const canvasId = `quantum-matrix-${participantId}`;
|
||
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
|
||
if (canvas) {
|
||
const ctx = canvas.getContext("2d");
|
||
if (ctx) {
|
||
this.canvasCtxMap.set(participantId, ctx);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
public async ignite(roomName: string, serverUrl: string = "/xcu-engine", quantumHash?: string) {
|
||
this.isRunning = true;
|
||
this.startVideoSyncLoop();
|
||
console.log("[QUANTUM MATRIX] Ignite v3.0 — WebTransport QUIC Primary + WebSocket Fallback");
|
||
|
||
// Attempt WebTransport QUIC first, then fall back to WebSocket
|
||
const host = window.location.hostname; // mesh.ultramodul.xyz
|
||
const wtUrl = `https://${host}:8443`;
|
||
|
||
let useWebTransport = false;
|
||
|
||
// === WebTransport QUIC (PRIMARY) ===
|
||
if (typeof WebTransport !== 'undefined') {
|
||
try {
|
||
console.log(`[QUANTUM MATRIX] Attempting WebTransport QUIC: ${wtUrl}`);
|
||
|
||
// Fetch server cert hash for serverCertificateHashes (needed for self-signed or pinning)
|
||
let certHashes: any[] | undefined;
|
||
try {
|
||
const certRes = await fetch('/api/v1/system/cert');
|
||
const certData = await certRes.json();
|
||
if (certData.hash) {
|
||
// Convert hex hash to Uint8Array
|
||
const hashBytes = new Uint8Array(certData.hash.match(/.{1,2}/g).map((b: string) => parseInt(b, 16)));
|
||
certHashes = [{ algorithm: 'sha-256', value: hashBytes.buffer }];
|
||
console.log('[QUANTUM MATRIX] Cert hash loaded for WebTransport pinning');
|
||
}
|
||
} catch(e) {
|
||
console.log('[QUANTUM MATRIX] No cert hash, using system trust');
|
||
}
|
||
|
||
const wtOptions: any = {};
|
||
// Only use serverCertificateHashes if available and needed
|
||
// For Let's Encrypt certs, browser trusts them natively
|
||
|
||
const wt = new WebTransport(wtUrl, wtOptions);
|
||
await Promise.race([
|
||
wt.ready,
|
||
new Promise((_, reject) => setTimeout(() => reject(new Error('WebTransport timeout')), 5000))
|
||
]);
|
||
|
||
console.log("[QUANTUM MATRIX] ✅ WebTransport QUIC CONNECTED! Zero Head-of-Line Blocking ACTIVE");
|
||
this.transport = wt;
|
||
this.trafficStats.wsState = 'QUIC';
|
||
useWebTransport = true;
|
||
this.reconnectAttempt = 0; // Reset backoff on success
|
||
|
||
// Setup datagram reader
|
||
const reader = wt.datagrams.readable.getReader();
|
||
const writer = wt.datagrams.writable.getWriter();
|
||
this.streamWriter = writer;
|
||
|
||
// Send registration datagram: [type=99, flags=0, pId_lo, pId_hi, room_len, ...room_bytes]
|
||
const roomBytes = new TextEncoder().encode(roomName);
|
||
const regPayload = new Uint8Array(5 + roomBytes.length);
|
||
regPayload[0] = 99; // CONTROL
|
||
regPayload[1] = 0;
|
||
regPayload[2] = this.participantId & 0xFF;
|
||
regPayload[3] = (this.participantId >> 8) & 0xFF;
|
||
regPayload[4] = roomBytes.length;
|
||
regPayload.set(roomBytes, 5);
|
||
await writer.write(regPayload);
|
||
this.trafficStats.tx.control += regPayload.length; this.trafficStats.tx.total += regPayload.length;
|
||
|
||
// Start NAT Keep-Alive Ping
|
||
this.natPingInterval = setInterval(() => {
|
||
if (this.streamWriter) {
|
||
const ping = new Uint8Array([99, 1]);
|
||
this.streamWriter.write(ping).catch(() => {});
|
||
}
|
||
}, 3000);
|
||
|
||
// Read datagrams in loop
|
||
(async () => {
|
||
try {
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
if (!value || value.byteLength < 4) continue;
|
||
|
||
// REAL RX counter
|
||
const rxLen = value.byteLength;
|
||
this.trafficStats.rx.total += rxLen;
|
||
const frameType = value[0];
|
||
if (frameType === 1 || frameType === 10) this.trafficStats.rx.video += rxLen;
|
||
else if (frameType === 2) this.trafficStats.rx.audio += rxLen;
|
||
else this.trafficStats.rx.control += rxLen;
|
||
|
||
this.handleIncomingFrame(value);
|
||
}
|
||
} catch(e) {
|
||
console.warn('[QUANTUM MATRIX] WebTransport datagram read ended:', e);
|
||
}
|
||
})();
|
||
|
||
// Handle connection close — with exponential backoff
|
||
wt.closed.then(() => {
|
||
console.log('[QUANTUM MATRIX] WebTransport closed');
|
||
this.trafficStats.wsState = 'CLOSED';
|
||
if (this.isRunning) {
|
||
const delay = Math.min(1000 * Math.pow(1.5, this.reconnectAttempt), this.maxReconnectDelay);
|
||
this.reconnectAttempt++;
|
||
console.log(`[QUANTUM MATRIX] Reconnecting WebTransport in ${(delay/1000).toFixed(1)}s (attempt ${this.reconnectAttempt})...`);
|
||
setTimeout(() => this.ignite(roomName, serverUrl, quantumHash), delay);
|
||
}
|
||
}).catch(() => {
|
||
if (this.natPingInterval) clearInterval(this.natPingInterval);
|
||
this.trafficStats.wsState = 'CLOSED';
|
||
if (this.isRunning) {
|
||
const delay = Math.min(1000 * Math.pow(1.5, this.reconnectAttempt), this.maxReconnectDelay);
|
||
this.reconnectAttempt++;
|
||
console.log(`[QUANTUM MATRIX] Reconnecting WebTransport in ${(delay/1000).toFixed(1)}s (attempt ${this.reconnectAttempt})...`);
|
||
setTimeout(() => this.ignite(roomName, serverUrl, quantumHash), delay);
|
||
}
|
||
});
|
||
|
||
wt.closed.then(() => {
|
||
if (this.natPingInterval) clearInterval(this.natPingInterval);
|
||
}).catch(() => {});
|
||
|
||
this.startQuantumAutoPilot();
|
||
this.startTrafficMonitor();
|
||
this.startGhostCleanup();
|
||
// BUG FIX: Send heartbeat IMMEDIATELY on connect for instant visibility
|
||
this.sendPresenceHeartbeat();
|
||
this.startHeartbeatLoop();
|
||
|
||
} catch(e) {
|
||
console.warn(`[QUANTUM MATRIX] WebTransport failed: ${e}. Falling back to WebSocket...`);
|
||
useWebTransport = false;
|
||
}
|
||
}
|
||
|
||
// === WebSocket Fallback (SECONDARY) ===
|
||
if (!useWebTransport) {
|
||
try {
|
||
const wsHost = window.location.host;
|
||
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${wsProto}//${wsHost}/ws/${roomName}`;
|
||
|
||
console.log("[QUANTUM MATRIX] WebSocket Fallback URL:", wsUrl);
|
||
|
||
const ws = new WebSocket(wsUrl);
|
||
ws.binaryType = "arraybuffer";
|
||
this.ws = ws;
|
||
this.trafficStats.wsState = 'WS-CONNECTING';
|
||
|
||
let resolveReady: () => void;
|
||
let rejectReady: (e: unknown) => void;
|
||
const readyPromise = new Promise<void>((res, rej) => {
|
||
resolveReady = res;
|
||
rejectReady = rej;
|
||
});
|
||
|
||
ws.onopen = () => {
|
||
console.log("[QUANTUM MATRIX] WebSocket Fallback CONNECTED (TCP)");
|
||
this.trafficStats.wsState = 'WS-OPEN';
|
||
this.reconnectAttempt = 0; // Reset backoff on success
|
||
ws.send(JSON.stringify({
|
||
type: "qcg_handshake",
|
||
token: this.jwtToken,
|
||
displayName: this.displayName || ''
|
||
}));
|
||
resolveReady!();
|
||
};
|
||
ws.onerror = (e) => {
|
||
console.error("[QUANTUM MATRIX] WebSocket Error:", e);
|
||
this.trafficStats.wsState = 'WS-ERROR';
|
||
rejectReady!(e);
|
||
};
|
||
ws.onclose = () => {
|
||
console.log("[QUANTUM MATRIX] WebSocket Closed");
|
||
this.trafficStats.wsState = 'WS-CLOSED';
|
||
if (this.isRunning) {
|
||
const delay = Math.min(1000 * Math.pow(1.5, this.reconnectAttempt), this.maxReconnectDelay);
|
||
this.reconnectAttempt++;
|
||
console.log(`[QUANTUM MATRIX] Reconnecting WebSocket in ${(delay/1000).toFixed(1)}s (attempt ${this.reconnectAttempt})...`);
|
||
setTimeout(() => this.ignite(roomName, serverUrl, quantumHash), delay);
|
||
}
|
||
};
|
||
ws.onmessage = (event) => {
|
||
if (typeof event.data === "string") {
|
||
try {
|
||
const msg = JSON.parse(event.data);
|
||
if (msg.type === 'participant_list') {
|
||
console.log('[QUANTUM MATRIX] Participant list:', msg.participants);
|
||
}
|
||
} catch(e) {}
|
||
return;
|
||
}
|
||
const data = new Uint8Array(event.data);
|
||
if (data.byteLength < 4) return;
|
||
|
||
// REAL RX counter
|
||
const rxLen = data.byteLength;
|
||
this.trafficStats.rx.total += rxLen;
|
||
const frameType = data[0];
|
||
if (frameType === 1 || frameType === 10) this.trafficStats.rx.video += rxLen;
|
||
else if (frameType === 2) this.trafficStats.rx.audio += rxLen;
|
||
else this.trafficStats.rx.control += rxLen;
|
||
|
||
this.handleIncomingFrame(data);
|
||
};
|
||
|
||
await readyPromise;
|
||
this.startQuantumAutoPilot();
|
||
this.startTrafficMonitor();
|
||
this.startGhostCleanup();
|
||
// BUG FIX: Send heartbeat IMMEDIATELY on connect for instant visibility
|
||
this.sendPresenceHeartbeat();
|
||
this.startHeartbeatLoop();
|
||
|
||
} catch(e) {
|
||
console.error("[QUANTUM MATRIX] Both WebTransport and WebSocket failed:", e);
|
||
this.trafficStats.wsState = 'FAILED';
|
||
}
|
||
}
|
||
|
||
// 3. Start video capture pipeline
|
||
if (this.mediaStream) {
|
||
this.setupVideoCapturePipeline();
|
||
}
|
||
}
|
||
|
||
/** Unified incoming frame handler for both WebTransport and WebSocket */
|
||
private async handleIncomingFrame(data: Uint8Array) {
|
||
if (data.byteLength < 8) return;
|
||
const dataView = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||
const frameType = dataView.getUint8(0);
|
||
const quality = dataView.getUint8(1);
|
||
const senderId = dataView.getUint16(2, true);
|
||
const frameLength = dataView.getUint32(4, true);
|
||
|
||
if (senderId === this.participantId) return; // Skip own frames
|
||
this.participantLastSeen.set(senderId, Date.now());
|
||
|
||
if (data.byteLength < 8 + frameLength) return;
|
||
const payloadData = data.slice(8, 8 + frameLength);
|
||
|
||
if (frameType === 5) { // FRAME_HEARTBEAT
|
||
if (!this.canvasCtxMap.has(senderId)) {
|
||
if (this.onParticipantJoined) this.onParticipantJoined(senderId);
|
||
}
|
||
// Parse display name from heartbeat payload (if present)
|
||
if (payloadData.length > 0) {
|
||
try {
|
||
const nameStr = new TextDecoder().decode(payloadData);
|
||
if (nameStr && nameStr.length > 0 && nameStr.length < 256) {
|
||
const parsed = JSON.parse(nameStr);
|
||
if (parsed.type === 'NAME_ANNOUNCE' && parsed.name) {
|
||
if (this.onParticipantNameReceived) this.onParticipantNameReceived(senderId, parsed.name);
|
||
}
|
||
}
|
||
} catch(_) { /* Not JSON, ignore */ }
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (frameType === 10 || frameType === 1) { // FRAME_PULSAR
|
||
let jpegPayload = payloadData;
|
||
let isDecryptionFailed = false;
|
||
|
||
if (quality === 2) {
|
||
if (this.e2eeKey && payloadData.length > 12) {
|
||
const iv = payloadData.slice(0, 12);
|
||
const cipher = payloadData.slice(12);
|
||
try {
|
||
const plainBuf = await window.crypto.subtle.decrypt(
|
||
{ name: "AES-GCM", iv: iv },
|
||
this.e2eeKey,
|
||
cipher
|
||
);
|
||
jpegPayload = new Uint8Array(plainBuf);
|
||
} catch (_e) {
|
||
isDecryptionFailed = true;
|
||
}
|
||
} else {
|
||
isDecryptionFailed = true;
|
||
}
|
||
}
|
||
|
||
if (isDecryptionFailed) {
|
||
this.tryAutoRegisterCanvas(senderId);
|
||
const ctx = this.canvasCtxMap.get(senderId);
|
||
if (ctx) {
|
||
const w = ctx.canvas.width || 640;
|
||
const h = ctx.canvas.height || 360;
|
||
const idata = ctx.createImageData(w, h);
|
||
const d32 = new Uint32Array(idata.data.buffer);
|
||
for(let i=0; i<d32.length; i++) {
|
||
d32[i] = Math.random() < 0.5 ? 0xff000000 : 0xffffffff;
|
||
}
|
||
ctx.putImageData(idata, 0, 0);
|
||
ctx.fillStyle = 'red';
|
||
ctx.font = 'bold 24px sans-serif';
|
||
ctx.fillText("🔒 E2EE ENCRYPTED", 20, 40);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (jpegPayload.length < 100) return;
|
||
|
||
if (!this.participantLastSeen.has(senderId) || !this.canvasCtxMap.has(senderId)) {
|
||
if (this.onParticipantJoined) this.onParticipantJoined(senderId);
|
||
}
|
||
|
||
// High-Performance Blob Renderer (Full Resolution)
|
||
this.tryAutoRegisterCanvas(senderId);
|
||
const jpegBlob = new Blob([jpegPayload], { type: "image/jpeg" });
|
||
const bmpUrl = URL.createObjectURL(jpegBlob);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const jitter = this.audioJitterMap.get(senderId) || 50;
|
||
let queue = this.videoRenderQueue.get(senderId);
|
||
if (!queue) { queue = []; this.videoRenderQueue.set(senderId, queue); }
|
||
queue.push({
|
||
frame: img,
|
||
targetTime: performance.now() + jitter,
|
||
isWebCodec: false
|
||
});
|
||
URL.revokeObjectURL(bmpUrl);
|
||
};
|
||
img.onerror = () => URL.revokeObjectURL(bmpUrl);
|
||
img.src = bmpUrl;
|
||
} else if (frameType === 2) {
|
||
// AUDIO frame
|
||
let audioPayload = payloadData;
|
||
let isDecryptionFailed = false;
|
||
|
||
if (quality === 2) {
|
||
if (this.e2eeKey && payloadData.length > 12) {
|
||
const iv = payloadData.slice(0, 12);
|
||
const cipher = payloadData.slice(12);
|
||
try {
|
||
const plainBuf = await window.crypto.subtle.decrypt(
|
||
{ name: "AES-GCM", iv: iv },
|
||
this.e2eeKey,
|
||
cipher
|
||
);
|
||
audioPayload = new Uint8Array(plainBuf);
|
||
} catch (_e) {
|
||
isDecryptionFailed = true;
|
||
}
|
||
} else {
|
||
isDecryptionFailed = true;
|
||
}
|
||
}
|
||
|
||
if (isDecryptionFailed) return;
|
||
// Play audio if method exists
|
||
this.playRemoteAudio(senderId, audioPayload);
|
||
} else if (frameType === 6) {
|
||
// FRAME_LEDGER — JSON data (Chat, Resonance, Name Announce, etc.)
|
||
try {
|
||
const jsonStr = new TextDecoder().decode(payloadData);
|
||
const parsed = JSON.parse(jsonStr);
|
||
if (parsed.pkepxType === 'NAME_ANNOUNCE' && parsed.name) {
|
||
if (this.onParticipantNameReceived) this.onParticipantNameReceived(senderId, parsed.name);
|
||
} else if (parsed.pkepxType === 'RESONANCE' && parsed.payload) {
|
||
if (this.onQuantumResonance) this.onQuantumResonance(senderId, parsed.payload);
|
||
} else if (parsed.pkepxType === 'SOVEREIGN_SIGNAL') {
|
||
if (this.onSovereignSignal) this.onSovereignSignal(parsed.signalType, parsed.payload);
|
||
}
|
||
// Also forward to generic data handler
|
||
if (this.onQuantumDataReceived) this.onQuantumDataReceived(senderId, jsonStr);
|
||
} catch(_) { /* Not valid JSON */ }
|
||
}
|
||
}
|
||
|
||
/** Send data via WebTransport (QUIC datagram) or WebSocket (TCP) */
|
||
public async sendFrame(payload: Uint8Array) {
|
||
// REAL TX counter
|
||
this.trafficStats.tx.total += payload.byteLength;
|
||
const frameType = payload[0];
|
||
if (frameType === 1 || frameType === 10) this.trafficStats.tx.video += payload.byteLength;
|
||
else if (frameType === 2) this.trafficStats.tx.audio += payload.byteLength;
|
||
else this.trafficStats.tx.control += payload.byteLength;
|
||
|
||
// Send via WebTransport QUIC datagram (primary) or WebSocket (fallback)
|
||
if (this.streamWriter) {
|
||
try {
|
||
await this.streamWriter.write(payload);
|
||
return;
|
||
} catch(e) {
|
||
// WebTransport write failed, try WebSocket
|
||
}
|
||
}
|
||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||
this.ws.send(payload);
|
||
}
|
||
}
|
||
|
||
|
||
public async hotSwapVideoEngine(mode: "auto" | "canvas" | "webcodecs") {
|
||
if (this.videoEngineMode === mode) return;
|
||
console.log(`[QUANTUM HOT-SWAP] Mengalihkan Video Engine ke: ${mode}`);
|
||
this.videoEngineMode = mode;
|
||
if (this.mediaStream) {
|
||
await this.deactivateUplink();
|
||
await this.activateUplink('camera');
|
||
}
|
||
}
|
||
|
||
public async hotSwapAudioEngine(mode: "auto" | "pcm" | "xcu-neural" | "xcu-resonance") {
|
||
if (this.audioEngineMode === mode) return;
|
||
console.log(`[QUANTUM HOT-SWAP] Mengalihkan Audio Engine ke: ${mode}`);
|
||
this.audioEngineMode = mode;
|
||
if (this.mediaStream) {
|
||
await this.deactivateUplink();
|
||
await this.activateUplink('camera');
|
||
}
|
||
}
|
||
|
||
public unlockAudio() {
|
||
if (!this.downlinkAudioCtx) {
|
||
this.initDownlinkAudio();
|
||
(this.downlinkAudioCtx as any).nextPlayTime = this.downlinkAudioCtx.currentTime;
|
||
}
|
||
if (this.downlinkAudioCtx.state === "suspended") {
|
||
this.downlinkAudioCtx.resume().catch(() => {});
|
||
}
|
||
if (!this.uplinkAudioCtx) {
|
||
this.uplinkAudioCtx = new AudioContext();
|
||
}
|
||
if (this.uplinkAudioCtx.state === "suspended") {
|
||
this.uplinkAudioCtx.resume().catch(() => {});
|
||
}
|
||
console.log("[QUANTUM MATRIX] Audio Contexts Pre-Created and Unlocked via User Gesture");
|
||
}
|
||
|
||
public toggleMic(enabled: boolean) {
|
||
this.isMicMuted = !enabled;
|
||
if (this.mediaStream) {
|
||
this.mediaStream.getAudioTracks().forEach((track) => {
|
||
track.enabled = enabled;
|
||
});
|
||
}
|
||
console.log(`[QUANTUM UPLINK] Mikrofon ${enabled ? "AKTIF" : "MATI"} — pipeline terjaga untuk mencegah bug Mac Safari/Chrome.`);
|
||
}
|
||
|
||
public resumeAudioContext() {
|
||
if (this.downlinkAudioCtx && this.downlinkAudioCtx.state === "suspended") {
|
||
this.downlinkAudioCtx.resume().then(() => {
|
||
console.log("[QUANTUM MATRIX] AudioContext berhasil dibangunkan secara manual!");
|
||
}).catch(e => {
|
||
console.warn("[QUANTUM MATRIX] Gagal membangunkan AudioContext:", e);
|
||
});
|
||
}
|
||
if (this.uplinkAudioCtx && this.uplinkAudioCtx.state === "suspended") {
|
||
this.uplinkAudioCtx.resume().catch(() => {});
|
||
}
|
||
if (this.vadAudioCtx && this.vadAudioCtx.state === "suspended") {
|
||
this.vadAudioCtx.resume().catch(() => {});
|
||
}
|
||
}
|
||
|
||
public setEffects(virtualBgMode: number, beautyFilter: boolean) {
|
||
this.useVirtualBg = virtualBgMode;
|
||
this.useBeautyFilter = beautyFilter;
|
||
console.log(`[QUANTUM EFFECTS] Virtual BG Mode: ${virtualBgMode}, Beauty: ${beautyFilter}`);
|
||
}
|
||
|
||
|
||
private async detectBestCodec(): Promise<string> {
|
||
const codecs = [
|
||
"av01.0.04M.08", // AV1
|
||
"vp09.00.10.08", // VP9
|
||
"vp8", // VP8
|
||
"avc1.42E01F" // H.264
|
||
];
|
||
for (const c of codecs) {
|
||
try {
|
||
const support = await VideoEncoder.isConfigSupported({
|
||
codec: c,
|
||
width: 1920,
|
||
height: 1080,
|
||
bitrate: 8_000_000,
|
||
framerate: 30
|
||
});
|
||
if (support.supported) {
|
||
console.log(`[QUANTUM WEBCODECS] Hardware GPU Codec Terdeteksi: ${c}`);
|
||
return c;
|
||
}
|
||
} catch (_e) {}
|
||
}
|
||
return "avc1.42E01F"; // Fallback H.264
|
||
}
|
||
|
||
private createDecoderForParticipant(senderId: number, codecStr: string = "avc1.42E01F") {
|
||
// Jika decoder sudah ada dan codec-nya berbeda, tutup dulu
|
||
if (this.videoDecoders.has(senderId)) {
|
||
try { this.videoDecoders.get(senderId)!.close(); } catch(e){}
|
||
this.videoDecoders.delete(senderId);
|
||
}
|
||
|
||
const decoder = new VideoDecoder({
|
||
output: (frame: unknown) => {
|
||
this.tryAutoRegisterCanvas(senderId);
|
||
const jitter = this.audioJitterMap.get(senderId) || 50;
|
||
let queue = this.videoRenderQueue.get(senderId);
|
||
if (!queue) { queue = []; this.videoRenderQueue.set(senderId, queue); }
|
||
queue.push({
|
||
frame: frame,
|
||
targetTime: performance.now() + jitter,
|
||
isWebCodec: true
|
||
});
|
||
},
|
||
error: (e: unknown) => console.error(`Decoder Error untuk ${senderId}:`, e),
|
||
});
|
||
|
||
try {
|
||
decoder.configure({ codec: codecStr, codedWidth: 1280, codedHeight: 720 });
|
||
this.videoDecoders.set(senderId, decoder);
|
||
this.firstKeyFrameReceived.set(senderId, false);
|
||
console.log(`[QUANTUM WEBCODECS] Hardware Decoder (${codecStr}) siap untuk Partisipan ${senderId}`);
|
||
} catch (_e) {
|
||
console.error("[QUANTUM WEBCODECS] Gagal konfigurasi decoder:", e);
|
||
}
|
||
|
||
// Notifikasi React untuk merender tile
|
||
if (this.onParticipantJoined) {
|
||
this.onParticipantJoined(senderId);
|
||
}
|
||
}
|
||
|
||
public async activateUplink(source: "camera" | "screen" = "camera", facingMode: "user" | "environment" = "user") {
|
||
if (this.mediaStream) {
|
||
console.warn(
|
||
"[QUANTUM UPLINK] Kamera/Layar sudah aktif! Mengabaikan perintah ganda untuk mencegah kebocoran memori.",
|
||
);
|
||
return;
|
||
}
|
||
console.log(
|
||
`[QUANTUM UPLINK] Memulai Injeksi ${source === "screen" ? "Layar" : "Kamera"} ke WebCodecs...`,
|
||
);
|
||
try {
|
||
if (source === "screen") {
|
||
this.mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||
video: { width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: this.targetFps } },
|
||
audio: false,
|
||
});
|
||
} else {
|
||
this.mediaStream = await navigator.mediaDevices.getUserMedia({
|
||
video: { width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: this.targetFps }, facingMode: facingMode },
|
||
audio: {
|
||
echoCancellation: true,
|
||
noiseSuppression: true,
|
||
autoGainControl: true
|
||
},
|
||
});
|
||
}
|
||
|
||
if (this.onLocalStream) {
|
||
this.onLocalStream(this.mediaStream);
|
||
}
|
||
this.startVAD();
|
||
|
||
// AUDIENCE mode: skip encoding
|
||
if (this.participantRole === "AUDIENCE") {
|
||
console.log("[UPLINK] AUDIENCE mode - no video upload.");
|
||
await this.sendRoleSignal();
|
||
return;
|
||
}
|
||
|
||
const videoTrack = this.mediaStream.getVideoTracks()[0];
|
||
|
||
// === Canvas JPEG Pipeline: Works on ALL browsers ===
|
||
const captureVideo = document.createElement("video");
|
||
captureVideo.srcObject = new MediaStream([videoTrack]);
|
||
captureVideo.muted = true;
|
||
captureVideo.playsInline = true;
|
||
captureVideo.autoplay = true;
|
||
try { await captureVideo.play(); } catch { /* autoplay blocked */ }
|
||
|
||
// Adaptive canvas resolution based on bandwidth
|
||
const conn = (navigator as any).connection;
|
||
const dl = conn ? conn.downlink : 10;
|
||
let captureW = 1280, captureH = 720; // Default HD
|
||
if (dl > 5) { captureW = 1920; captureH = 1080; } // FHD for good bandwidth
|
||
else if (dl < 1.5) { captureW = 854; captureH = 480; } // SD for low bandwidth
|
||
const captureCvs = document.createElement("canvas");
|
||
captureCvs.width = captureW;
|
||
captureCvs.height = captureH;
|
||
const captureCtx = captureCvs.getContext("2d")!;
|
||
console.log(`[UPLINK] Canvas JPEG pipeline active (${captureW}x${captureH} @ ${this.targetFps}fps) [TSM.ID].[11031972]`);
|
||
|
||
let lastVideoFrame = 0;
|
||
const captureLoop = (now: number) => {
|
||
if (!this.isRunning) return;
|
||
requestAnimationFrame(captureLoop);
|
||
const fpsInterval = 1000 / this.targetFps;
|
||
if (now - lastVideoFrame < fpsInterval) return;
|
||
lastVideoFrame = now;
|
||
|
||
if (this.videoEngineMode === "webcodecs") return;
|
||
if (!this.streamWriter && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) return;
|
||
|
||
// === ULTRA BACKPRESSURE CONTROL ===
|
||
// If WebSocket send buffer > 64KB, skip this frame (prevent 18KB backlog!)
|
||
if (this.ws && this.ws.bufferedAmount > 65536) {
|
||
this.framesDropped++;
|
||
// Smart downgrade: reduce quality when buffer is full
|
||
if (this.adaptiveQuality > 0.5) {
|
||
this.adaptiveQuality = Math.max(0.5, this.adaptiveQuality - 0.05);
|
||
console.log(`[AUTOPILOT] ⚡ Backpressure! Quality → ${(this.adaptiveQuality*100).toFixed(0)}% (dropped: ${this.framesDropped})`);
|
||
}
|
||
if (this.adaptiveResScale > 0.5) {
|
||
this.adaptiveResScale = Math.max(0.5, this.adaptiveResScale - 0.1);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// === SMART RESOLUTION SCALING ===
|
||
const effectiveW = Math.round(captureW * this.adaptiveResScale);
|
||
const effectiveH = Math.round(captureH * this.adaptiveResScale);
|
||
if (captureCvs.width !== effectiveW || captureCvs.height !== effectiveH) {
|
||
captureCvs.width = effectiveW;
|
||
captureCvs.height = effectiveH;
|
||
}
|
||
|
||
try {
|
||
let mode = 0;
|
||
if (this.useBeautyFilter && this.useVirtualBg === 2) mode = 3;
|
||
else if (this.useBeautyFilter && this.useVirtualBg === 4) mode = 5;
|
||
else if (this.useVirtualBg > 0) mode = this.useVirtualBg;
|
||
else if (this.useBeautyFilter) mode = 1;
|
||
|
||
if (mode > 0) {
|
||
if (!(this as any).webglFilter) (this as any).webglFilter = new XCUWebGLFilter(effectiveW, effectiveH);
|
||
(this as any).webglFilter.render(captureVideo, mode);
|
||
captureCtx.drawImage((this as any).webglFilter.canvas, 0, 0, effectiveW, effectiveH);
|
||
} else {
|
||
if (captureVideo.readyState >= 2) {
|
||
captureCtx.drawImage(captureVideo, 0, 0, effectiveW, effectiveH);
|
||
}
|
||
}
|
||
|
||
if (this.isEncodingCanvas) return; // Prevent OOM stacking
|
||
this.isEncodingCanvas = true;
|
||
captureCvs.toBlob((blob) => {
|
||
this.isEncodingCanvas = false;
|
||
if (!blob) return;
|
||
// Check both WS and WT
|
||
if (!this.streamWriter && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) return;
|
||
blob.arrayBuffer().then(async (buf) => {
|
||
let jpegData = new Uint8Array(buf);
|
||
let packetLen = 8 + jpegData.length;
|
||
let isEncrypted = 0;
|
||
const iv = new Uint8Array(12);
|
||
|
||
if (this.e2eeKey) {
|
||
window.crypto.getRandomValues(iv);
|
||
const cipherBuffer = await window.crypto.subtle.encrypt(
|
||
{ name: "AES-GCM", iv: iv },
|
||
this.e2eeKey,
|
||
jpegData
|
||
);
|
||
jpegData = new Uint8Array(cipherBuffer);
|
||
packetLen = 8 + 12 + jpegData.length;
|
||
isEncrypted = 2;
|
||
}
|
||
|
||
const packet = new Uint8Array(packetLen);
|
||
packet[0] = 10; // FRAME_PULSAR
|
||
packet[1] = isEncrypted ? 2 : 1;
|
||
const view = new DataView(packet.buffer);
|
||
view.setUint16(2, this.participantId, true);
|
||
|
||
if (isEncrypted) {
|
||
view.setUint32(4, 12 + jpegData.length, true);
|
||
packet.set(iv, 8);
|
||
packet.set(jpegData, 20);
|
||
} else {
|
||
view.setUint32(4, jpegData.length, true);
|
||
packet.set(jpegData, 8);
|
||
}
|
||
|
||
this.sendFrame(packet);
|
||
this._frameCount++;
|
||
});
|
||
}, "image/jpeg", this.adaptiveQuality);
|
||
} catch (err) { console.warn("[TX] capture error", err); }
|
||
};
|
||
requestAnimationFrame(captureLoop);
|
||
|
||
// === Audio PCM Pipeline — ULTRA TUNE-UP ===
|
||
const audioTracks = this.mediaStream.getAudioTracks();
|
||
if (audioTracks.length > 0) {
|
||
if (!this.uplinkAudioCtx) this.uplinkAudioCtx = new AudioContext();
|
||
this.uplinkAudioSource = this.uplinkAudioCtx.createMediaStreamSource(new MediaStream([audioTracks[0]]));
|
||
// Smaller buffer = lower latency (2048 samples = ~42ms @ 48kHz)
|
||
this.uplinkScriptNode = this.uplinkAudioCtx.createScriptProcessor(2048, 1, 1);
|
||
this.uplinkScriptNode.onaudioprocess = (ev: AudioProcessingEvent) => {
|
||
if (this.isMicMuted) {
|
||
const out = ev.outputBuffer.getChannelData(0);
|
||
for (let i = 0; i < out.length; i++) out[i] = 0;
|
||
return;
|
||
}
|
||
const float32 = ev.inputBuffer.getChannelData(0);
|
||
|
||
// SILENCE DETECTION: Skip sending if audio is nearly silent (saves bandwidth)
|
||
let rms = 0;
|
||
for (let i = 0; i < float32.length; i += 16) rms += float32[i] * float32[i];
|
||
rms = Math.sqrt(rms / (float32.length / 16));
|
||
if (rms < 0.005) {
|
||
const out = ev.outputBuffer.getChannelData(0);
|
||
for (let i = 0; i < out.length; i++) out[i] = 0;
|
||
return; // Skip silent frames
|
||
}
|
||
|
||
// DOWNSAMPLE 48kHz → 16kHz (3x bandwidth reduction)
|
||
const srcRate = this.uplinkAudioCtx!.sampleRate;
|
||
const targetRate = 16000;
|
||
const ratio = srcRate / targetRate;
|
||
const downLen = Math.floor(float32.length / ratio);
|
||
const int16 = new Int16Array(downLen);
|
||
for (let i = 0; i < downLen; i++) {
|
||
// Linear interpolation for cleaner downsampling
|
||
const srcIdx = i * ratio;
|
||
const idx0 = Math.floor(srcIdx);
|
||
const idx1 = Math.min(idx0 + 1, float32.length - 1);
|
||
const frac = srcIdx - idx0;
|
||
const sample = float32[idx0] * (1 - frac) + float32[idx1] * frac;
|
||
int16[i] = Math.max(-32768, Math.min(32767, Math.round(sample * 32767)));
|
||
}
|
||
|
||
const out = ev.outputBuffer.getChannelData(0);
|
||
for (let i = 0; i < out.length; i++) out[i] = 0;
|
||
|
||
const pcmBytes = new Uint8Array(int16.buffer);
|
||
|
||
if (this.e2eeKey) {
|
||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||
window.crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, this.e2eeKey, pcmBytes)
|
||
.then(cipherBuffer => {
|
||
const cipherBytes = new Uint8Array(cipherBuffer);
|
||
const packet = new Uint8Array(8 + 4 + 12 + cipherBytes.length);
|
||
packet[0] = 2;
|
||
packet[1] = 2;
|
||
const av = new DataView(packet.buffer);
|
||
av.setUint16(2, this.participantId, true);
|
||
av.setUint32(4, 4 + 12 + cipherBytes.length, true);
|
||
av.setUint32(8, targetRate, true);
|
||
packet.set(iv, 12);
|
||
packet.set(cipherBytes, 24);
|
||
this.sendFrame(packet);
|
||
}).catch(e => console.error("Audio Encrypt Error", e));
|
||
} else {
|
||
const packet = new Uint8Array(8 + 4 + pcmBytes.length);
|
||
packet[0] = 2;
|
||
packet[1] = 0;
|
||
const av = new DataView(packet.buffer);
|
||
av.setUint16(2, this.participantId, true);
|
||
av.setUint32(4, 4 + pcmBytes.length, true);
|
||
av.setUint32(8, targetRate, true);
|
||
packet.set(pcmBytes, 12);
|
||
this.sendFrame(packet);
|
||
}
|
||
};
|
||
this.uplinkAudioSource.connect(this.uplinkScriptNode);
|
||
this.uplinkScriptNode.connect(this.uplinkAudioCtx.destination);
|
||
(window as unknown as { [key: string]: unknown }).uplinkScriptNode = this.uplinkScriptNode;
|
||
console.log("[UPLINK] Audio pipeline active (16kHz downsampled, silence gated) [TSM.ID].[11031972]");
|
||
|
||
this.startVadLoop();
|
||
}
|
||
|
||
} catch (e: unknown) {
|
||
console.error("[UPLINK] Camera error:", e);
|
||
}
|
||
}
|
||
|
||
|
||
/** Calculate real bytes/sec rates from cumulative counters */
|
||
|
||
// Ghost Participant Cleanup — auto-remove stale participants after 8s of no data
|
||
private startGhostCleanup() {
|
||
if (this.ghostCleanupInterval) clearInterval(this.ghostCleanupInterval);
|
||
this.ghostCleanupInterval = setInterval(() => {
|
||
const now = Date.now();
|
||
const GHOST_TIMEOUT = 5000; // 5s — faster cleanup for accurate participant list
|
||
this.participantLastSeen.forEach((lastSeen, senderId) => {
|
||
if (now - lastSeen > GHOST_TIMEOUT) {
|
||
console.log(`[GHOST CLEANUP] Removing stale participant ${senderId} (${Math.round((now - lastSeen)/1000)}s stale)`);
|
||
this.participantLastSeen.delete(senderId);
|
||
this.canvasCtxMap.delete(senderId);
|
||
if (this.videoDecoders.has(senderId)) {
|
||
try { this.videoDecoders.get(senderId)!.close(); } catch(e) {}
|
||
this.videoDecoders.delete(senderId);
|
||
}
|
||
if (this.onParticipantLeft) {
|
||
this.onParticipantLeft(senderId);
|
||
}
|
||
}
|
||
});
|
||
}, 5000);
|
||
}
|
||
|
||
public startTrafficMonitor() {
|
||
this.trafficStats.startTime = performance.now();
|
||
this.trafficStats._lastSnapshot.ts = performance.now();
|
||
if (this.trafficInterval) clearInterval(this.trafficInterval);
|
||
this.trafficInterval = setInterval(() => {
|
||
const now = performance.now();
|
||
const elapsed = (now - this.trafficStats._lastSnapshot.ts) / 1000;
|
||
if (elapsed < 0.5) return;
|
||
const snap = this.trafficStats._lastSnapshot;
|
||
this.trafficStats.rates.txVideo = Math.round((this.trafficStats.tx.video - snap.txVideo) / elapsed);
|
||
this.trafficStats.rates.txAudio = Math.round((this.trafficStats.tx.audio - snap.txAudio) / elapsed);
|
||
this.trafficStats.rates.txTotal = Math.round((this.trafficStats.tx.total - snap.txTotal) / elapsed);
|
||
this.trafficStats.rates.rxVideo = Math.round((this.trafficStats.rx.video - snap.rxVideo) / elapsed);
|
||
this.trafficStats.rates.rxAudio = Math.round((this.trafficStats.rx.audio - snap.rxAudio) / elapsed);
|
||
this.trafficStats.rates.rxTotal = Math.round((this.trafficStats.rx.total - snap.rxTotal) / elapsed);
|
||
snap.txVideo = this.trafficStats.tx.video;
|
||
snap.txAudio = this.trafficStats.tx.audio;
|
||
snap.txTotal = this.trafficStats.tx.total;
|
||
snap.rxVideo = this.trafficStats.rx.video;
|
||
snap.rxAudio = this.trafficStats.rx.audio;
|
||
snap.rxTotal = this.trafficStats.rx.total;
|
||
snap.ts = now;
|
||
this.trafficStats.wsState = this.ws?.readyState === WebSocket.OPEN ? 'OPEN' : this.ws?.readyState === WebSocket.CONNECTING ? 'CONNECTING' : 'CLOSED';
|
||
}, 1000);
|
||
}
|
||
|
||
public stopTrafficMonitor() {
|
||
if (this.trafficInterval) { clearInterval(this.trafficInterval); this.trafficInterval = null; }
|
||
}
|
||
|
||
public startQuantumAutoPilot() {
|
||
if (this.autoPilotInterval) clearInterval(this.autoPilotInterval);
|
||
this.autoPilotInterval = setInterval(async () => {
|
||
const conn = (navigator as any).connection;
|
||
const downlink = conn ? conn.downlink : 10; // Mbps
|
||
this.currentBandwidth = downlink;
|
||
|
||
// === BANDWIDTH ESTIMATION (OPTION 1 & 2) ===
|
||
// Option 1: Standard Network API (Downlink) + Traffic Jitter
|
||
let bwScore = 3; // Default FHD
|
||
if (downlink < 0.5) bwScore = 0; // Base/Audio Only
|
||
else if (downlink < 1.5) bwScore = 1; // SD
|
||
else if (downlink < 4) bwScore = 2; // HD
|
||
|
||
// Option 2: Target Buffer Throttle (Dynamic RX Drop simulation)
|
||
// Jika RX video macet (Buffer tersendat), paksa turunkan skor!
|
||
if (this.trafficStats.rates.rxVideo < 5000 && this.participantRole === 'AUDIENCE') {
|
||
// Terindikasi video membeku walau downlink besar
|
||
bwScore = Math.max(0, bwScore - 1);
|
||
}
|
||
|
||
// Kirim Telemetry Datagram ke XCU Router (Ring-0 Simulator)
|
||
if (this.streamWriter) {
|
||
const bwTelemetry = new Uint8Array(4);
|
||
bwTelemetry[0] = 10; // Tipe: Bandwidth Telemetry
|
||
bwTelemetry[1] = bwScore; // Skor 0-3
|
||
bwTelemetry[2] = this.participantId & 0xFF;
|
||
bwTelemetry[3] = (this.participantId >> 8) & 0xFF;
|
||
this.streamWriter.write(bwTelemetry).catch(() => {});
|
||
}
|
||
|
||
if (this.isSwappingEncoder) return;
|
||
|
||
// --- AUTO VIDEO (XCU Pulsar = INSTANT BOOT, WebCodecs = PERFORMANCE UPGRADE) ---
|
||
// PKX Konstitusi 5 Pasal 3: XCU Pulsar STARTS FIRST, kemudian upgrade jika hardware tersedia
|
||
if (this.videoEngineMode === 'auto') {
|
||
if (!this.usePulsarCodec && !this.videoEncoder) {
|
||
// BOOT PERTAMA: XCU Pulsar langsung aktif tanpa delay
|
||
this.usePulsarCodec = true;
|
||
this.activeVideoCodec = 'XCU PULSAR (Delta)';
|
||
console.log(`[AUTO-PILOT] XCU Pulsar BOOT PERTAMA — instant start`);
|
||
} else if (this.usePulsarCodec && this.videoEncoder && downlink > 1) {
|
||
// UPGRADE: WebCodecs hardware tersedia + bandwidth cukup → upgrade untuk performa
|
||
this.isSwappingEncoder = true;
|
||
let targetCodec = "avc1.42E01F"; // H.264 baseline (paling kompatibel)
|
||
let targetBitrate = 800_000;
|
||
|
||
if (downlink > 10) {
|
||
targetCodec = "vp09.00.10.08"; // VP9
|
||
targetBitrate = 2_500_000;
|
||
} else if (downlink > 5) {
|
||
targetCodec = "vp8";
|
||
targetBitrate = 1_500_000;
|
||
} else if (downlink > 2) {
|
||
targetCodec = "avc1.42E01F"; // H.264
|
||
targetBitrate = 1_000_000;
|
||
}
|
||
|
||
console.log(`[AUTO-PILOT] UPGRADE dari Pulsar ke WebCodecs ${targetCodec} (${downlink} Mbps)`);
|
||
try {
|
||
try { (this.videoEncoder as any).close(); } catch(_e) {}
|
||
this.activeCodecStr = targetCodec;
|
||
this.activeVideoCodec = `WebCodecs ${targetCodec.split('.')[0].toUpperCase()}`;
|
||
this.videoEncoder = new (window as any).VideoEncoder({
|
||
output: (chunk: any, metadata: any) => this.handleVideoChunk(chunk, metadata),
|
||
error: (e: any) => {
|
||
console.error("[AUTO-PILOT] WebCodecs error, fallback ke Pulsar", e);
|
||
this.usePulsarCodec = true;
|
||
this.activeVideoCodec = 'XCU PULSAR (Delta)';
|
||
}
|
||
});
|
||
(this.videoEncoder as any).configure({
|
||
codec: this.activeCodecStr,
|
||
width: 1920, height: 1080, bitrate: targetBitrate, framerate: this.targetFps
|
||
});
|
||
this.usePulsarCodec = false; // Switch berhasil
|
||
} catch(e) {
|
||
// WebCodecs tidak support: tetap Pulsar (PKX fallback)
|
||
this.usePulsarCodec = true;
|
||
this.activeVideoCodec = 'XCU PULSAR (Delta)';
|
||
console.log('[AUTO-PILOT] WebCodecs tidak tersedia, Pulsar tetap aktif');
|
||
}
|
||
this.isSwappingEncoder = false;
|
||
} else if (!this.usePulsarCodec && this.videoEncoder && downlink < 0.5) {
|
||
// DOWNGRADE: Bandwidth sangat rendah → kembali ke Pulsar
|
||
this.usePulsarCodec = true;
|
||
this.activeVideoCodec = 'XCU PULSAR (Delta)';
|
||
console.log(`[AUTO-PILOT] Bandwidth rendah (${downlink} Mbps), kembali ke Pulsar`);
|
||
}
|
||
}
|
||
|
||
// --- AUTO AUDIO (XCU Resonance = INSTANT BOOT, Opus = PERFORMANCE UPGRADE) ---
|
||
// PKX: Resonance STARTS FIRST, upgrade ke Opus jika bandwidth stabil
|
||
if (this.audioEngineMode === 'auto') {
|
||
if (!this.audioEncoder && downlink > 2) {
|
||
// UPGRADE: Bandwidth cukup → Opus untuk kualitas audio lebih baik
|
||
this.isSwappingEncoder = true;
|
||
try {
|
||
this.audioEncoder = new (window as any).AudioEncoder({
|
||
output: (chunk: any) => this.handleAudioChunk(chunk),
|
||
error: (e: any) => {
|
||
console.error("[AUTO-PILOT] Opus gagal, Resonance tetap aktif", e);
|
||
this.audioEncoder = null;
|
||
this.activeAudioCodec = 'XCU RESONANCE (300bps)';
|
||
}
|
||
});
|
||
(this.audioEncoder as any).configure({
|
||
codec: 'opus', sampleRate: 48000, numberOfChannels: 1, bitrate: 32000
|
||
});
|
||
this.activeAudioCodec = 'XCU NEURAL (Opus 32k)';
|
||
console.log(`[AUTO-PILOT] UPGRADE Audio ke Opus (${downlink} Mbps)`);
|
||
} catch(e) {
|
||
this.audioEncoder = null;
|
||
this.activeAudioCodec = 'XCU RESONANCE (300bps)';
|
||
}
|
||
this.isSwappingEncoder = false;
|
||
} else if (this.audioEncoder && downlink < 0.5) {
|
||
// DOWNGRADE: Bandwidth rendah → kembali ke Resonance
|
||
try { (this.audioEncoder as any).close(); } catch(_e) {}
|
||
this.audioEncoder = null;
|
||
this.activeAudioCodec = 'XCU RESONANCE (300bps)';
|
||
console.log(`[AUTO-PILOT] Bandwidth rendah, kembali ke Resonance`);
|
||
} else if (!this.audioEncoder) {
|
||
this.activeAudioCodec = 'XCU RESONANCE (300bps)';
|
||
}
|
||
}
|
||
|
||
// --- AUTO FPS (30fps → 60fps probe — PKX Extreme Smooth) ---
|
||
if (this.targetFps === 30 && !this.fpsConfirmed60 && downlink > 3) {
|
||
this.fpsProbeCount++;
|
||
if (this.fpsProbeCount >= 4) { // 4 cycles × 3 sec = 12 detik stabil
|
||
this.fpsConfirmed60 = true;
|
||
this.targetFps = 60;
|
||
console.log(`[AUTO-PILOT] 60fps CONFIRMED! Bandwidth stabil ${downlink} Mbps selama 12 detik. Upgrade ke 60fps.`);
|
||
// Re-configure video track jika memungkinkan
|
||
try {
|
||
const videoTrack = this.mediaStream?.getVideoTracks()[0];
|
||
if (videoTrack) {
|
||
videoTrack.applyConstraints({ frameRate: { ideal: 60 } }).catch(() => {});
|
||
}
|
||
} catch(_e) {}
|
||
}
|
||
} else if (this.targetFps === 30 && downlink <= 3) {
|
||
this.fpsProbeCount = 0; // Reset probe jika bandwidth tidak stabil
|
||
}
|
||
|
||
// --- DOWNGRADE FPS jika bandwidth sangat rendah ---
|
||
if (this.targetFps === 60 && downlink < 1) {
|
||
this.targetFps = 30;
|
||
this.fpsConfirmed60 = false;
|
||
this.fpsProbeCount = 0;
|
||
console.log(`[AUTO-PILOT] Bandwidth drop (${downlink} Mbps). Downgrade ke 30fps.`);
|
||
}
|
||
}, 3000);
|
||
|
||
// === ULTRA AUTOPILOT: Smart Quality Recovery ===
|
||
// Gradually restore quality when bandwidth improves
|
||
setInterval(() => {
|
||
const wsBuffered = this.ws ? this.ws.bufferedAmount : 0;
|
||
const txRate = this.trafficStats.rates.txTotal; // bytes/sec
|
||
|
||
// Estimate real bandwidth from TX rate
|
||
this.lastBandwidthEstimate = txRate > 0 ? (txRate * 8 / 1_000_000) : this.currentBandwidth;
|
||
|
||
// Buffer is clear + bandwidth ok → gradually RESTORE quality
|
||
if (wsBuffered < 8192 && this.adaptiveQuality < 0.92) {
|
||
this.adaptiveQuality = Math.min(0.92, this.adaptiveQuality + 0.02);
|
||
console.log(`[AUTOPILOT] 📈 Quality recovering → ${(this.adaptiveQuality*100).toFixed(0)}%`);
|
||
}
|
||
if (wsBuffered < 4096 && this.adaptiveResScale < 1.0) {
|
||
this.adaptiveResScale = Math.min(1.0, this.adaptiveResScale + 0.05);
|
||
console.log(`[AUTOPILOT] 📈 Resolution recovering → ${(this.adaptiveResScale*100).toFixed(0)}%`);
|
||
}
|
||
|
||
// Severe congestion → aggressive downgrade
|
||
if (wsBuffered > 131072) { // >128KB stuck
|
||
this.adaptiveQuality = 0.5;
|
||
this.adaptiveResScale = 0.5;
|
||
if (this.targetFps > 15) {
|
||
this.targetFps = 15;
|
||
console.log(`[AUTOPILOT] 🚨 SEVERE CONGESTION! Quality→50%, Res→50%, FPS→15`);
|
||
}
|
||
}
|
||
}, 2000);
|
||
}
|
||
|
||
/**
|
||
* Set FPS manual dari Matrix UI
|
||
* @param fps - 15, 30, atau 60
|
||
* Setelah di-set manual, AutoPilot FPS probe di-nonaktifkan
|
||
*/
|
||
public setFps(fps: 15 | 30 | 60): void {
|
||
this.targetFps = fps;
|
||
this.fpsConfirmed60 = fps === 60; // Jika manual 60, tandai confirmed
|
||
this.fpsProbeCount = 999; // Disable auto probe
|
||
console.log(`[MATRIX] FPS manual di-set ke ${fps}fps oleh user`);
|
||
// Re-configure video track
|
||
try {
|
||
const videoTrack = this.mediaStream?.getVideoTracks()[0];
|
||
if (videoTrack) {
|
||
videoTrack.applyConstraints({ frameRate: { ideal: fps } }).catch(() => {});
|
||
}
|
||
} catch(_e) {}
|
||
}
|
||
|
||
private handleVideoChunk(chunk: any, metadata: any) {
|
||
if ((this.ws && this.ws.readyState === WebSocket.OPEN) || this.streamWriter) {
|
||
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.ws.bufferedAmount > 65536) {
|
||
// Jaringan kewalahan (backlog TCP menumpuk). Buang frame delta!
|
||
if (chunk.type !== "key") return;
|
||
}
|
||
const chunkData = new Uint8Array(chunk.byteLength);
|
||
chunk.copyTo(chunkData);
|
||
const packetLen = 8 + chunkData.length;
|
||
const packet = new Uint8Array(packetLen);
|
||
packet[0] = chunk.type === "key" ? 3 : 4;
|
||
packet[1] = 0;
|
||
const view = new DataView(packet.buffer);
|
||
view.setUint16(2, this.participantId, true);
|
||
view.setUint32(4, packetLen - 8, true);
|
||
packet.set(chunkData, 8);
|
||
this.sendFrame(packet);
|
||
}
|
||
}
|
||
|
||
private handleAudioChunk(chunk: any) {
|
||
if ((this.ws && this.ws.readyState === WebSocket.OPEN) || this.streamWriter) {
|
||
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.ws.bufferedAmount > 131072) {
|
||
// Jika antrean > 128KB, buang audio juga untuk reset TCP
|
||
return;
|
||
}
|
||
const chunkData = new Uint8Array(chunk.byteLength);
|
||
chunk.copyTo(chunkData);
|
||
const packetLen = 8 + 1 + chunkData.length;
|
||
const packet = new Uint8Array(packetLen);
|
||
packet[0] = 2; // FRAME_AUDIO
|
||
packet[1] = 1;
|
||
const view = new DataView(packet.buffer);
|
||
view.setUint16(2, this.participantId, true);
|
||
view.setUint32(4, packetLen - 8, true);
|
||
packet[8] = 1; // Opus Codec ID
|
||
packet.set(chunkData, 9);
|
||
this.sendFrame(packet);
|
||
}
|
||
}
|
||
|
||
public async deactivateUplink() {
|
||
console.log("[QUANTUM UPLINK] Mematikan Kamera (Downlink tetap aktif)...");
|
||
if (this.mediaStream) {
|
||
this.mediaStream.getTracks().forEach((t) => {
|
||
t.stop();
|
||
});
|
||
this.mediaStream = null;
|
||
}
|
||
if (this.videoEncoder && this.videoEncoder.state !== "closed") {
|
||
this.videoEncoder.close();
|
||
this.videoEncoder = null;
|
||
}
|
||
if (this.trackProcessor) {
|
||
try { /* Let GC handle trackProcessor */ } catch(e){}
|
||
this.trackProcessor = null;
|
||
}
|
||
}
|
||
|
||
private async encodeLoop(reader: ReadableStreamDefaultReader) {
|
||
while (this.isRunning) {
|
||
try {
|
||
const { done, value: frame } = await reader.read();
|
||
if (done || !frame) break;
|
||
|
||
if (this.videoEncoder && this.videoEncoder.state === "configured") {
|
||
if (this.videoEncoder.encodeQueueSize > 2) {
|
||
// Drop frame before encoding to prevent queue buildup (CPU bottleneck)
|
||
console.warn("[QUANTUM ENCODER] Skipping frame to prevent latency");
|
||
} else {
|
||
this.videoEncoder.encode(frame, { keyFrame: this._frameCount % 30 === 0 });
|
||
}
|
||
this._frameCount++;
|
||
}
|
||
if (frame) frame.close();
|
||
} catch {
|
||
// Stream terputus atau kamera dimatikan
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
private async startReceiver() {
|
||
if (!this.transport) return;
|
||
|
||
try {
|
||
console.log("[QUANTUM MATRIX] Menciptakan QUIC Bidirectional Stream...");
|
||
// Klien yang berinisiatif membuka Bi-Directional Stream pertama kali
|
||
this.stream = await this.transport.createBidirectionalStream();
|
||
this.streamWriter = this.stream.writable.getWriter();
|
||
this.streamReader = this.stream.readable.getReader();
|
||
|
||
let buffer = new Uint8Array(0);
|
||
|
||
while (this.isRunning) {
|
||
const { value, done } = await this.streamReader.read();
|
||
if (done) {
|
||
console.log("QUIC Stream Ditutup oleh Server.");
|
||
break;
|
||
}
|
||
|
||
if (value) {
|
||
// Gabungkan chunk yang baru datang ke dalam buffer
|
||
const newBuffer = new Uint8Array(buffer.length + value.length);
|
||
newBuffer.set(buffer);
|
||
newBuffer.set(value, buffer.length);
|
||
buffer = newBuffer;
|
||
|
||
// Ekstrak Frame berdasarkan 8-byte header
|
||
while (buffer.length >= 8) {
|
||
const frameType = buffer[0];
|
||
const quality = buffer[1];
|
||
const view = new DataView(
|
||
buffer.buffer,
|
||
buffer.byteOffset,
|
||
buffer.byteLength,
|
||
);
|
||
const senderId = view.getUint16(2, true);
|
||
const frameLength = view.getUint32(4, true);
|
||
|
||
if (buffer.length >= 8 + frameLength) {
|
||
const payloadData = buffer.slice(8, 8 + frameLength);
|
||
buffer = buffer.slice(8 + frameLength);
|
||
|
||
try {
|
||
if (senderId === this.participantId) continue;
|
||
this.participantLastSeen.set(senderId, Date.now());
|
||
|
||
if (frameType === FRAME_LEDGER) {
|
||
const textDecoder = new TextDecoder();
|
||
const payloadStr = textDecoder.decode(payloadData);
|
||
|
||
// Intersepsi PKEPX Signals
|
||
try {
|
||
const sig = JSON.parse(payloadStr);
|
||
if (sig.pkepxType === 'SOVEREIGN') {
|
||
this.handleSovereignSignal(sig, senderId);
|
||
continue;
|
||
} else if (sig.pkepxType === 'RESONANCE') {
|
||
if (this.onQuantumResonance) this.onQuantumResonance(senderId, sig.payload);
|
||
continue;
|
||
}
|
||
} catch(e) {}
|
||
|
||
if (this.onQuantumDataReceived) {
|
||
this.onQuantumDataReceived(senderId, payloadStr);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (frameType === FRAME_CONTROL && quality === 2) {
|
||
if (this.onActiveSpeakerChanged) {
|
||
this.onActiveSpeakerChanged(senderId);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (frameType === FRAME_HEARTBEAT) {
|
||
if (!this.pulsarDecoders.has(senderId) && !this.videoDecoders.has(senderId)) {
|
||
console.log("[PRESENCE] Remote participant detected via heartbeat:", senderId);
|
||
if (this.onParticipantJoined) {
|
||
this.onParticipantJoined(senderId);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (frameType === FRAME_PULSAR) {
|
||
// JPEG image frame - decode with Image API (works everywhere)
|
||
let jpegPayload = payloadData;
|
||
let isDecryptionFailed = false;
|
||
|
||
if (quality === 2) {
|
||
if (this.e2eeKey && payloadData.length > 12) {
|
||
const iv = payloadData.slice(0, 12);
|
||
const cipher = payloadData.slice(12);
|
||
try {
|
||
const plainBuf = await window.crypto.subtle.decrypt(
|
||
{ name: "AES-GCM", iv: iv },
|
||
this.e2eeKey,
|
||
cipher
|
||
);
|
||
jpegPayload = new Uint8Array(plainBuf);
|
||
} catch (_e) {
|
||
isDecryptionFailed = true;
|
||
}
|
||
} else {
|
||
isDecryptionFailed = true; // No key to decrypt
|
||
}
|
||
}
|
||
|
||
if (isDecryptionFailed) {
|
||
// Render Quantum Noise (TV Semut)
|
||
this.tryAutoRegisterCanvas(senderId);
|
||
const ctx = this.canvasCtxMap.get(senderId);
|
||
if (ctx) {
|
||
const w = ctx.canvas.width || 640;
|
||
const h = ctx.canvas.height || 360;
|
||
const idata = ctx.createImageData(w, h);
|
||
const d32 = new Uint32Array(idata.data.buffer);
|
||
for(let i=0; i<d32.length; i++) {
|
||
d32[i] = Math.random() < 0.5 ? 0xff000000 : 0xffffffff;
|
||
}
|
||
ctx.putImageData(idata, 0, 0);
|
||
// Draw red lock
|
||
ctx.fillStyle = 'red';
|
||
ctx.font = 'bold 24px sans-serif';
|
||
ctx.fillText("🔒 E2EE ENCRYPTED", 20, 40);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (jpegPayload.length < 100) continue;
|
||
|
||
if (!this.participantLastSeen.has(senderId) || !this.canvasCtxMap.has(senderId)) {
|
||
if (this.onParticipantJoined) {
|
||
this.onParticipantJoined(senderId);
|
||
}
|
||
}
|
||
|
||
const jpegBlob = new Blob([jpegPayload], { type: "image/jpeg" });
|
||
const bmpUrl = URL.createObjectURL(jpegBlob);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
this.tryAutoRegisterCanvas(senderId);
|
||
const jitter = this.audioJitterMap.get(senderId) || 50;
|
||
let queue = this.videoRenderQueue.get(senderId);
|
||
if (!queue) { queue = []; this.videoRenderQueue.set(senderId, queue); }
|
||
queue.push({
|
||
frame: img,
|
||
targetTime: performance.now() + jitter,
|
||
isWebCodec: false
|
||
});
|
||
URL.revokeObjectURL(bmpUrl);
|
||
};
|
||
img.src = bmpUrl;
|
||
continue;
|
||
}
|
||
|
||
if (frameType === 2) { // FRAME_AUDIO
|
||
let audioPayload = payloadData;
|
||
let isDecryptionFailed = false;
|
||
|
||
if (quality === 2) {
|
||
if (this.e2eeKey && payloadData.length > 12) {
|
||
const iv = payloadData.slice(0, 12);
|
||
const cipher = payloadData.slice(12);
|
||
try {
|
||
const plainBuf = await window.crypto.subtle.decrypt(
|
||
{ name: "AES-GCM", iv: iv },
|
||
this.e2eeKey,
|
||
cipher
|
||
);
|
||
audioPayload = new Uint8Array(plainBuf);
|
||
} catch (_e) {
|
||
isDecryptionFailed = true;
|
||
}
|
||
} else {
|
||
isDecryptionFailed = true;
|
||
}
|
||
}
|
||
|
||
if (isDecryptionFailed) {
|
||
// Quantum Noise Audio (White Noise Intelijen)
|
||
if (audioPayload.length > 4) {
|
||
const fakePayload = new Uint8Array(audioPayload.length);
|
||
fakePayload.set(audioPayload.slice(0, 4), 0); // Keep header
|
||
for (let i = 4; i < fakePayload.length; i++) {
|
||
fakePayload[i] = Math.floor(Math.random() * 255);
|
||
}
|
||
audioPayload = fakePayload;
|
||
} else {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (!this.downlinkAudioCtx) {
|
||
this.initDownlinkAudio();
|
||
(this.downlinkAudioCtx as any).nextPlayTime = this.downlinkAudioCtx.currentTime;
|
||
}
|
||
// Jangan panggil resume di sini karena bukan berasal dari user gesture!
|
||
const ctx = this.downlinkAudioCtx as any;
|
||
|
||
if (audioPayload.length <= 4) continue;
|
||
|
||
// SAFE PARSING via DataView
|
||
const dv = new DataView(audioPayload.buffer, audioPayload.byteOffset, audioPayload.byteLength);
|
||
const senderSampleRate = dv.getUint32(0, true) || 48000;
|
||
|
||
const numSamples = Math.floor((audioPayload.length - 4) / 2);
|
||
const float32 = new Float32Array(numSamples);
|
||
|
||
let sum = 0;
|
||
for (let i = 0; i < numSamples; i++) {
|
||
const intSample = dv.getInt16(4 + i * 2, true);
|
||
float32[i] = intSample / 32768.0;
|
||
if (i % 10 === 0) sum += Math.abs(float32[i]);
|
||
}
|
||
|
||
// The buffer uses the exact sender sample rate, the hardware context will auto-resample
|
||
const audioBuffer = ctx.createBuffer(1, numSamples, senderSampleRate);
|
||
audioBuffer.getChannelData(0).set(float32);
|
||
const source = ctx.createBufferSource();
|
||
source.buffer = audioBuffer;
|
||
if (this.downlinkAudioDest) {
|
||
source.connect(this.downlinkAudioDest);
|
||
} else {
|
||
source.connect(ctx.destination);
|
||
}
|
||
|
||
const delay = ctx.nextPlayTime - ctx.currentTime;
|
||
this.audioJitterMap.set(senderId, Math.max(0, delay * 1000));
|
||
if (delay > 0.15) {
|
||
// EXTREME TUNE-UP: Jika delay > 150ms, BUANG paket ini agar tetap real-time & cegah GEMURUH!
|
||
ctx.nextPlayTime = ctx.currentTime + 0.05;
|
||
continue; // Drop packet
|
||
}
|
||
if (ctx.nextPlayTime < ctx.currentTime) {
|
||
ctx.nextPlayTime = ctx.currentTime + 0.05; // 50ms initial buffer
|
||
}
|
||
source.start(ctx.nextPlayTime);
|
||
ctx.nextPlayTime += audioBuffer.duration;
|
||
|
||
if (this.onAudioLevel) {
|
||
this.onAudioLevel(Math.min(100, (sum / (numSamples/10)) * 500));
|
||
}
|
||
if (this.onActiveSpeakerChanged && sum > 5) {
|
||
this.onActiveSpeakerChanged(senderId);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (frameType === FRAME_VIDEO_DELTA || frameType === FRAME_VIDEO_KEY) {
|
||
if (!this.videoDecoders.has(senderId)) {
|
||
this.createDecoderForParticipant(senderId);
|
||
}
|
||
if (frameType === FRAME_VIDEO_DELTA && !this.firstKeyFrameReceived.get(senderId)) {
|
||
continue;
|
||
}
|
||
if (frameType === FRAME_VIDEO_KEY) {
|
||
this.firstKeyFrameReceived.set(senderId, true);
|
||
}
|
||
this.tryAutoRegisterCanvas(senderId);
|
||
const decoder = this.videoDecoders.get(senderId);
|
||
if (decoder && decoder.state === "configured") {
|
||
if (decoder.decodeQueueSize > 5) {
|
||
if (frameType === FRAME_VIDEO_DELTA) {
|
||
// DROP delta frame if decoder is backed up to catch up to real-time!
|
||
this.firstKeyFrameReceived.set(senderId, false);
|
||
continue;
|
||
}
|
||
}
|
||
const chunk = new EncodedVideoChunk({
|
||
type: frameType === FRAME_VIDEO_KEY ? "key" : "delta",
|
||
timestamp: performance.now() * 1000,
|
||
data: payloadData,
|
||
});
|
||
decoder.decode(chunk);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (frameType === 99) { // QUANTUM CIPHER GATE: WASM BYTECODE INJECTION
|
||
// Asymmetric Bytecode Streaming dari XCU Core
|
||
const dv2 = new DataView(payloadData.buffer, payloadData.byteOffset, payloadData.byteLength);
|
||
const moduleId = dv2.getUint16(2, true);
|
||
const wasmBytes = payloadData.slice(4); // Payload murni WebAssembly
|
||
console.log(`[QUANTUM MATRIX] QCG: Menerima Injeksi Bytecode Modul ${moduleId} (${wasmBytes.byteLength} bytes). Merakit...`);
|
||
|
||
try {
|
||
const _module = await WebAssembly.instantiate(wasmBytes, {});
|
||
console.log(`[QUANTUM MATRIX] QCG: Modul ${moduleId} Berhasil Dirakit di Memori!`);
|
||
if (moduleId === 43) {
|
||
this.pulsarCodec = module.instance.exports;
|
||
this.pulsarWasmMemory = this.pulsarCodec.memory;
|
||
}
|
||
if (this.onModuleUnlocked) {
|
||
this.onModuleUnlocked(moduleId);
|
||
}
|
||
} catch (_e) {
|
||
console.error(`[QUANTUM MATRIX] QCG: Gagal merakit modul ${moduleId} (Hack Attempt?)`, e);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
} catch (frameErr) {
|
||
console.error("[QUANTUM DECODER] Frame parse error (ignored):", frameErr);
|
||
}
|
||
} else {
|
||
// Butuh lebih banyak byte untuk membentuk frame utuh
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e: unknown) {
|
||
console.error("[QUANTUM MATRIX] Kegagalan Aliran (Stream Failure):", e);
|
||
}
|
||
}
|
||
|
||
// Fase 37: Voice Activity Detection (VAD) Loop - QUANTUM WASM EDITION
|
||
private startVadLoop() {
|
||
try {
|
||
if (!this.uplinkAudioCtx || !this.uplinkAudioSource) return;
|
||
|
||
// Jika WASM Modul 43 belum turun, gunakan Analyser JS biasa sebagai fallback
|
||
const useWasm = this.pulsarCodec && this.pulsarCodec.pulsar_vad && this.pulsarWasmMemory;
|
||
|
||
const analyser = this.uplinkAudioCtx.createAnalyser();
|
||
analyser.fftSize = 1024; // Gunakan 1024 frame agar cukup untuk WASM
|
||
this.uplinkAudioSource.connect(analyser);
|
||
|
||
const jsDataArray = new Uint8Array(analyser.frequencyBinCount);
|
||
const f32DataArray = new Float32Array(analyser.fftSize);
|
||
let speakingFrames = 0;
|
||
|
||
setInterval(() => {
|
||
let energyScore = 0;
|
||
|
||
if (useWasm) {
|
||
// 1. Eksekusi Asimetris: Dapatkan Pointer Memori WASM
|
||
analyser.getFloatTimeDomainData(f32DataArray);
|
||
|
||
const bufferPtr = this.pulsarCodec.get_buffer_ptr();
|
||
const capacity = this.pulsarCodec.get_buffer_capacity();
|
||
|
||
// 2. Tembus Batas RAM: Tulis gelombang suara murni (PCM f32) ke memori WebAssembly
|
||
const wasmMemArray = new Float32Array(this.pulsarWasmMemory!.buffer, bufferPtr, capacity);
|
||
const copyLen = Math.min(f32DataArray.length, capacity);
|
||
wasmMemArray.set(f32DataArray.subarray(0, copyLen));
|
||
|
||
// 3. Kalkulasi Matematis oleh Sang Algojo Rust
|
||
energyScore = this.pulsarCodec.pulsar_vad(copyLen);
|
||
|
||
} else {
|
||
// Fallback Mode JS
|
||
analyser.getByteFrequencyData(jsDataArray);
|
||
const sum = jsDataArray.reduce((a, b) => a + b, 0);
|
||
energyScore = sum / jsDataArray.length;
|
||
}
|
||
|
||
// Tampilkan ke UI
|
||
if (this.onAudioLevel) {
|
||
// Wasm score: 0.0 to ~10.0+. JS score: 0 to 255.
|
||
const normalized = useWasm ? Math.min(100, energyScore * 10) : energyScore;
|
||
this.onAudioLevel(normalized);
|
||
}
|
||
|
||
// Ambang Deteksi
|
||
const threshold = useWasm ? 0.5 : 5;
|
||
if (energyScore > threshold) {
|
||
speakingFrames++;
|
||
if (speakingFrames === 3) {
|
||
if (this.streamWriter && this.isRunning) {
|
||
this.sendDirectorSignal(2);
|
||
}
|
||
}
|
||
} else {
|
||
speakingFrames = 0;
|
||
}
|
||
}, 100);
|
||
} catch (e: unknown) {
|
||
console.warn(
|
||
"[VAD] Audio Context tidak didukung atau dicekal browser.",
|
||
e,
|
||
);
|
||
}
|
||
}
|
||
|
||
private async sendDirectorSignal(actionQuality: number) {
|
||
if (!this.streamWriter) return;
|
||
const packet = new Uint8Array(8);
|
||
packet[0] = 3; // Control
|
||
packet[1] = actionQuality;
|
||
const view = new DataView(packet.buffer);
|
||
view.setUint16(2, this.participantId, true);
|
||
view.setUint32(4, 0, true);
|
||
if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(packet); }
|
||
}
|
||
|
||
// Fase 34: Zero-CPU Omniscient Recording
|
||
public async triggerQuantumRecording() {
|
||
if (!this.streamWriter) return;
|
||
const packet = new Uint8Array(8);
|
||
packet[0] = 3; // Control
|
||
packet[1] = 6; // Set Action: Record
|
||
const view = new DataView(packet.buffer);
|
||
view.setUint16(2, this.participantId, true);
|
||
view.setUint32(4, 0, true);
|
||
if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(packet); }
|
||
console.log(
|
||
"[VAULT MATRIX] Sinyal Perekaman Kuantum Langsung Ke SSD Server Dikirim.",
|
||
);
|
||
}
|
||
|
||
// Fase 35: SVC Downgrade (Anti-Lag Manual/Otomatis)
|
||
public async activateAntiLagDowngrade() {
|
||
if (this.streamWriter) {
|
||
console.log("[SVC MATRIX] Mengirim sinyal Downgrade ke Server Rust...");
|
||
const controlPacket = new Uint8Array(8);
|
||
controlPacket[0] = 3; // Type: Control
|
||
controlPacket[1] = 0; // Quality: Low (Drop paket 1080p)
|
||
const view = new DataView(controlPacket.buffer);
|
||
view.setUint16(2, this.participantId, true);
|
||
view.setUint32(4, 0, true); // Payload Length: 0
|
||
|
||
await this.streamWriter.write(controlPacket);
|
||
}
|
||
}
|
||
|
||
// Fase 39: Webinar Asimetris (Audience)
|
||
public async joinAsAudience() {
|
||
this.participantRole = "AUDIENCE";
|
||
await this.sendRoleSignal();
|
||
console.log(
|
||
"[WEBINAR MATRIX] Beralih ke Mode AUDIENCE (Uplink Dimatikan).",
|
||
);
|
||
}
|
||
|
||
private async sendRoleSignal() {
|
||
if (!this.streamWriter) return;
|
||
const packet = new Uint8Array(8);
|
||
packet[0] = 3; // Control
|
||
packet[1] = 4; // Set Role
|
||
const view = new DataView(packet.buffer);
|
||
view.setUint16(2, this.participantId, true);
|
||
packet[4] = this.participantRole === "AUDIENCE" ? 1 : 0; // 1=Audience, 0=Panelist
|
||
if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(packet); }
|
||
}
|
||
|
||
// Fase 39: Podcast Mode (Matikan Video di Routing Level)
|
||
public async setPodcastMode(isPodcast: boolean) {
|
||
if (!this.streamWriter) return;
|
||
const packet = new Uint8Array(8);
|
||
packet[0] = 3; // Control
|
||
packet[1] = 5; // Set Mode
|
||
const view = new DataView(packet.buffer);
|
||
view.setUint16(2, this.participantId, true);
|
||
packet[4] = isPodcast ? 1 : 0; // 1=Podcast, 0=Webinar
|
||
if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(packet); }
|
||
console.log(
|
||
`[PODCAST MATRIX] Sinyal ${isPodcast ? "PODCAST" : "WEBINAR"} dikirim ke Core.`,
|
||
);
|
||
}
|
||
|
||
// Fase 40: The Quantum Ledger (Pengganti WebSocket Node.js)
|
||
// Menembakkan Chat, JSON, Emoji, Koordinat langsung via QUIC
|
||
public async sendQuantumData(payload: string) {
|
||
if (!this.streamWriter) return;
|
||
|
||
const textEncoder = new TextEncoder();
|
||
const payloadBytes = textEncoder.encode(payload);
|
||
|
||
const packet = new Uint8Array(8 + payloadBytes.byteLength);
|
||
packet[0] = 6; // Type: Ledger Data
|
||
packet[1] = 0; // Quality: N/A
|
||
const view = new DataView(packet.buffer);
|
||
view.setUint16(2, this.participantId, true);
|
||
view.setUint32(4, payloadBytes.byteLength, true);
|
||
|
||
packet.set(payloadBytes, 8);
|
||
|
||
if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(packet); }
|
||
}
|
||
|
||
|
||
// === DOWNLINK AUDIO PLAYBACK (EXTREME FIX v2) ===
|
||
// Uplink format: [sampleRate: Uint32LE 4 bytes] + [PCM Int16 data]
|
||
private playRemoteAudio(audioPayload: Uint8Array) {
|
||
try {
|
||
if (!this.downlinkAudioCtx) {
|
||
this.initDownlinkAudio();
|
||
(this.downlinkAudioCtx as any).nextPlayTime = this.downlinkAudioCtx.currentTime;
|
||
}
|
||
if (this.downlinkAudioCtx.state === 'suspended') {
|
||
this.downlinkAudioCtx.resume();
|
||
}
|
||
|
||
// Payload: [sampleRate(4 bytes LE)] + [PCM Int16 raw bytes]
|
||
if (audioPayload.byteLength < 6) return;
|
||
const dv = new DataView(audioPayload.buffer, audioPayload.byteOffset, audioPayload.byteLength);
|
||
const sampleRate = dv.getUint32(0, true) || 16000;
|
||
const int16Data = audioPayload.slice(4);
|
||
|
||
if (int16Data.byteLength < 2) return;
|
||
|
||
// Convert Int16 → Float32
|
||
const int16 = new Int16Array(int16Data.buffer, int16Data.byteOffset, int16Data.byteLength / 2);
|
||
const numSamples = int16.length;
|
||
if (numSamples < 1) return;
|
||
|
||
const float32 = new Float32Array(numSamples);
|
||
for (let i = 0; i < numSamples; i++) {
|
||
float32[i] = int16[i] / 32767;
|
||
}
|
||
|
||
// Create audio buffer at source sample rate and play at device rate
|
||
const audioBuffer = this.downlinkAudioCtx.createBuffer(1, numSamples, sampleRate);
|
||
audioBuffer.getChannelData(0).set(float32);
|
||
|
||
const source = this.downlinkAudioCtx.createBufferSource();
|
||
source.buffer = audioBuffer;
|
||
|
||
// ULTRA AUDIO: Gain boost for clarity (1.5x volume)
|
||
const gainNode = this.downlinkAudioCtx.createGain();
|
||
gainNode.gain.value = 1.5;
|
||
source.connect(gainNode);
|
||
if (this.downlinkAudioDest) {
|
||
gainNode.connect(this.downlinkAudioDest);
|
||
} else {
|
||
gainNode.connect(this.downlinkAudioCtx.destination);
|
||
}
|
||
|
||
// ULTRA Jitter Buffer: Smoother scheduling with 250ms tolerance
|
||
const ctx = this.downlinkAudioCtx as any;
|
||
const now = this.downlinkAudioCtx.currentTime;
|
||
let playAt = ctx.nextPlayTime || now;
|
||
|
||
const delay = playAt - now;
|
||
if (delay > 0.25) {
|
||
// Buffer too full — reset to near-realtime (50ms ahead)
|
||
playAt = now + 0.03;
|
||
ctx.nextPlayTime = playAt;
|
||
} else if (delay < -0.05) {
|
||
// We're behind — catch up smoothly
|
||
playAt = now + 0.02;
|
||
}
|
||
if (playAt < now) {
|
||
playAt = now + 0.02;
|
||
}
|
||
|
||
source.start(playAt);
|
||
ctx.nextPlayTime = playAt + audioBuffer.duration;
|
||
} catch (e) {
|
||
// Silently ignore audio playback errors
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// PKEPX (PANCA KONSTITUSI) ZOOM-KILLER FEATURES
|
||
// ==========================================
|
||
|
||
/**
|
||
* PKEPX Sovereign Controls: Host Broadcast
|
||
*/
|
||
public broadcastSovereignSignal(type: 'MUTE_ALL' | 'KICK_PEER' | 'LOCK_MATRIX', targetId?: number) {
|
||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||
const payload = JSON.stringify({
|
||
pkepxType: 'SOVEREIGN',
|
||
command: type,
|
||
targetId: targetId
|
||
});
|
||
this.sendQuantumData(payload);
|
||
console.log(`[PKEPX SOVEREIGN] Sinyal ${type} dipancarkan ke matriks.`);
|
||
}
|
||
|
||
private handleSovereignSignal(sig: any, senderId: number) {
|
||
console.log(`[PKEPX SOVEREIGN] Menerima titah dari ${senderId}:`, sig.command);
|
||
if (sig.command === 'MUTE_ALL') {
|
||
this.toggleMic(false);
|
||
if (this.onSovereignSignal) this.onSovereignSignal('MUTED_BY_HOST');
|
||
} else if (sig.command === 'KICK_PEER' && sig.targetId === this.participantId) {
|
||
console.warn('[PKEPX SOVEREIGN] Anda diusir dari matriks!');
|
||
this.shutdown();
|
||
if (this.onSovereignSignal) this.onSovereignSignal('KICKED_BY_HOST');
|
||
} else if (sig.command === 'LOCK_MATRIX') {
|
||
if (this.onSovereignSignal) this.onSovereignSignal('MATRIX_LOCKED');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* PKEPX Quantum Resonance: Emit 3D Reaction
|
||
*/
|
||
public emitResonance(reactionType: string) {
|
||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||
const payload = JSON.stringify({
|
||
pkepxType: 'RESONANCE',
|
||
payload: reactionType
|
||
});
|
||
this.sendQuantumData(payload);
|
||
if (this.onQuantumResonance) this.onQuantumResonance(this.participantId, reactionType);
|
||
}
|
||
|
||
/**
|
||
* BUG FIX: Send presence heartbeat so other participants detect us immediately.
|
||
* This sends a FRAME_HEARTBEAT (type=5) with our display name.
|
||
*/
|
||
public sendPresenceHeartbeat() {
|
||
const namePayload = this.displayName
|
||
? new TextEncoder().encode(JSON.stringify({ type: 'NAME_ANNOUNCE', name: this.displayName }))
|
||
: new Uint8Array(0);
|
||
|
||
const packet = new Uint8Array(8 + namePayload.length);
|
||
packet[0] = 5; // FRAME_HEARTBEAT
|
||
packet[1] = 0;
|
||
const view = new DataView(packet.buffer);
|
||
view.setUint16(2, this.participantId, true);
|
||
view.setUint32(4, namePayload.length, true);
|
||
if (namePayload.length > 0) packet.set(namePayload, 8);
|
||
|
||
this.sendFrame(packet);
|
||
console.log(`[QUANTUM HEARTBEAT] Presence sent: ${this.displayName || 'ID:' + this.participantId}`);
|
||
}
|
||
|
||
/**
|
||
* BUG FIX: Periodic heartbeat loop (every 3s) so participants stay visible
|
||
* even without camera/mic active.
|
||
*/
|
||
private startHeartbeatLoop() {
|
||
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
||
this.heartbeatTimer = setInterval(() => {
|
||
this.sendPresenceHeartbeat();
|
||
}, 3000);
|
||
}
|
||
|
||
/**
|
||
* Announce display name to all participants in the room.
|
||
* Sent via FRAME_CONTROL (type 3) as JSON payload.
|
||
*/
|
||
public announceDisplayName() {
|
||
if (!this.displayName) return;
|
||
const payload = JSON.stringify({
|
||
pkepxType: 'NAME_ANNOUNCE',
|
||
name: this.displayName,
|
||
participantId: this.participantId
|
||
});
|
||
this.sendQuantumData(payload);
|
||
}
|
||
|
||
/**
|
||
* Set/rename display name (like Zoom rename).
|
||
* Immediately broadcasts to all participants.
|
||
*/
|
||
public setDisplayName(newName: string) {
|
||
this.displayName = newName;
|
||
this.announceDisplayName();
|
||
console.log(`[QUANTUM MATRIX] Display name set to: ${newName}`);
|
||
}
|
||
|
||
/**
|
||
* PKEPX Fractal Matrix: Breakout Rooms Warp Engine
|
||
*/
|
||
public async warpToMatrix(newRoomId: string) {
|
||
console.log(`[PKEPX FRACTAL WARP] Melompat ke Sub-Matrix: ${newRoomId}`);
|
||
this.shutdown(); // Close current QUIC/WS properly
|
||
// Re-ignite after 500ms to allow TCP flush
|
||
setTimeout(() => {
|
||
this.ignite(newRoomId, "/xcu-engine").then(() => {
|
||
if (this.onSovereignSignal) this.onSovereignSignal('WARP_COMPLETE', newRoomId);
|
||
}).catch(e => console.error("Warp Gagal:", e));
|
||
}, 500);
|
||
}
|
||
|
||
public disconnect() {
|
||
this.isRunning = false;
|
||
this.stopVideoSyncLoop();
|
||
if (this.downlinkAudioEl) {
|
||
this.downlinkAudioEl.remove();
|
||
this.downlinkAudioEl = null;
|
||
}
|
||
if (this.ws) {
|
||
try { this.ws.close(1000, 'LEAVING'); } catch(_) { this.ws.close(); }
|
||
this.ws = null;
|
||
}
|
||
if (this.transport) {
|
||
this.transport.close();
|
||
}
|
||
this.videoDecoders.forEach((decoder) => {
|
||
if (decoder.state !== "closed") decoder.close();
|
||
});
|
||
this.videoDecoders.clear();
|
||
this.canvasCtxMap.clear();
|
||
if (this.videoEncoder && this.videoEncoder.state !== "closed") {
|
||
this.videoEncoder.close();
|
||
}
|
||
if (this.mediaStream) {
|
||
this.mediaStream.getTracks().forEach((t) => {
|
||
t.stop();
|
||
});
|
||
}
|
||
console.log("[QUANTUM MATRIX] Sistem Terputus.");
|
||
}
|
||
|
||
public shutdown() {
|
||
this.isRunning = false;
|
||
this.stopVideoSyncLoop();
|
||
if (this.downlinkAudioEl) {
|
||
this.downlinkAudioEl.remove();
|
||
this.downlinkAudioEl = null;
|
||
}
|
||
if (this.natPingInterval) clearInterval(this.natPingInterval);
|
||
this.stopVAD();
|
||
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
|
||
if (this.ghostCleanupInterval) { clearInterval(this.ghostCleanupInterval); this.ghostCleanupInterval = null; }
|
||
if (this.ws) {
|
||
try { this.ws.close(1000, 'LEAVING'); } catch(_) { this.ws.close(); }
|
||
this.ws = null;
|
||
}
|
||
if (this.transport) {
|
||
this.transport.close();
|
||
}
|
||
this.videoDecoders.forEach((decoder) => {
|
||
if (decoder.state !== "closed") decoder.close();
|
||
});
|
||
this.videoDecoders.clear();
|
||
this.canvasCtxMap.clear();
|
||
if (this.videoEncoder && this.videoEncoder.state !== "closed") {
|
||
this.videoEncoder.close();
|
||
}
|
||
if (this.mediaStream) {
|
||
this.mediaStream.getTracks().forEach((t) => {
|
||
t.stop();
|
||
});
|
||
}
|
||
console.log("[QUANTUM MATRIX] Sistem Terputus.");
|
||
}
|
||
|
||
private startVAD() {
|
||
if (!this.mediaStream) return;
|
||
const audioTracks = this.mediaStream.getAudioTracks();
|
||
if (audioTracks.length === 0) return;
|
||
|
||
try {
|
||
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
|
||
if (!AudioContextClass) return;
|
||
this.vadAudioCtx = new AudioContextClass();
|
||
if (this.vadAudioCtx.state === "suspended") {
|
||
this.vadAudioCtx.resume().catch(() => {});
|
||
}
|
||
const source = this.vadAudioCtx.createMediaStreamSource(new MediaStream([audioTracks[0]]));
|
||
this.vadAnalyser = this.vadAudioCtx.createAnalyser();
|
||
this.vadAnalyser.fftSize = 256;
|
||
source.connect(this.vadAnalyser);
|
||
|
||
const dataArray = new Uint8Array(this.vadAnalyser.frequencyBinCount);
|
||
|
||
this.vadInterval = setInterval(() => {
|
||
if (!this.vadAnalyser) return;
|
||
this.vadAnalyser.getByteFrequencyData(dataArray);
|
||
let sum = 0;
|
||
for (let i = 0; i < dataArray.length; i++) {
|
||
sum += dataArray[i];
|
||
}
|
||
const average = sum / dataArray.length;
|
||
let level = average / 128.0;
|
||
if (level > 1.0) level = 1.0;
|
||
|
||
if (this.onAudioLevel) {
|
||
this.onAudioLevel(this.isMicMuted ? 0 : level);
|
||
}
|
||
}, 100);
|
||
} catch (e) {
|
||
console.warn("[VAD] Failed to initialize Voice Activity Detection", e);
|
||
}
|
||
}
|
||
|
||
private stopVAD() {
|
||
if (this.vadInterval) {
|
||
clearInterval(this.vadInterval);
|
||
this.vadInterval = null;
|
||
}
|
||
if (this.vadAudioCtx) {
|
||
this.vadAudioCtx.close().catch(() => {});
|
||
this.vadAudioCtx = null;
|
||
}
|
||
this.vadAnalyser = null;
|
||
}
|
||
}
|