From 2612d46f5cee719b8809b1db2a3cb2c0e6fa4f7c Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Mon, 30 Mar 2026 09:12:25 +0400 Subject: [PATCH] v0.0.45: call ring tones + group calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct call ringing: - Incoming: oscillator ring tone (440/480Hz, on/off pattern) - Outgoing: ringback tone (2s on, 4s off) while waiting for answer - Browser Notification API for background incoming calls - All tones stop on answer/reject/hangup - Notification permission requested on first click Group calls: - /gcall — start a group call (notifies all group members) - /gjoin — join an active group call - /gleave-call — leave group call - /gmute — toggle per-group call notification mute (localStorage) - Participant tracking (joined/left messages with count) - Group call signal via group message broadcast (JSON type: group_call) - Call bar shows "Join Call" button when group call is active - Audio via same WZP bridge (room = gc-) Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 +- warzone/Cargo.toml | 2 +- warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../crates/warzone-server/src/routes/web.rs | 351 +++++++++++++++++- 4 files changed, 356 insertions(+), 9 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 1a43439..11a15f2 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.44" +version = "0.0.45" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.44" +version = "0.0.45" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.44" +version = "0.0.45" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.44" +version = "0.0.45" dependencies = [ "anyhow", "axum", @@ -3054,7 +3054,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.44" +version = "0.0.45" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 075c793..35be02e 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.44" +version = "0.0.45" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index c23f065..02ed18c 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.44" +version = "0.0.45" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 2742672..f755713 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -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-v26'; +const CACHE = 'wz-v27'; 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.44'; +const VERSION = '0.0.45'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -601,6 +601,10 @@ function connectWebSocket() { // Text frame — could be a bot message or missed call notification try { const json = JSON.parse(event.data); + if (json.type === 'group_call') { + handleGroupCallSignal(json); + return; + } if (json.type === 'missed_call') { addSys('Missed call from ' + (json.data?.caller_fp || 'unknown')); return; @@ -798,6 +802,11 @@ async function handleIncomingMessage(bytes) { try { const str = new TextDecoder().decode(bytes); const json = JSON.parse(str); + // Check for group call signal + if (json.type === 'group_call') { + handleGroupCallSignal(json); + return; + } if (json.type === 'file_header') { handleFileHeader(json); return; } if (json.type === 'file_chunk') { handleFileChunk(json); return; } // Handle bot messages (plaintext JSON from bot API) @@ -1083,6 +1092,7 @@ async function enterChat() { addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above'); addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /friend · /file · /info'); + addSys('/call · /gcall · /gjoin · /gleave-call · /gmute'); // Show system bots if available try { @@ -1238,6 +1248,11 @@ async function sendToGroup(groupName, text) { let callState = 'idle'; // idle, calling, ringing, active let callPeer = null; +// Group call state +let groupCallRoom = null; // Current group call room name +let groupCallGroup = null; // Group name for active group call +let groupCallParticipants = []; // List of fingerprints in the call + function updateCallUI() { const bar = document.getElementById('call-bar'); const status = document.getElementById('call-status'); @@ -1311,6 +1326,7 @@ async function startCall() { payload.set(signalBytes, header.length); ws.send(payload); addSys('Calling ' + peer.slice(0, 16) + '...'); + startRingback(); } } catch(e) { addSys('Call failed: ' + e.message); @@ -1321,6 +1337,7 @@ async function startCall() { function acceptCall() { if (callState !== 'ringing') return; + stopRingtone(); callState = 'active'; updateCallUI(); @@ -1341,6 +1358,7 @@ function acceptCall() { function rejectCall() { if (callState !== 'ringing') return; + stopRingtone(); try { const signalBytes = create_call_signal(wasmIdentity, 'reject', '', normFP(callPeer)); if (ws && ws.readyState === WebSocket.OPEN) { @@ -1361,6 +1379,7 @@ function rejectCall() { function hangupCall() { if (callState === 'idle') return; + stopRingtone(); try { const target = callPeer ? normFP(callPeer) : ''; const signalBytes = create_call_signal(wasmIdentity, 'hangup', '', target); @@ -1389,6 +1408,8 @@ function handleCallSignal(signal) { callState = 'ringing'; callPeer = sender; updateCallUI(); + startRingtone(); + sendCallNotification('Incoming Call', 'Call from ' + (peerEthAddr || sender.slice(0, 16))); addSys('\u{1F4DE} Incoming call from ' + sender.slice(0, 16)); // Play sound or vibrate try { navigator.vibrate && navigator.vibrate([200, 100, 200]); } catch(e) {} @@ -1398,6 +1419,7 @@ function handleCallSignal(signal) { if (callState === 'calling') { callState = 'active'; updateCallUI(); + stopRingtone(); addSys('Call connected!'); startAudio(); } @@ -1405,6 +1427,7 @@ function handleCallSignal(signal) { case 'hangup': case 'reject': if (callState !== 'idle') { + stopRingtone(); stopAudio(); addSys('Call ended' + (type === 'reject' ? ' (rejected)' : '')); callState = 'idle'; @@ -1438,6 +1461,92 @@ let mediaStream = null; let captureNode = null; let playbackNode = null; +// ═══════════════════════════════════════════════ +// SECTION: Ring Tones (generated via Web Audio) +// ═══════════════════════════════════════════════ + +let ringCtx = null; +let ringOsc = null; +let ringGain = null; +let ringInterval = null; + +function startRingtone() { + stopRingtone(); + try { + ringCtx = new AudioContext(); + ringGain = ringCtx.createGain(); + ringGain.connect(ringCtx.destination); + ringGain.gain.value = 0; + + ringOsc = ringCtx.createOscillator(); + ringOsc.type = 'sine'; + ringOsc.frequency.value = 440; + ringOsc.connect(ringGain); + ringOsc.start(); + + // Ring pattern: 400ms on, 200ms off, 400ms on, 1500ms off + let step = 0; + const pattern = [ + { gain: 0.3, freq: 440, dur: 400 }, + { gain: 0, freq: 440, dur: 200 }, + { gain: 0.3, freq: 480, dur: 400 }, + { gain: 0, freq: 480, dur: 1500 }, + ]; + function tick() { + const p = pattern[step % pattern.length]; + ringGain.gain.setValueAtTime(p.gain, ringCtx.currentTime); + ringOsc.frequency.setValueAtTime(p.freq, ringCtx.currentTime); + step++; + ringInterval = setTimeout(tick, p.dur); + } + tick(); + } catch(e) { dbg('Ring tone error:', e); } +} + +function startRingback() { + stopRingtone(); + try { + ringCtx = new AudioContext(); + ringGain = ringCtx.createGain(); + ringGain.connect(ringCtx.destination); + ringGain.gain.value = 0; + + ringOsc = ringCtx.createOscillator(); + ringOsc.type = 'sine'; + ringOsc.frequency.value = 440; + ringOsc.connect(ringGain); + ringOsc.start(); + + // Ringback: 2s on, 4s off (US standard) + let on = true; + function tick() { + ringGain.gain.setValueAtTime(on ? 0.15 : 0, ringCtx.currentTime); + ringOsc.frequency.setValueAtTime(on ? 440 : 480, ringCtx.currentTime); + on = !on; + ringInterval = setTimeout(tick, on ? 4000 : 2000); + } + tick(); + } catch(e) { dbg('Ringback error:', e); } +} + +function stopRingtone() { + if (ringInterval) { clearTimeout(ringInterval); ringInterval = null; } + if (ringOsc) { try { ringOsc.stop(); } catch(e) {} ringOsc = null; } + if (ringGain) { ringGain = null; } + if (ringCtx) { ringCtx.close().catch(() => {}); ringCtx = null; } +} + +function sendCallNotification(title, body) { + if (document.hasFocus()) return; + if (Notification.permission === 'granted') { + const n = new Notification(title, { body, icon: '/icon.svg', tag: 'wz-call', requireInteraction: true }); + n.onclick = () => { window.focus(); n.close(); }; + return n; + } else if (Notification.permission !== 'denied') { + Notification.requestPermission(); + } +} + async function startAudio() { // Fetch relay config (includes auth token) let relayAddr, authToken; @@ -1574,6 +1683,223 @@ function stopAudio() { playbackNode = null; } +// ═══════════════════════════════════════════════ +// SECTION: Group Calls +// ═══════════════════════════════════════════════ + +async function startGroupCall() { + const peer = $peerInput.value.trim(); + if (!peer || !peer.startsWith('#')) { addSys('Switch to a group first (/g )'); return; } + const gname = peer.replace('#', ''); + + groupCallGroup = gname; + groupCallRoom = 'gc-' + gname; + groupCallParticipants = [normFP(myFingerprint)]; + updateGroupCallUI(); + + // Notify group members + const msg = JSON.stringify({ type: 'group_call', action: 'started', group: gname, room: groupCallRoom, from: normFP(myFingerprint) }); + await fetch(SERVER + '/v1/groups/' + gname + '/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: normFP(myFingerprint), messages: [{ to: '__broadcast__', message: Array.from(new TextEncoder().encode(msg)) }] }) + }).catch(() => {}); + + addSys('Group call started in #' + gname + ' \u2014 waiting for others to join'); + await joinGroupCallAudio(); +} + +async function joinGroupCall(gname, room) { + groupCallGroup = gname; + groupCallRoom = room; + if (!groupCallParticipants.includes(normFP(myFingerprint))) { + groupCallParticipants.push(normFP(myFingerprint)); + } + updateGroupCallUI(); + + // Notify others we joined + const msg = JSON.stringify({ type: 'group_call', action: 'joined', group: gname, room: room, from: normFP(myFingerprint) }); + await fetch(SERVER + '/v1/groups/' + gname + '/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: normFP(myFingerprint), messages: [{ to: '__broadcast__', message: Array.from(new TextEncoder().encode(msg)) }] }) + }).catch(() => {}); + + addSys('Joined group call in #' + gname); + await joinGroupCallAudio(); +} + +async function joinGroupCallAudio() { + // Reuse the audio bridge \u2014 connect to wzp-web with group room name + let relayAddr, token; + try { + const resp = await fetch(SERVER + '/v1/wzp/relay-config'); + const data = await resp.json(); + relayAddr = data.relay_addr; + token = data.token; + } catch(e) { addSys('Audio: cannot get relay config'); return; } + + try { + mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { sampleRate: 48000, channelCount: 1, echoCancellation: true, noiseSuppression: true } + }); + } catch(e) { addSys('Audio: mic access denied'); return; } + + audioCtx = new AudioContext({ sampleRate: 48000 }); + const host = relayAddr.replace(/^https?:\/\//, ''); + const proto = host.startsWith('localhost') || host.startsWith('127.') ? 'ws:' : 'wss:'; + const wsUrl = proto + '//' + host + '/ws/' + groupCallRoom; + + audioWs = new WebSocket(wsUrl); + audioWs.binaryType = 'arraybuffer'; + + audioWs.onopen = async () => { + audioWs.send(JSON.stringify({ type: 'auth', token })); + addSys('Audio: connected to group call room'); + + const source = audioCtx.createMediaStreamSource(mediaStream); + const processor = audioCtx.createScriptProcessor(1024, 1, 1); + let captureBuffer = new Float32Array(0); + + processor.onaudioprocess = (e) => { + if (!groupCallRoom || !audioWs || audioWs.readyState !== WebSocket.OPEN) 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))); + } + audioWs.send(pcm.buffer); + } + }; + + source.connect(processor); + processor.connect(audioCtx.destination); + captureNode = processor; + }; + + audioWs.onmessage = (event) => { + if (!audioCtx || typeof event.data === 'string') return; + const pcm = new Int16Array(event.data); + if (pcm.length === 0) 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(); + }; + + audioWs.onclose = () => { if (groupCallRoom) addSys('Audio: group call disconnected'); }; + audioWs.onerror = () => { addSys('Audio: group call connection error'); }; +} + +function leaveGroupCall() { + if (!groupCallGroup) return; + const gname = groupCallGroup; + + // Notify others + const msg = JSON.stringify({ type: 'group_call', action: 'left', group: gname, room: groupCallRoom, from: normFP(myFingerprint) }); + fetch(SERVER + '/v1/groups/' + gname + '/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: normFP(myFingerprint), messages: [{ to: '__broadcast__', message: Array.from(new TextEncoder().encode(msg)) }] }) + }).catch(() => {}); + + stopAudio(); + addSys('Left group call in #' + gname); + groupCallRoom = null; + groupCallGroup = null; + groupCallParticipants = []; + updateGroupCallUI(); + + // Reset call button + const btnCall = document.getElementById('btn-call'); + btnCall.textContent = '\u{1F4DE}'; + btnCall.onclick = () => startCall(); +} + +function handleGroupCallSignal(data) { + // Check if notifications are muted for this group + const muted = JSON.parse(localStorage.getItem('wz-gcall-mute') || '{}'); + + switch(data.action) { + case 'started': + if (data.from === normFP(myFingerprint)) return; // ignore own + if (!groupCallParticipants.includes(data.from)) groupCallParticipants = [data.from]; + if (!muted[data.group]) { + addSys('\u{1F4DE} Group call started in #' + data.group + ' \u2014 type /gjoin to join'); + sendCallNotification('Group Call', 'Call started in #' + data.group); + } + groupCallGroup = data.group; + groupCallRoom = data.room; + updateGroupCallUI(); + break; + case 'joined': + if (data.from === normFP(myFingerprint)) return; + if (!groupCallParticipants.includes(data.from)) groupCallParticipants.push(data.from); + addSys(data.from.slice(0, 8) + '... joined #' + data.group + ' call (' + groupCallParticipants.length + ' participants)'); + updateGroupCallUI(); + break; + case 'left': + if (data.from === normFP(myFingerprint)) return; + groupCallParticipants = groupCallParticipants.filter(fp => fp !== data.from); + addSys(data.from.slice(0, 8) + '... left #' + data.group + ' call (' + groupCallParticipants.length + ' participants)'); + if (groupCallParticipants.length === 0) { + addSys('Group call in #' + data.group + ' ended (no participants)'); + if (groupCallRoom) leaveGroupCall(); + } + updateGroupCallUI(); + break; + } +} + +function updateGroupCallUI() { + const bar = document.getElementById('call-bar'); + const status = document.getElementById('call-status'); + const btnCall = document.getElementById('btn-call'); + const btnHangup = document.getElementById('btn-hangup'); + + if (groupCallRoom && audioWs) { + // We're in a group call + bar.classList.add('active'); + btnCall.style.display = 'none'; + btnHangup.style.display = ''; + document.getElementById('btn-accept').style.display = 'none'; + document.getElementById('btn-reject').style.display = 'none'; + status.textContent = '\u{1F50A} Group call #' + groupCallGroup + ' (' + groupCallParticipants.length + ' in call)'; + status.className = 'call-status'; + } else if (groupCallRoom && !audioWs) { + // Group call exists but we haven't joined + bar.classList.add('active'); + btnCall.style.display = ''; + btnCall.textContent = 'Join Call'; + btnCall.onclick = () => joinGroupCall(groupCallGroup, groupCallRoom); + btnHangup.style.display = 'none'; + document.getElementById('btn-accept').style.display = 'none'; + document.getElementById('btn-reject').style.display = 'none'; + status.textContent = '\u{1F4DE} Group call in #' + groupCallGroup + ' (' + groupCallParticipants.length + ' in call)'; + status.className = 'call-status incoming-call'; + } + // If no group call, let the regular updateCallUI handle it +} + +function toggleGroupCallMute(gname) { + const muted = JSON.parse(localStorage.getItem('wz-gcall-mute') || '{}'); + muted[gname] = !muted[gname]; + localStorage.setItem('wz-gcall-mute', JSON.stringify(muted)); + addSys('Group call notifications for #' + gname + ': ' + (muted[gname] ? 'muted' : 'unmuted')); +} + // ═══════════════════════════════════════════════ // SECTION: Command Handlers // ═══════════════════════════════════════════════ @@ -1711,6 +2037,19 @@ async function doSend() { updateCallUI(); return; } + if (text === '/gcall') { startGroupCall(); return; } + if (text === '/gjoin') { + if (groupCallRoom) joinGroupCall(groupCallGroup, groupCallRoom); + else addSys('No active group call'); + return; + } + if (text === '/gleave-call' || text === '/gleave-audio') { leaveGroupCall(); return; } + if (text.startsWith('/gmute')) { + const peer = $peerInput.value.trim(); + if (peer && peer.startsWith('#')) toggleGroupCallMute(peer.replace('#','')); + else addSys('Switch to a group first'); + return; + } if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; } if (text.startsWith('/gjoin ')) { await groupJoin(text.slice(7).trim()); return; } if (text === '/gleave') { @@ -1943,6 +2282,14 @@ window.addEventListener('beforeinstallprompt', e => { addSys('Tip: install as app for fullscreen + notifications. Type /install'); }); +// Request notification permission on first user interaction +if ('Notification' in window && Notification.permission === 'default') { + document.addEventListener('click', function reqNotif() { + Notification.requestPermission(); + document.removeEventListener('click', reqNotif); + }, { once: true }); +} + // Initialize WASM and auto-load (async function() { try {