ServeDir now falls back to index.html for unknown paths (SPA routing). https://host:port/manwe loads the page with room input pre-filled as "manwe". JS getRoom() already reads the path, now the page actually loads. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
349 lines
13 KiB
HTML
349 lines
13 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; }
|
|
.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</h1>
|
|
<p class="subtitle">Lossy VoIP Protocol</p>
|
|
<div class="room-input">
|
|
<label for="room">Room</label>
|
|
<input type="text" id="room" placeholder="enter room name" value="">
|
|
</div>
|
|
<button id="callBtn" onclick="toggleCall()">Connect</button>
|
|
<div class="controls" id="controls" style="display:none;">
|
|
<label><input type="checkbox" id="pttMode" onchange="togglePTT()"> 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>
|
|
const SAMPLE_RATE = 48000;
|
|
const FRAME_SIZE = 960;
|
|
|
|
let ws = null;
|
|
let audioCtx = null;
|
|
let mediaStream = null;
|
|
let captureNode = null;
|
|
let playbackNode = null;
|
|
let active = false;
|
|
let transmitting = true; // in open-mic mode, always transmitting
|
|
let pttMode = false;
|
|
let framesSent = 0;
|
|
let framesRecv = 0;
|
|
let startTime = 0;
|
|
let statsInterval = null;
|
|
|
|
// Use room from URL path or input field
|
|
function getRoom() {
|
|
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
|
if (path && path !== 'index.html') return path;
|
|
const hash = location.hash.replace('#', '');
|
|
if (hash) return hash;
|
|
return document.getElementById('room').value.trim() || 'default';
|
|
}
|
|
|
|
// Pre-fill room input from URL on page load
|
|
(function() {
|
|
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
|
if (path && path !== 'index.html') {
|
|
document.getElementById('room').value = path;
|
|
}
|
|
})();
|
|
|
|
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
|
function setStats(msg) { document.getElementById('stats').textContent = msg; }
|
|
|
|
function toggleCall() {
|
|
if (active) stopCall();
|
|
else startCall();
|
|
}
|
|
|
|
async function startCall() {
|
|
const btn = document.getElementById('callBtn');
|
|
const room = getRoom();
|
|
if (!room) { setStatus('Enter a room name'); return; }
|
|
|
|
btn.disabled = true;
|
|
setStatus('Requesting microphone...');
|
|
|
|
try {
|
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: { sampleRate: SAMPLE_RATE, channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
|
});
|
|
} catch(e) {
|
|
setStatus('Mic access denied: ' + e.message);
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
|
|
|
// Connect WebSocket with room name
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
|
setStatus('Connecting to room: ' + room + '...');
|
|
|
|
ws = new WebSocket(wsUrl);
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.onopen = async () => {
|
|
setStatus('Connected to room: ' + room);
|
|
btn.textContent = 'Disconnect';
|
|
btn.classList.add('active');
|
|
btn.disabled = false;
|
|
active = true;
|
|
framesSent = 0;
|
|
framesRecv = 0;
|
|
startTime = Date.now();
|
|
showControls(true);
|
|
await startAudioCapture();
|
|
await startAudioPlayback();
|
|
startStatsUpdate();
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const pcmData = new Int16Array(event.data);
|
|
framesRecv++;
|
|
playAudio(pcmData);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
if (active) {
|
|
setStatus('Disconnected — reconnecting to ' + room + '...');
|
|
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
|
|
} else {
|
|
setStatus('Disconnected');
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
if (active) {
|
|
setStatus('Error — reconnecting...');
|
|
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
|
|
}
|
|
};
|
|
}
|
|
|
|
function stopCall() {
|
|
active = false;
|
|
const btn = document.getElementById('callBtn');
|
|
btn.textContent = 'Connect';
|
|
btn.classList.remove('active');
|
|
btn.disabled = false;
|
|
showControls(false);
|
|
cleanupAudio();
|
|
if (ws) { ws.close(); ws = null; }
|
|
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
|
|
setStatus('');
|
|
setStats('');
|
|
}
|
|
|
|
function cleanupAudio() {
|
|
if (captureNode) { captureNode.disconnect(); captureNode = null; }
|
|
if (playbackNode) { playbackNode.disconnect(); playbackNode = null; }
|
|
if (audioCtx) { audioCtx.close(); audioCtx = null; workletLoaded = false; }
|
|
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
|
|
}
|
|
|
|
let workletLoaded = false;
|
|
|
|
async function loadWorkletModule() {
|
|
if (workletLoaded) return true;
|
|
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
|
|
console.warn('AudioWorklet API not supported in this browser — using ScriptProcessorNode fallback');
|
|
return false;
|
|
}
|
|
try {
|
|
await audioCtx.audioWorklet.addModule('audio-processor.js');
|
|
workletLoaded = true;
|
|
return true;
|
|
} catch(e) {
|
|
console.warn('AudioWorklet module failed to load — using ScriptProcessorNode fallback:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function startAudioCapture() {
|
|
const source = audioCtx.createMediaStreamSource(mediaStream);
|
|
const hasWorklet = await loadWorkletModule();
|
|
|
|
if (hasWorklet) {
|
|
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
|
|
captureNode.port.onmessage = (e) => {
|
|
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
|
|
ws.send(e.data);
|
|
framesSent++;
|
|
|
|
// Level meter from the PCM data
|
|
const pcm = new Int16Array(e.data);
|
|
let max = 0;
|
|
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
|
|
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
|
|
};
|
|
source.connect(captureNode);
|
|
captureNode.connect(audioCtx.destination); // needed to keep worklet alive
|
|
} else {
|
|
// Fallback to ScriptProcessorNode (deprecated but widely supported)
|
|
console.warn('Capture: using ScriptProcessorNode fallback');
|
|
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
|
|
let acc = new Float32Array(0);
|
|
captureNode.onaudioprocess = (ev) => {
|
|
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
|
|
const input = ev.inputBuffer.getChannelData(0);
|
|
const n = new Float32Array(acc.length + input.length);
|
|
n.set(acc); n.set(input, acc.length); acc = n;
|
|
while (acc.length >= FRAME_SIZE) {
|
|
const frame = acc.slice(0, FRAME_SIZE); acc = acc.slice(FRAME_SIZE);
|
|
const pcm = new Int16Array(FRAME_SIZE);
|
|
for (let i = 0; i < FRAME_SIZE; i++) pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
|
let max = 0;
|
|
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
|
|
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
|
|
ws.send(pcm.buffer);
|
|
framesSent++;
|
|
}
|
|
};
|
|
source.connect(captureNode);
|
|
captureNode.connect(audioCtx.destination);
|
|
}
|
|
}
|
|
|
|
async function startAudioPlayback() {
|
|
const hasWorklet = await loadWorkletModule();
|
|
|
|
if (hasWorklet) {
|
|
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
|
|
playbackNode.connect(audioCtx.destination);
|
|
} else {
|
|
console.warn('Playback: using scheduled BufferSource fallback');
|
|
playbackNode = null; // will use createBufferSource fallback in playAudio()
|
|
}
|
|
}
|
|
|
|
let nextPlayTime = 0;
|
|
|
|
function playAudio(pcmInt16) {
|
|
if (!audioCtx) return;
|
|
|
|
if (playbackNode && playbackNode.port) {
|
|
// AudioWorklet path — send Int16 PCM directly to the worklet for conversion
|
|
playbackNode.port.postMessage(pcmInt16.buffer, [pcmInt16.buffer]);
|
|
} else {
|
|
// Fallback: scheduled BufferSource (convert Int16 -> Float32 on main thread)
|
|
const floatData = new Float32Array(pcmInt16.length);
|
|
for (let i = 0; i < pcmInt16.length; i++) {
|
|
floatData[i] = pcmInt16[i] / 32768.0;
|
|
}
|
|
const buffer = audioCtx.createBuffer(1, floatData.length, SAMPLE_RATE);
|
|
buffer.getChannelData(0).set(floatData);
|
|
const source = audioCtx.createBufferSource();
|
|
source.buffer = buffer;
|
|
source.connect(audioCtx.destination);
|
|
const now = audioCtx.currentTime;
|
|
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
|
|
nextPlayTime = now + 0.02;
|
|
}
|
|
source.start(nextPlayTime);
|
|
nextPlayTime += buffer.duration;
|
|
}
|
|
}
|
|
|
|
function startStatsUpdate() {
|
|
statsInterval = setInterval(() => {
|
|
if (!active) { clearInterval(statsInterval); return; }
|
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
setStats(elapsed + 's | sent: ' + framesSent + ' | recv: ' + framesRecv);
|
|
}, 1000);
|
|
}
|
|
|
|
// --- Push-to-talk ---
|
|
|
|
function togglePTT() {
|
|
pttMode = document.getElementById('pttMode').checked;
|
|
const btn = document.getElementById('pttBtn');
|
|
if (pttMode) {
|
|
transmitting = false;
|
|
btn.style.display = 'block';
|
|
} else {
|
|
transmitting = true;
|
|
btn.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// PTT button — hold to talk (mouse + touch)
|
|
document.getElementById('pttBtn').addEventListener('mousedown', () => { startTransmit(); });
|
|
document.getElementById('pttBtn').addEventListener('mouseup', () => { stopTransmit(); });
|
|
document.getElementById('pttBtn').addEventListener('mouseleave', () => { stopTransmit(); });
|
|
document.getElementById('pttBtn').addEventListener('touchstart', (e) => { e.preventDefault(); startTransmit(); });
|
|
document.getElementById('pttBtn').addEventListener('touchend', (e) => { e.preventDefault(); stopTransmit(); });
|
|
|
|
// Spacebar PTT
|
|
document.addEventListener('keydown', (e) => { if (pttMode && active && e.code === 'Space' && !e.repeat) { e.preventDefault(); startTransmit(); } });
|
|
document.addEventListener('keyup', (e) => { if (pttMode && active && e.code === 'Space') { e.preventDefault(); stopTransmit(); } });
|
|
|
|
function startTransmit() {
|
|
if (!pttMode || !active) return;
|
|
transmitting = true;
|
|
document.getElementById('pttBtn').classList.add('transmitting');
|
|
document.getElementById('pttBtn').textContent = 'Transmitting...';
|
|
}
|
|
|
|
function stopTransmit() {
|
|
if (!pttMode) return;
|
|
transmitting = false;
|
|
document.getElementById('pttBtn').classList.remove('transmitting');
|
|
document.getElementById('pttBtn').textContent = 'Hold to Talk';
|
|
}
|
|
|
|
// Show controls when connected
|
|
function showControls(show) {
|
|
document.getElementById('controls').style.display = show ? 'flex' : 'none';
|
|
if (!show) {
|
|
document.getElementById('pttBtn').style.display = 'none';
|
|
pttMode = false;
|
|
transmitting = true;
|
|
}
|
|
}
|
|
|
|
// Set room from URL on load
|
|
window.addEventListener('load', () => {
|
|
const room = getRoom();
|
|
if (room && room !== 'default') {
|
|
document.getElementById('room').value = room;
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|