From d86f7cb039bbd4bf2873be332af278bc6372aeae Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 18 Oct 2026 09:55:40 -0700 Subject: Answering a call no longer takes seconds: while it rings, the connection is made with the caller's offer and its routes (TURN's too) gather, and the caller's relays and TURN credentials are looked up — Answer only adds the microphone and camera and replies (3.2 s → 0.2 s on a simulated network). An open conversation warms the same for calling out. A remote signer's offer or answer waits for a direct and a relayed route or 1.5 s, not for all of them (up to 4 s). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg --- src/calls.js | 66 +++++++++++++++++++++++++++++++++++++++--------- src/features/messages.js | 15 ++++++++++- tools/call-test.js | 12 +++++++-- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/calls.js b/src/calls.js index 66d6f16..6f18aec 100644 --- a/src/calls.js +++ b/src/calls.js @@ -71,6 +71,8 @@ export const candidateJson = (c) => JSON.stringify({ candidate: c.candidate, sdp // trickle() false for a signer that's slow or asks each time (a // bunker, Amber): the candidates go inside the offer or // answer instead of one signed message each +// warm(pk) a call with them may be coming: look up what sending to +// them takes (their relays, TURN) ahead of it // RTCPeerConnection (tests may pass their own) export function createCalls(env) { const PC = env.RTCPeerConnection || globalThis.RTCPeerConnection; @@ -103,8 +105,10 @@ export function createCalls(env) { endTimer = setTimeout(() => { if (call === c) { call = null; changed(); } }, ENDED_MS); } + // (iceCandidatePoolSize: the routes — TURN's included, which take a round + // trip to allocate — are gathered from the start, not from the answer on) async function connection(c) { - const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle' }); + const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle', iceCandidatePoolSize: 2 }); c.pc = pc; c.remote = new MediaStream(); pc.ontrack = (e) => { for (const t of (e.streams[0] || { getTracks: () => [e.track] }).getTracks()) if (!c.remote.getTracks().includes(t)) c.remote.addTrack(t); changed(); }; @@ -121,17 +125,47 @@ export function createCalls(env) { if (s === 'connected' && c.state !== 'connected') { c.state = 'connected'; c.connectedAt = Date.now(); clearTimeout(ringTimer); changed(); } else if (s === 'failed') end('failed', c); }; - for (const t of c.local.getTracks()) pc.addTrack(t, c.local); + if (c.local) for (const t of c.local.getTracks()) pc.addTrack(t, c.local); return pc; } - // without trickle: the description once its candidates are gathered (a few seconds at most) + // While a call rings: its connection made and the offer set, the routes + // gathering — everything but the microphone and camera, which wait for + // Answer. Answering then only adds them and replies (it used to do all of + // this after the tap, seconds of "Connecting…"). + async function prepare(c) { + const pc = await connection(c); + if (call !== c || c.state === 'ended') { try { pc.close(); } catch {} return null; } + await pc.setRemoteDescription({ type: 'offer', sdp: c.offer }); + c.remoteSet = true; + await addCandidates(c); + return pc; + } + // our tracks into the transceivers the offer made + function attachLocal(c) { + const trs = c.pc.getTransceivers(); + for (const track of c.local.getTracks()) { + const tr = trs.find((x) => !x.sender.track && x.receiver.track && x.receiver.track.kind === track.kind && !x.stopped); + if (tr) { + tr.sender.replaceTrack(track); + tr.direction = 'sendrecv'; + try { tr.sender.setStreams && tr.sender.setStreams(c.local); } catch {} + } else c.pc.addTrack(track, c.local); + } + } + // Without trickle: the description once it holds routes enough — one + // through the router (srflx) and one through TURN (relay) — or after a + // second and a half. Waiting for gathering to finish took up to 4 s (TURN + // over TCP and TLS is slow to give up), all of it after the tap. async function described(c) { const pc = c.pc; - if (!c.trickle && pc.iceGatheringState !== 'complete') { + const enough = () => pc.iceGatheringState === 'complete' + || (/ typ srflx/.test(pc.localDescription.sdp) && / typ relay/.test(pc.localDescription.sdp)); + if (!c.trickle && !enough()) { await new Promise((resolve) => { - const done = () => { if (pc.iceGatheringState === 'complete') resolve(); }; - pc.addEventListener('icegatheringstatechange', done); - setTimeout(resolve, 4000); + const check = () => { if (enough()) resolve(); }; + pc.addEventListener('icegatheringstatechange', check); + pc.addEventListener('icecandidate', check); + setTimeout(resolve, 1500); }); } return pc.localDescription.sdp; @@ -169,12 +203,18 @@ export function createCalls(env) { c.state = 'connecting'; changed(); try { - c.local = await env.media(c.type, c.facing); + const [local, ready] = await Promise.all([env.media(c.type, c.facing), (c.prep || Promise.resolve(null)).catch(() => null)]); + c.local = local; if (call !== c) { stopMedia(c); return; } - const pc = await connection(c); - await pc.setRemoteDescription({ type: 'offer', sdp: c.offer }); - c.remoteSet = true; - await addCandidates(c); + let pc = ready; + if (pc && pc.signalingState === 'have-remote-offer') attachLocal(c); + else { // nothing made while it rang (or it failed): all of it now + try { pc && pc.close(); } catch {} + pc = await connection(c); + await pc.setRemoteDescription({ type: 'offer', sdp: c.offer }); + c.remoteSet = true; + await addCandidates(c); + } await pc.setLocalDescription(await pc.createAnswer()); const signed = await env.sign(callEvent(CALL.ANSWER, c.peer, c.id, await described(c))); await env.send(c.peer, signed); @@ -261,6 +301,8 @@ export function createCalls(env) { const n = call = { id, peer: from, type, dir: 'in', state: 'incoming', offer: ev.content, ice: [], at: Date.now(), offerAt: ev.created_at * 1000, muted: false, camOff: false, facing: 'user' }; ringTimer = setTimeout(() => { if (call === n && n.state === 'incoming') end('missed', n); }, Math.max(5000, RING_MS - (Date.now() - ev.created_at * 1000))); changed(); + try { if (env.warm) env.warm(from); } catch {} + n.prep = prepare(n).catch(() => null); return; } case CALL.ANSWER: { diff --git a/src/features/messages.js b/src/features/messages.js index 54c9cfe..e239aee 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -9120,6 +9120,7 @@ export function messagesFeature(ctx) { mayRing: async (pk) => mayRing(pk), changed: () => { callRing(); render(); }, ended: (rec) => { logCall(rec).catch(() => {}); }, + warm: (pk) => callWarm(pk), // a key of our own signs each candidate at once; a remote signer gets // them all inside the offer or answer instead trickle: () => { try { if (localStorage.getItem('coinos-call-trickle') === '0') return false; } catch {} const id = hook('nostrLoginIdentity'); return !id || id.signer instanceof Uint8Array; }, @@ -9238,6 +9239,17 @@ export function messagesFeature(ctx) { } } catch {} } + // A call with them may be coming (it rings, or their conversation is open): + // their relays looked up, TURN fetched — the seconds the first send or + // answer used to spend on it. Not through Amber: a prompt to open a chat. + const warmed = new Set(); + function callWarm(pk) { + if (!pk || warmed.has(pk)) return; + warmed.add(pk); + inboxFor(pk).catch(() => {}); + const login = hook('nostrLoginIdentity'); + if (!(login && login.signer && login.signer.interactive)) callIce().catch(() => {}); + } function startCall(peer, type) { if (typeof RTCPeerConnection === 'undefined' || !navigator.mediaDevices?.getUserMedia) { toast(t('callUnsupported')); return; } const cur = callsNow().call; @@ -16467,6 +16479,7 @@ export function messagesFeature(ctx) { const peer = ui.msgPeer; if (!peer) { ui.msgView = 'home'; return homeView(); } const thread = threads.get(peer) || new Map(); + if (!isMe(peer)) setTimeout(() => callWarm(peer), 1500); // a call may be next const msgs = foldCallLogs([...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at)); // a first message may go by White Noise: have their KeyPackage looked up before Send wnKnow(peer); @@ -17312,7 +17325,7 @@ export function messagesFeature(ctx) { clearTimeout(drainTimer); pendingWraps.clear(); dmStarted = false; - if (calls) { try { calls.stop(); } catch {} calls = null; callInbox.clear(); iceCache = null; callRing(); } + if (calls) { try { calls.stop(); } catch {} calls = null; callInbox.clear(); iceCache = null; warmed.clear(); callRing(); } listsSynced = false; if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; } clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = []; diff --git a/tools/call-test.js b/tools/call-test.js index 7e9a99c..5bfa38c 100644 --- a/tools/call-test.js +++ b/tools/call-test.js @@ -14,6 +14,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; const PORT = 5305; +const SLOW = +(process.env.SLOW || 0); if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed'); // a small relay: keeps what isn't ephemeral, sends each event to the subscriptions it matches @@ -25,7 +26,9 @@ const server = Bun.serve({ port: PORT, fetch: async (req, srv) => { const path = new URL(req.url).pathname; if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 }); - if (path === '/live/turn') return Response.json({ iceServers: [], ttl: 600 }); // two tabs on one machine: no TURN needed + // two tabs on one machine: no TURN needed (SLOW=ms makes it, and the relay + // lookups, take as long as they do out there) + if (path === '/live/turn') { if (SLOW) await sleep(SLOW); return Response.json({ iceServers: [], ttl: 600 }); } const f = Bun.file('dist' + path); return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html')); }, @@ -39,6 +42,7 @@ const server = Bun.serve({ port: PORT, if (!(e.kind >= 20000 && e.kind < 30000)) stored.push(e); for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match({ ...f, since: undefined }, e))) s.ws.send(JSON.stringify(['EVENT', s.id, e])); } else if (m[0] === 'REQ') { + if (SLOW && m.slice(2).some((f) => (f.kinds || []).includes(10050))) { setTimeout(() => ws.send(JSON.stringify(['EOSE', m[1]])), SLOW); return; } const s = { ws, id: m[1], filters: m.slice(2) }; ws.data.subs.set(m[1], s); subs.add(s); for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e])); @@ -106,8 +110,12 @@ try { check('the caller sees it calling, their own camera on', await waitFor(A.page, () => /calling/i.test(document.querySelector('.call-overlay .call-status')?.textContent || '') && document.querySelector('video.call-local')?.videoWidth > 0, 10000)); check('the callee, on their wallet page, is rung: who, and a video call', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText) && /video call/i.test(document.querySelector('.call-overlay').innerText), 15000)); check('everything went as ephemeral gift wraps', kindsSeen.length > 0 && !kindsSeen.some((k) => k >= 25050 && k <= 25055) && kindsSeen.includes(21059)); + if (process.env.RING_WAIT) await sleep(+process.env.RING_WAIT); // a person reaching for the phone + const tAccept = Date.now(); await B.page.evaluate(() => document.querySelector('.call-accept').click()); - check('answered: both connected, the clock running', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000) && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000)); + await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000); + const answerMs = Date.now() - tAccept; + check('answered: both connected, the clock running (' + answerMs + ' ms from Answer)', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000) && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000)); await sleep(1500); const ma = await media(A.page), mb = await media(B.page); check('each sees the other\'s picture and gets their sound', ma.video && ma.audio && mb.video && mb.audio, JSON.stringify({ ma, mb })); -- cgit v1.2.3