aboutsummaryrefslogtreecommitdiffstats
path: root/src/calls.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 09:55:40 -0700
committerAdam Soltys <[email protected]>2026-10-18 09:55:40 -0700
commitd86f7cb039bbd4bf2873be332af278bc6372aeae (patch)
tree522cc403f44619ba694f51953ea7578bc310eb41 /src/calls.js
parent5869fa2ce351efa485ae6547d618a216b09a47c1 (diff)
downloadcoinosv3-d86f7cb039bbd4bf2873be332af278bc6372aeae.tar.gz
coinosv3-d86f7cb039bbd4bf2873be332af278bc6372aeae.zip
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).HEADmaster
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'src/calls.js')
-rw-r--r--src/calls.js66
1 files changed, 54 insertions, 12 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: {