3 new WZP-WS variants (speak WZP wire format over WebSocket):
- wzp-ws.js (Variant 4): WZP MediaHeader + raw PCM, no WASM
- wzp-ws-fec.js (Variant 5): WZP + WASM RaptorQ FEC (block=5, symbol=2048)
- wzp-ws-full.js (Variant 6): WZP + FEC + ChaCha20-Poly1305 E2E encryption
Wire protocol compliance (verified against wzp-proto/src/packet.rs):
- MediaHeader 12-byte bit layout: V(1)|T(1)|CodecID(4)|Q(1)|FecRatioHi(1)
- FEC ratio 7-bit encoding across byte0-byte1 boundary
- All fields big-endian (seq u16, timestamp u32)
- Crypto nonce: session_id[4] + seq_be[4] + direction[1] + pad[3]
- HKDF info: "warzone-session-key" (matches wzp-crypto)
Auth flow (matches wzp-relay/src/ws.rs):
- First WS message: {"type":"auth","token":"..."}
- Relay responds: {"type":"auth_ok"} or {"type":"auth_error"}
- All 6 variants handle auth_ok/auth_error text messages
Updated:
- wzp-core.js: detectVariant() accepts ws, ws-fec, ws-full
- index.html: script map + ClientClass dispatch for all 6 variants
- index.html: WASM auto-loading for variants with loadWasm()
URL patterns:
?variant=pure Variant 1: Raw PCM over WS (bridge needed)
?variant=hybrid Variant 2: Raw PCM + WASM FEC (bridge needed)
?variant=full Variant 3: WebTransport + FEC + crypto (no bridge)
?variant=ws Variant 4: WZP protocol over WS (relay direct)
?variant=ws-fec Variant 5: WZP + FEC over WS (relay direct)
?variant=ws-full Variant 6: WZP + FEC + E2E crypto over WS (relay direct)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
216 lines
8.0 KiB
HTML
216 lines
8.0 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>WarzonePhone</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #1a1a2e; color: #e0e0e0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
|
.container { text-align: center; max-width: 420px; padding: 2rem; }
|
|
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
|
|
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
|
.variant-badge { display: inline-block; background: #2a2a4a; border: 1px solid #444; color: #00d4ff; font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 4px; margin-left: 0.4rem; vertical-align: middle; font-family: monospace; letter-spacing: 0.05em; }
|
|
.variant-selector { margin-bottom: 1.2rem; display: flex; gap: 0.8rem; justify-content: center; flex-wrap: wrap; }
|
|
.variant-selector label { font-size: 0.75rem; color: #888; cursor: pointer; display: flex; align-items: center; gap: 0.25rem; }
|
|
.variant-selector input[type="radio"] { accent-color: #00d4ff; }
|
|
.room-input { margin-bottom: 1.5rem; }
|
|
.room-input input { background: #2a2a4a; border: 1px solid #444; color: #e0e0e0; padding: 0.6rem 1rem; font-size: 1rem; border-radius: 8px; width: 200px; text-align: center; }
|
|
.room-input input:focus { outline: none; border-color: #00d4ff; }
|
|
.room-input label { display: block; color: #888; font-size: 0.8rem; margin-bottom: 0.4rem; }
|
|
#callBtn { background: #00d4ff; color: #1a1a2e; border: none; padding: 1rem 3rem; font-size: 1.2rem; border-radius: 50px; cursor: pointer; transition: all 0.2s; }
|
|
#callBtn:hover { background: #00b8d4; transform: scale(1.05); }
|
|
#callBtn.active { background: #ff4444; color: white; }
|
|
#callBtn:disabled { background: #444; color: #888; cursor: not-allowed; transform: none; }
|
|
.status { margin-top: 1.5rem; font-size: 0.9rem; color: #888; min-height: 1.5rem; }
|
|
.stats { margin-top: 0.5rem; font-size: 0.75rem; color: #555; font-family: monospace; }
|
|
.level { margin-top: 1rem; height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
|
|
.level-bar { height: 100%; background: #00d4ff; width: 0%; transition: width 50ms; }
|
|
.controls { margin-top: 1rem; display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
|
|
.controls label { font-size: 0.8rem; color: #888; cursor: pointer; display: flex; align-items: center; gap: 0.3rem; }
|
|
.controls input[type="checkbox"] { accent-color: #00d4ff; }
|
|
#pttBtn { display: none; background: #444; color: #e0e0e0; border: 2px solid #666; padding: 0.8rem 2rem; font-size: 1rem; border-radius: 12px; cursor: pointer; user-select: none; -webkit-user-select: none; touch-action: none; }
|
|
#pttBtn.transmitting { background: #ff4444; border-color: #ff6666; color: white; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>WarzonePhone <span class="variant-badge" id="variantBadge">PURE</span></h1>
|
|
<p class="subtitle">Lossy VoIP Protocol</p>
|
|
|
|
<div class="variant-selector">
|
|
<label><input type="radio" name="variant" value="pure"> Pure JS</label>
|
|
<label><input type="radio" name="variant" value="hybrid"> Hybrid</label>
|
|
<label><input type="radio" name="variant" value="full"> Full WASM</label>
|
|
</div>
|
|
|
|
<div class="room-input">
|
|
<label for="room">Room</label>
|
|
<input type="text" id="room" placeholder="enter room name" value="">
|
|
</div>
|
|
<button id="callBtn">Connect</button>
|
|
<div class="controls" id="controls" style="display:none;">
|
|
<label><input type="checkbox" id="pttMode"> Radio mode (push-to-talk)</label>
|
|
</div>
|
|
<button id="pttBtn">Hold to Talk</button>
|
|
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
|
<div class="status" id="status"></div>
|
|
<div class="stats" id="stats"></div>
|
|
</div>
|
|
|
|
<script src="js/wzp-core.js"></script>
|
|
<script>
|
|
// ---------------------------------------------------------------------------
|
|
// Load the selected variant script dynamically
|
|
// ---------------------------------------------------------------------------
|
|
(function() {
|
|
var variant = WZPCore.detectVariant();
|
|
var scriptMap = {
|
|
pure: 'js/wzp-pure.js',
|
|
hybrid: 'js/wzp-hybrid.js',
|
|
full: 'js/wzp-full.js',
|
|
'ws': 'js/wzp-ws.js',
|
|
'ws-fec': 'js/wzp-ws-fec.js',
|
|
'ws-full': 'js/wzp-ws-full.js',
|
|
};
|
|
var src = scriptMap[variant] || scriptMap.pure;
|
|
var s = document.createElement('script');
|
|
s.src = src;
|
|
s.onload = function() { wzpBoot(); };
|
|
s.onerror = function() {
|
|
WZPCore.updateStatus('Failed to load variant: ' + variant);
|
|
};
|
|
document.body.appendChild(s);
|
|
})();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Boot: wire UI to the loaded client variant
|
|
// ---------------------------------------------------------------------------
|
|
function wzpBoot() {
|
|
var client = null;
|
|
var capture = null;
|
|
var playback = null;
|
|
var transmitting = true;
|
|
|
|
var ui = WZPCore.initUI({
|
|
onConnect: function(room) {
|
|
doConnect(room);
|
|
},
|
|
onDisconnect: function() {
|
|
doDisconnect();
|
|
},
|
|
onTransmit: function(tx) {
|
|
transmitting = tx;
|
|
},
|
|
});
|
|
|
|
async function doConnect(room) {
|
|
WZPCore.updateStatus('Requesting microphone...');
|
|
|
|
var audioCtx;
|
|
try {
|
|
audioCtx = await WZPCore.startAudioContext();
|
|
} catch (e) {
|
|
WZPCore.updateStatus('Audio init failed: ' + e.message);
|
|
ui.setConnected(false);
|
|
return;
|
|
}
|
|
|
|
// Build WebSocket URL
|
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
var wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
|
|
|
// Create client based on detected variant
|
|
var variant = WZPCore.detectVariant();
|
|
var ClientClass = {
|
|
pure: window.WZPPureClient,
|
|
hybrid: window.WZPHybridClient,
|
|
full: window.WZPFullClient,
|
|
'ws': window.WZPWsClient,
|
|
'ws-fec': window.WZPWsFecClient,
|
|
'ws-full': window.WZPWsFullClient,
|
|
}[variant] || window.WZPPureClient;
|
|
|
|
var clientOpts = {
|
|
wsUrl: wsUrl,
|
|
room: room,
|
|
onAudio: function(pcm) {
|
|
if (playback) playback.play(pcm);
|
|
},
|
|
onStatus: function(msg) {
|
|
WZPCore.updateStatus(msg);
|
|
},
|
|
onStats: function(stats) {
|
|
WZPCore.updateStats(stats);
|
|
},
|
|
};
|
|
|
|
// Full variant: add WebTransport URL for direct relay connection
|
|
if (variant === 'full') {
|
|
clientOpts.url = location.origin.replace('http', 'https');
|
|
}
|
|
|
|
client = new ClientClass(clientOpts);
|
|
|
|
// Load WASM for variants that need it
|
|
if (client.loadWasm) {
|
|
try {
|
|
WZPCore.updateStatus('Loading WASM module...');
|
|
await client.loadWasm();
|
|
} catch (e) {
|
|
WZPCore.updateStatus('WASM load failed: ' + e.message);
|
|
ui.setConnected(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await client.connect();
|
|
} catch (e) {
|
|
WZPCore.updateStatus('Connection failed: ' + e.message);
|
|
ui.setConnected(false);
|
|
return;
|
|
}
|
|
|
|
// Start audio capture and playback
|
|
try {
|
|
capture = await WZPCore.connectCapture(audioCtx, function(pcmBuffer) {
|
|
if (!transmitting) return;
|
|
var pcm = new Int16Array(pcmBuffer);
|
|
WZPCore.updateLevel(pcm);
|
|
if (client) client.sendAudio(pcmBuffer);
|
|
});
|
|
|
|
playback = await WZPCore.connectPlayback(audioCtx);
|
|
} catch (e) {
|
|
WZPCore.updateStatus('Audio error: ' + e.message);
|
|
if (client) client.disconnect();
|
|
client = null;
|
|
ui.setConnected(false);
|
|
return;
|
|
}
|
|
|
|
ui.setConnected(true);
|
|
}
|
|
|
|
function doDisconnect() {
|
|
if (capture) { capture.stop(); capture = null; }
|
|
if (playback) { playback.stop(); playback = null; }
|
|
if (client) { client.disconnect(); client = null; }
|
|
|
|
var audioCtx = WZPCore.getAudioContext();
|
|
if (audioCtx && audioCtx.state !== 'closed') {
|
|
audioCtx.close();
|
|
}
|
|
|
|
WZPCore.updateStatus('');
|
|
WZPCore.updateStats('');
|
|
document.getElementById('levelBar').style.width = '0%';
|
|
|
|
ui.setConnected(false);
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|