v0.0.45: call ring tones + group calls

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-<groupname>)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-30 09:12:25 +04:00
parent 983afc5916
commit 2612d46f5c
4 changed files with 356 additions and 9 deletions

10
warzone/Cargo.lock generated
View File

@@ -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",

View File

@@ -9,7 +9,7 @@ members = [
]
[workspace.package]
version = "0.0.44"
version = "0.0.45"
edition = "2021"
license = "MIT"
rust-version = "1.75"

View File

@@ -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)"

View File

@@ -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 <name>)'); 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 {