v0.0.47: integrate 6 WZP audio variants into featherChat calls
startAudio() now dynamically loads the selected WZP client variant: - /audio-variant [pure|hybrid|full|ws|ws-fec|ws-full] - Loads variant JS from wzp-web's /audio/js/ path via Caddy - Falls back to inline pure implementation if variant fails to load - Variant persisted in localStorage across sessions - Call bar shows active variant: "In call [ws-fec] with 0x..." Variants: pure — raw PCM over WS (bridge needed, no WASM) hybrid — raw PCM + WASM FEC over WS (bridge needed) full — WebTransport + FEC + crypto (no bridge, future) ws — WZP protocol over WS (relay direct) ws-fec — WZP + WASM FEC over WS (relay direct) ws-full — WZP + FEC + E2E crypto over WS (relay direct) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.46"
|
||||
version = "0.0.47"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Core crypto & wire protocol for featherChat (Warzone messenger)"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user