|
|
|
|
@@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse {
|
|
|
|
|
|
|
|
|
|
async fn service_worker() -> impl IntoResponse {
|
|
|
|
|
([(header::CONTENT_TYPE, "application/javascript")], r##"
|
|
|
|
|
const CACHE = 'wz-v28';
|
|
|
|
|
const CACHE = 'wz-v29';
|
|
|
|
|
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
|
|
|
|
|
|
|
|
|
|
self.addEventListener('install', e => {
|
|
|
|
|
@@ -288,7 +288,7 @@ let pollTimer = null;
|
|
|
|
|
let ws = null; // WebSocket connection
|
|
|
|
|
let wasmReady = false;
|
|
|
|
|
|
|
|
|
|
const VERSION = '0.0.46';
|
|
|
|
|
const VERSION = '0.0.47';
|
|
|
|
|
let DEBUG = true; // toggle with /debug command
|
|
|
|
|
|
|
|
|
|
// ── Receipt tracking ──
|
|
|
|
|
@@ -1265,6 +1265,8 @@ async function sendToGroup(groupName, text) {
|
|
|
|
|
|
|
|
|
|
let callState = 'idle'; // idle, calling, ringing, active
|
|
|
|
|
let callPeer = null;
|
|
|
|
|
let audioVariant = localStorage.getItem('wz-audio-variant') || 'pure';
|
|
|
|
|
let wzpClient = null; // The loaded WZP variant client instance
|
|
|
|
|
|
|
|
|
|
// Group call state
|
|
|
|
|
let groupCallRoom = null; // Current group call room name
|
|
|
|
|
@@ -1307,7 +1309,7 @@ function updateCallUI() {
|
|
|
|
|
btnReject.style.display = '';
|
|
|
|
|
break;
|
|
|
|
|
case 'active':
|
|
|
|
|
status.textContent = '\u{1F50A} In call with ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '?').slice(0, 16));
|
|
|
|
|
status.textContent = '\u{1F50A} In call [' + audioVariant + '] with ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '?').slice(0, 16));
|
|
|
|
|
status.className = 'call-status';
|
|
|
|
|
btnHangup.style.display = '';
|
|
|
|
|
break;
|
|
|
|
|
@@ -1566,14 +1568,13 @@ function sendCallNotification(title, body) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function startAudio() {
|
|
|
|
|
// Fetch relay config (includes auth token)
|
|
|
|
|
// Fetch relay config
|
|
|
|
|
let relayAddr, authToken;
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(SERVER + '/v1/wzp/relay-config');
|
|
|
|
|
const data = await resp.json();
|
|
|
|
|
relayAddr = data.relay_addr;
|
|
|
|
|
authToken = data.token;
|
|
|
|
|
dbg('Relay address:', relayAddr, 'token:', authToken);
|
|
|
|
|
} catch(e) {
|
|
|
|
|
addSys('Audio: cannot get relay config \u2014 ' + e.message);
|
|
|
|
|
return;
|
|
|
|
|
@@ -1591,7 +1592,7 @@ async function startAudio() {
|
|
|
|
|
|
|
|
|
|
audioCtx = new AudioContext({ sampleRate: 48000 });
|
|
|
|
|
|
|
|
|
|
// Deterministic room: sort both fingerprints so both peers get the same room
|
|
|
|
|
// Deterministic room name
|
|
|
|
|
const myFP = normFP(myFingerprint);
|
|
|
|
|
const peerFP = callPeer ? normFP(callPeer) : '';
|
|
|
|
|
const roomPair = [myFP, peerFP].sort().join('-');
|
|
|
|
|
@@ -1600,40 +1601,154 @@ async function startAudio() {
|
|
|
|
|
const proto = host.startsWith('localhost') || host.startsWith('127.') ? 'ws:' : 'wss:';
|
|
|
|
|
const wsUrl = proto + '//' + host + '/ws/' + room;
|
|
|
|
|
|
|
|
|
|
addSys('Audio: connecting to room ' + room.slice(0, 12) + '...');
|
|
|
|
|
addSys('Audio [' + audioVariant + ']: connecting to room ' + room.slice(0, 12) + '...');
|
|
|
|
|
|
|
|
|
|
// Load variant JS from wzp-web if not already loaded
|
|
|
|
|
const variantClass = await loadAudioVariant(audioVariant);
|
|
|
|
|
if (!variantClass) {
|
|
|
|
|
addSys('Audio: failed to load variant "' + audioVariant + '", falling back to pure');
|
|
|
|
|
// Fallback to inline pure implementation
|
|
|
|
|
startAudioPure(wsUrl, authToken, room);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create variant client
|
|
|
|
|
wzpClient = new variantClass({
|
|
|
|
|
wsUrl: wsUrl,
|
|
|
|
|
room: room,
|
|
|
|
|
authToken: authToken,
|
|
|
|
|
onAudio: (pcm) => {
|
|
|
|
|
if (!audioCtx) return;
|
|
|
|
|
const float32 = new Float32Array(pcm.length);
|
|
|
|
|
for (let i = 0; i < pcm.length; i++) float32[i] = pcm[i] / 32768.0;
|
|
|
|
|
const buffer = audioCtx.createBuffer(1, float32.length, 48000);
|
|
|
|
|
buffer.getChannelData(0).set(float32);
|
|
|
|
|
const src = audioCtx.createBufferSource();
|
|
|
|
|
src.buffer = buffer;
|
|
|
|
|
src.connect(audioCtx.destination);
|
|
|
|
|
src.start();
|
|
|
|
|
},
|
|
|
|
|
onStatus: (msg) => addSys('Audio: ' + msg),
|
|
|
|
|
onStats: (stats) => dbg('Audio stats:', stats),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Load WASM if variant needs it
|
|
|
|
|
if (wzpClient.loadWasm) {
|
|
|
|
|
try {
|
|
|
|
|
addSys('Audio: loading WASM module...');
|
|
|
|
|
await wzpClient.loadWasm();
|
|
|
|
|
} catch(e) {
|
|
|
|
|
addSys('Audio: WASM load failed \u2014 ' + e.message);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Connect
|
|
|
|
|
try {
|
|
|
|
|
await wzpClient.connect();
|
|
|
|
|
} catch(e) {
|
|
|
|
|
addSys('Audio: connection failed \u2014 ' + e.message);
|
|
|
|
|
wzpClient = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start mic capture -> variant client
|
|
|
|
|
const source = audioCtx.createMediaStreamSource(mediaStream);
|
|
|
|
|
const processor = audioCtx.createScriptProcessor(1024, 1, 1);
|
|
|
|
|
let captureBuffer = new Float32Array(0);
|
|
|
|
|
|
|
|
|
|
processor.onaudioprocess = (e) => {
|
|
|
|
|
if (callState !== 'active' || !wzpClient) return;
|
|
|
|
|
const input = e.inputBuffer.getChannelData(0);
|
|
|
|
|
const combined = new Float32Array(captureBuffer.length + input.length);
|
|
|
|
|
combined.set(captureBuffer);
|
|
|
|
|
combined.set(input, captureBuffer.length);
|
|
|
|
|
captureBuffer = combined;
|
|
|
|
|
|
|
|
|
|
while (captureBuffer.length >= 960) {
|
|
|
|
|
const frame = captureBuffer.slice(0, 960);
|
|
|
|
|
captureBuffer = captureBuffer.slice(960);
|
|
|
|
|
const pcm = new Int16Array(frame.length);
|
|
|
|
|
for (let i = 0; i < frame.length; i++) {
|
|
|
|
|
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
|
|
|
|
}
|
|
|
|
|
wzpClient.sendAudio(pcm.buffer);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
source.connect(processor);
|
|
|
|
|
processor.connect(audioCtx.destination);
|
|
|
|
|
captureNode = processor;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dynamically load a WZP variant JS file from the audio bridge
|
|
|
|
|
async function loadAudioVariant(variant) {
|
|
|
|
|
const classMap = {
|
|
|
|
|
pure: 'WZPPureClient',
|
|
|
|
|
hybrid: 'WZPHybridClient',
|
|
|
|
|
full: 'WZPFullClient',
|
|
|
|
|
ws: 'WZPWsClient',
|
|
|
|
|
'ws-fec': 'WZPWsFecClient',
|
|
|
|
|
'ws-full': 'WZPWsFullClient',
|
|
|
|
|
};
|
|
|
|
|
const fileMap = {
|
|
|
|
|
pure: 'wzp-pure.js',
|
|
|
|
|
hybrid: 'wzp-hybrid.js',
|
|
|
|
|
full: 'wzp-full.js',
|
|
|
|
|
ws: 'wzp-ws.js',
|
|
|
|
|
'ws-fec': 'wzp-ws-fec.js',
|
|
|
|
|
'ws-full': 'wzp-ws-full.js',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const className = classMap[variant];
|
|
|
|
|
if (!className) return null;
|
|
|
|
|
|
|
|
|
|
// Already loaded?
|
|
|
|
|
if (window[className]) return window[className];
|
|
|
|
|
|
|
|
|
|
// Load from wzp-web's static files via the /audio/ Caddy path
|
|
|
|
|
const file = fileMap[variant];
|
|
|
|
|
if (!file) return null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const script = document.createElement('script');
|
|
|
|
|
script.src = SERVER + '/audio/js/' + file;
|
|
|
|
|
await new Promise((resolve, reject) => {
|
|
|
|
|
script.onload = resolve;
|
|
|
|
|
script.onerror = reject;
|
|
|
|
|
document.head.appendChild(script);
|
|
|
|
|
});
|
|
|
|
|
return window[className] || null;
|
|
|
|
|
} catch(e) {
|
|
|
|
|
dbg('Failed to load variant script:', e);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: inline pure audio (original implementation, no external JS)
|
|
|
|
|
function startAudioPure(wsUrl, authToken, room) {
|
|
|
|
|
audioWs = new WebSocket(wsUrl);
|
|
|
|
|
audioWs.binaryType = 'arraybuffer';
|
|
|
|
|
|
|
|
|
|
audioWs.onopen = async () => {
|
|
|
|
|
// Send auth token as first message (required by wzp-web --auth-url)
|
|
|
|
|
audioWs.send(JSON.stringify({ type: 'auth', token: authToken }));
|
|
|
|
|
addSys('Audio: connected \u2014 mic active');
|
|
|
|
|
addSys('Audio [pure-inline]: connected');
|
|
|
|
|
|
|
|
|
|
// Capture: mic -> PCM frames -> WS
|
|
|
|
|
const source = audioCtx.createMediaStreamSource(mediaStream);
|
|
|
|
|
|
|
|
|
|
// Use ScriptProcessor as fallback (AudioWorklet needs a separate file)
|
|
|
|
|
const bufferSize = 960; // 20ms at 48kHz
|
|
|
|
|
const processor = audioCtx.createScriptProcessor(1024, 1, 1);
|
|
|
|
|
let captureBuffer = new Float32Array(0);
|
|
|
|
|
|
|
|
|
|
processor.onaudioprocess = (e) => {
|
|
|
|
|
if (callState !== 'active' || !audioWs || audioWs.readyState !== WebSocket.OPEN) return;
|
|
|
|
|
const input = e.inputBuffer.getChannelData(0);
|
|
|
|
|
|
|
|
|
|
// Accumulate samples
|
|
|
|
|
const combined = new Float32Array(captureBuffer.length + input.length);
|
|
|
|
|
combined.set(captureBuffer);
|
|
|
|
|
combined.set(input, captureBuffer.length);
|
|
|
|
|
captureBuffer = combined;
|
|
|
|
|
|
|
|
|
|
// Send 960-sample frames (20ms)
|
|
|
|
|
while (captureBuffer.length >= bufferSize) {
|
|
|
|
|
const frame = captureBuffer.slice(0, bufferSize);
|
|
|
|
|
captureBuffer = captureBuffer.slice(bufferSize);
|
|
|
|
|
|
|
|
|
|
// Convert float32 to int16
|
|
|
|
|
while (captureBuffer.length >= 960) {
|
|
|
|
|
const frame = captureBuffer.slice(0, 960);
|
|
|
|
|
captureBuffer = captureBuffer.slice(960);
|
|
|
|
|
const pcm = new Int16Array(frame.length);
|
|
|
|
|
for (let i = 0; i < frame.length; i++) {
|
|
|
|
|
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
|
|
|
|
@@ -1643,42 +1758,26 @@ async function startAudio() {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
source.connect(processor);
|
|
|
|
|
processor.connect(audioCtx.destination); // needed to keep processor alive
|
|
|
|
|
processor.connect(audioCtx.destination);
|
|
|
|
|
captureNode = processor;
|
|
|
|
|
|
|
|
|
|
// Playback buffer
|
|
|
|
|
playbackNode = { queue: [] };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
audioWs.onmessage = (event) => {
|
|
|
|
|
if (!audioCtx) return;
|
|
|
|
|
const pcm = new Int16Array(event.data);
|
|
|
|
|
if (pcm.length === 0) return;
|
|
|
|
|
|
|
|
|
|
// Convert int16 to float32 and play
|
|
|
|
|
const float32 = new Float32Array(pcm.length);
|
|
|
|
|
for (let i = 0; i < pcm.length; i++) {
|
|
|
|
|
float32[i] = pcm[i] / 32768.0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < pcm.length; i++) float32[i] = pcm[i] / 32768.0;
|
|
|
|
|
const buffer = audioCtx.createBuffer(1, float32.length, 48000);
|
|
|
|
|
buffer.getChannelData(0).set(float32);
|
|
|
|
|
const source = audioCtx.createBufferSource();
|
|
|
|
|
source.buffer = buffer;
|
|
|
|
|
source.connect(audioCtx.destination);
|
|
|
|
|
source.start();
|
|
|
|
|
const src = audioCtx.createBufferSource();
|
|
|
|
|
src.buffer = buffer;
|
|
|
|
|
src.connect(audioCtx.destination);
|
|
|
|
|
src.start();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
audioWs.onclose = () => {
|
|
|
|
|
if (callState === 'active') {
|
|
|
|
|
addSys('Audio: disconnected');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
audioWs.onerror = (e) => {
|
|
|
|
|
addSys('Audio: connection error');
|
|
|
|
|
dbg('Audio WS error:', e);
|
|
|
|
|
};
|
|
|
|
|
audioWs.onclose = () => { if (callState === 'active') addSys('Audio: disconnected'); };
|
|
|
|
|
audioWs.onerror = () => { addSys('Audio: connection error'); };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopAudio() {
|
|
|
|
|
@@ -1686,6 +1785,10 @@ function stopAudio() {
|
|
|
|
|
audioWs.close();
|
|
|
|
|
audioWs = null;
|
|
|
|
|
}
|
|
|
|
|
if (wzpClient) {
|
|
|
|
|
wzpClient.disconnect();
|
|
|
|
|
wzpClient = null;
|
|
|
|
|
}
|
|
|
|
|
if (captureNode) {
|
|
|
|
|
captureNode.disconnect();
|
|
|
|
|
captureNode = null;
|
|
|
|
|
@@ -1966,6 +2069,24 @@ async function doSend() {
|
|
|
|
|
if (text === '/hangup' || text === '/end') { hangupCall(); return; }
|
|
|
|
|
if (text === '/accept') { acceptCall(); return; }
|
|
|
|
|
if (text === '/reject') { rejectCall(); return; }
|
|
|
|
|
if (text.startsWith('/audio-variant')) {
|
|
|
|
|
const parts = text.split(/\s+/);
|
|
|
|
|
if (parts.length < 2) {
|
|
|
|
|
addSys('Audio variant: ' + audioVariant);
|
|
|
|
|
addSys('Options: pure, hybrid, full, ws, ws-fec, ws-full');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const v = parts[1].toLowerCase();
|
|
|
|
|
const valid = ['pure', 'hybrid', 'full', 'ws', 'ws-fec', 'ws-full'];
|
|
|
|
|
if (!valid.includes(v)) {
|
|
|
|
|
addSys('Unknown variant: ' + v + '. Options: ' + valid.join(', '));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
audioVariant = v;
|
|
|
|
|
localStorage.setItem('wz-audio-variant', v);
|
|
|
|
|
addSys('Audio variant set to: ' + v + ' (takes effect on next call)');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (text === '/reset') {
|
|
|
|
|
localStorage.clear();
|
|
|
|
|
addSys('localStorage cleared. Refresh the page to start fresh.');
|
|
|
|
|
@@ -2061,6 +2182,7 @@ async function doSend() {
|
|
|
|
|
addSys(' /bundleinfo \u2014 debug key bundle info');
|
|
|
|
|
addSys(' /sessions \u2014 list cached sessions');
|
|
|
|
|
addSys(' /selftest \u2014 run WASM self-test');
|
|
|
|
|
addSys(' /audio-variant [v] \u2014 set audio stack (pure/hybrid/full/ws/ws-fec/ws-full)');
|
|
|
|
|
addSys(' /debug \u2014 toggle debug mode');
|
|
|
|
|
addSys(' /reset \u2014 clear all local data');
|
|
|
|
|
return;
|
|
|
|
|
|